Commit graph

795 commits

Author SHA1 Message Date
Dragon
4ec9f63d0a
feat(core): add CodeModeOnly tool execution (#10607)
* feat(core): add CodeModeOnly tool execution

* fix(core): preserve CodeModeOnly execution semantics

* fix(core): drop the tool_search reminder in CodeModeOnly

CodeModeOnly hides `tool_search` and binds every deferred tool — schema
included — into the `exec` description, so progressive discovery has
nothing left to do. The session prelude still announced those tools as
"reachable via `tool_search`", pointing the model at a tool that is
never declared and re-billing the text on every cached prefix. Worse,
tools already callable as `tools.web_fetch(...)` looked gated behind a
lookup step that does not exist.

The subagent and fork-resume callers already opted out, but the
main-session prelude sites did not, so the gate now lives where the
prelude is built rather than in each caller.

* chore: refresh settings schema

* chore: refresh companion notices

* fix: register exec tool metadata

* fix(cli): localize exec tool display name

* fix(core): stop offering ToolSearch lookups in CodeModeOnly

CodeModeOnly hides tool_search and binds every deferred schema into the
exec description, so text that tells the model to look a tool up
describes a surface it cannot reach. Return an empty deferred summary
from the registry itself - this also covers the fork-resume reminder
that bypassed the earlier call-site gate - and point the image zoom
hint at tools.zoom_image.

* feat(core): teach the system prompt CodeModeOnly's tool surface

In CodeModeOnly the model can only call exec, so the tool guidance and the
worked examples described a surface it does not have: bare tool names it
cannot call, an instruction to issue several tool calls in one response, and
examples in a direct tool-call syntax that does not exist there.

Give "Using Your Tools" a code-mode branch that routes every tool through
tools.<name>, states which tools are direct controls instead, and replaces
multi-call parallelism with batching inside one exec program. Swap the four
model-family example sets for one shared exec set, since the syntax
differences they exist for do not apply. The mechanics of exec itself stay in
its tool description; the prompt carries only policy.

* test: include code mode host in standalone fixture

* fix(core): return code mode media as multimodal output

* feat(core): support image tool results in code mode

* fix(core): carry deferred tool schemas in the exec description

CodeModeOnly hides tool_search and never surfaces a nested call as a history
functionCall, so a signature collapsed to Record<string, unknown> could never
be filled in later: parameter names were reachable only by guessing, or by
reading them off validation errors one at a time. Deferred tools keep their
registry state; only the generated signature gains the schema.

* fix(node-repl): restore the bin exec bit after rebuild

npm chmods dist/index.js when it links the bin at install time, but the
build deletes that exact file and tsc re-emits it as 0644. After any
rebuild node_modules/.bin/node-repl-mcp therefore points at a
non-executable target and spawning it fails with EACCES (exit 126)
before the shebang is ever read.

OR the exec bits into the emitted mode instead of setting 0o755, so a
restrictive umask is preserved rather than widened.

* fix(cli): pin patched Ink version

* feat(cli): add collapsible tool call details

* fix(dev): use CLI tsconfig outside repository

* fix(cli): preserve expanded tool details after completion

* fix(cli): fail headless runs on model API errors

* fix(core): expose shell timeout bounds in code mode

* feat(core): add code mode timeout helpers

* feat(cli): expose code mode in settings

* fix(mcp): preserve structured results for model and exec consumers

* fix(core): preserve code mode output and truncate inline

* feat(core): port scoped context tools to code mode

* fix(build): synchronize pnpm lockfile and capability docs

* fix(core): recover from rejected Responses encrypted reasoning
2026-09-11 07:40:35 +00:00
Shaojin Wen
d66ad3e3a2
feat(review): focus automatic static navigation reviews (#11456)
* feat(review): focus automatic static navigation reviews

* fix(review): address round-1 review of the docs-nav profile (#11456)

- QWEN_REVIEW_AUTOMATIC is operator-only: added to
  PROJECT_ENV_HARDCODED_EXCLUSIONS and read through
  automaticReviewRequested(), which refuses file-sourced values
- SKILL.md: the docs-nav profile passes the cumulative findings file to
  compose-review as findingsPath, so an unruled candidate caps at
  Comment instead of posting as a verified blocker
- the classifier treats top-level key order as structure (a reorder
  keeps the full review); the design doc records the decision
- assessResume reports a moved head as head-moved even on a profiled
  report; both profile guards read the shared DOCS_NAV_PROFILE constant
- the mechanism-health note no longer reads the profile's by-design
  anchor withhold as a stopped chain
- the coverage gap subject names the omitted full review and reverse
  audit instead of the review that ran
- buildRoleBrief welds no context pointer for a malformed plan prNumber

* fix(review): address round-2 review of the docs-nav profile (#11456)

* fix(review): address round-3 review of the docs-nav profile (#11456)

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

* fix(review): close focused navigation review gaps

* fix(review): strengthen focused navigation contracts

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-11 03:59:55 +00:00
qwen-code-dev-bot
7556821e65
fix(ci): retry transient E2E build artifact upload failures once (#11364) (#11375)
* fix(ci): retry transient E2E build artifact upload failures once (#11364)

* test(ci): harden the e2e-build upload retry contract pins (#11364)

Address review findings on the retry helper suite: scope the two-attempt
assertions to the archive instead of every upload-artifact step and bind
the attempts by step name, pin the pack-to-upload handoff (archive name
and step order), pin the build job's absent job-level continue-on-error,
pin the retry gate by whole expression so a failure() conjunct cannot
silently disable it, compare the two with: blocks wholesale (minus the
intentional overwrite asymmetry) plus the action pin, and compare
downloaded names against every workflow upload instead of restating the
four known legs. Also add the suite to the yamlSuites roster in
ci-platform-lanes.test.js so it can never join the dependency-free fast
lane undetected.

* fix(ci): correct the upload-retry rationale and name-key its contract (#11364)

Real-runner probes on the PR showed the comment's two reasons for the
retry's overwrite do not reproduce: a stalled upload aborts before
finalize and reserves nothing, and name uniqueness is scoped per run
attempt so a manual re-run does not 409 either. Reword both comments to
the window overwrite actually covers — a first attempt that finalized
e2e-build and only then reported failure.

The contract test picked "the archive" by position (uploads[0]),
contradicting its own comment: an unrelated upload step placed before
the archive upload reddened the suite with misleading messages. Key on
the literal e2e-build name instead — already the pinned contract in
scripts/tests/e2e-workflow.test.js — in both the filter and the
download-leg assertion. Mutation probes: a decoy upload step before the
archive now stays green, while decoy+retry-deleted, retry-deleted,
retry-renamed, and overwrite-dropped all still fail.

* test(ci): pin the pack-to-upload directory in the e2e retry contract (#11364)

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

* test(ci): pin the exact upload path in the e2e retry contract (#11364)

Round 3's handoff pin closed the pack side but left the upload side
pinned to a directory prefix, so moving only the two upload with.path
values into a subdirectory of runner.temp kept the suite green while
the runtime upload found no file and redded the build job. Compare
first.with.path to the exact '${{ runner.temp }}/' + archive literal,
reusing the derived archive binding rather than restating the name.
Mutation probes: the upload-side drift now fails on the new assertion
(16/1, expected-vs-actual), the workspace-escape and pack-side drift
mutations stay red on their existing assertions, and the same
upload-side mutation under the old prefix assertion stays green —
the exact pin is the load-bearing one.

Also reword the overwrite rationale in both the workflow comment and
the test comment to the form the shipped action source supports: the
record is created before any bytes move, so a failed first attempt can
leave the e2e-build name taken, and overwrite is delete-then-upload
and a no-op when nothing exists. The previous wording asserted a
stall reserves nothing, which the create-then-upload lifecycle
contradicts. And name the retry contract's actual ungated siblings
(run-e2e-tests.sh's sandbox-image build retry,
build-and-publish-image.yml's image build retry) instead of the
budget-gated sandbox:none shard retry the comment over-claimed parity
with.

* fix(ci): pin the unpack archive name and restore the overwrite rationale (#11364)

Round 4's handoff pin derives the archive name from the upload
with.path, so renaming the tarball at the pack step and both upload
paths while the four legs' unpack arguments stay put kept the suite
green while every leg would fail closed on main (review R1-3). Pin
each 'Unpack build artifact' run's trailing argument against the
derived name; the legs download into runner.temp/e2e-build/, so the
comparison is a suffix match, not the upload's full path. Mutation
probe: a producer-only rename now fails the contract with 'a leg
unpacks a different archive than the build job uploads' (16 pass / 1
fail), the unmutated workflow stays green (17/17), and the same
mutation was green before this pin (17/17).

Also restore the overwrite rationale that 03b921ba landed and 09deb2b
rewrote: the real-runner harness on this PR showed a stalled attempt
aborts before finalize and reserves nothing, and overwrite
(ListArtifacts -> DeleteArtifact) cannot rescue a name an un-finalized
attempt reserved, so the comments once again name the window overwrite
actually covers — an attempt that finalized e2e-build and only then
reported failure.

* fix(ci): announce absorbed e2e-build upload stalls so recurrence stays countable (#11364)

The upload retry absorbs a stalled first attempt behind
continue-on-error, so the build job concludes success and the main-CI
failure tracker — gated on workflow_run.conclusion == "failure" and
selecting jobs with conclusion == "failure" — never records the stall
the retry recovered from, freezing the recurrence count the #11364
ruling conditions on at one. Add a plain run step after the retry,
gated on steps.upload-build.outcome == 'failure', that emits a
:⚠️: annotation: the same check-run surface that named run
34208365262's "Upload progress stalled.", so the absorbed stall stays
queryable without reddening the job or the four legs behind
needs: ['build']. The implicit success() gate from its placement after
the retry scopes it to the absorbed case; a double failure reddens the
job directly and the tracker already sees that.

Pin the step in the retry-contract test per the round-7 review's
acceptance criterion (R4-1): exact gate expression, :⚠️:
emission, no action and no failing directive, placed after the retry.
Mutation probes: deleting the step, stripping :⚠️:, negating
the gate, adding exit 1, moving the step before the retry, and giving
it a uses: action each turn the suite red (16 pass / 1 fail); the
unmutated workflow is green (17/17).

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-11 02:05:02 +00:00
qqqys
5a9c9b3406
feat(goal): stop a Goal at a turn or an active-time budget (#11457)
* feat(goal): stop a Goal at a turn or an active-time budget

The token budget is a runaway-spend guard, sized for that job: 30,000,000
tokens by default, which a healthy long run reaches late. It is not what a
user reaches for to keep a Goal short -- people say "at most twenty turns"
or "at most half an hour". Neither had any effect, and the objective
template invites the first phrasing while the user doc has to explain that
writing it configures nothing.

Add two ceilings beside the token one, both off by default:
`model.goalMaxTurns` counts finished Goal turns and `model.goalMaxActiveMinutes`
counts the wall time the Goal spends active. The meters were already on the
record; only the ceilings and the stop were missing.

A Goal that reaches either takes the path a spent token budget already
takes: one wind-down turn to hand off, then `usage_limited` with a new
`limitKind` (`turn_budget` or `time_budget`) and a reason naming the budget.
A resume authorizes another window on top of what was used and moves only
the ceiling that ran out. Ceilings are read at the continuation boundary, so
the turn that crosses one still finishes; a turn crossing several reports one
reason, token first.

- `spentBudget` replaces the runtime's direct token check, so the
  continuation gate, the settle, and the no-progress bound cannot disagree
  about whether a Goal is out of allowance. The no-progress pause yields to
  every spent budget, not just the token one, so a cadence stop still gets
  its hand-off.
- The reducer's budget-resume branch keyed off the literal `token_budget`;
  it now keys off `isGoalBudgetLimitKind`, or a turn-stopped Goal would
  resume still rendering "ran its turn budget" as the reason it is active.
- The wind-down line stops naming the token budget, since the hosts carry a
  plain `windDown` flag and cannot say which ceiling was reached. It points
  at the budget line above it, which grew to report turns against their
  ceiling and, when one is armed, active minutes against theirs.
- Both new `limitKind` values reach the two whitelists outside core: the
  SDK's hand-copied union and the Web Shell's `getGoalState`, which rebuilds
  the record field by field and would otherwise drop them on the live path.

Design: docs/design/2026-09-09-goal-turn-and-time-budget.md and its
.zh-CN.md counterpart.

* fix(goal): repair the assertions and mocks the budget change broke

Six CLI assertions pinned the continuation prompt verbatim, and renaming
the budget line's prefix and rewording the wind-down line moved that text
out from under them. Two more CLI suites could not collect at all: they
build their `@qwen-code/qwen-code-core` mock by enumerating exports, and
`settingsSchema.ts` now reads two new caps at module scope.

Neither was reachable from the verification steps this change shipped
with, which ran only `runBudget.test.ts` and `config.test.ts` in the CLI
package. `cd packages/cli && npx vitest run` reaches all eight.

Also, while in these files:

- `get_goal`'s `lastGoal` summary already reported `tokenBudget`, so a
  Goal stopped by a cadence ceiling could be inspected and show only the
  allowance that did not run out. It now reports all three, and the tool
  description says so.
- Two validator doc comments said "-1 for no ceiling, or a positive
  integer"; the predicates accept `0` as the same opt-out, the way the
  token budget's sibling already documents it.
- `settingsSchema.test.ts` now pins both new entries: integer, no
  default, bounded by their caps, no restart.

* fix(config): reject signed zero for the Goal cadence settings, and say what the ceilings actually do

The `excludedValues` guard added last round compared with `Object.is`, which
tells `+0` from `-0`. `Number()` maps `-0`, `-0.0`, `-.0` and `-0e0` to `-0`,
`Number.isInteger(-0)` is true and `-0 < -1` is false, so `/config
model.goalMaxTurns=-0` walked past the guard, reported `Set ... = 0` because
`String(-0)` is `"0"`, and `JSON.stringify` persisted the excluded value.
Every later start in that scope then aborted on `must be > 0`, with `/config`
unreachable to repair it -- the same lockout the guard was added to prevent.
Compare numbers with `===` so signed zero matches, keeping identity for the
string half of the field's type.

The documentation of both ceilings overclaimed in three ways, each fixed
wherever the claim is published -- the two schema descriptions and their
generated mirror, the settings table, the user guide, both locales of the
Web Shell placeholder, three places in the goal-draft skill, and the
`propose_goal` objective example:

- `-1` was described as running Goals with no ceiling. It only removes a
  ceiling a Goal has already spent, on the resume or edit that follows; a Goal
  still under its ceiling keeps it.
- Neither ceiling is retrofitted onto a Goal already on the record, so the
  only way to bound one is to replace it. Nothing said so.
- The minutes row read as a wall-clock cap. There is no timer: the ceiling is
  read between turns, so a turn already running is never interrupted and the
  window can be exceeded by that turn plus the hand-off.

The design doc's "no behavioural change when off" bullet and its first
acceptance criterion were also too strong, in both languages. Restoring an
`active` Goal rebases `updatedAt` unconditionally, so the elapsed figure a
restored Goal reports drops offline time for everyone, settings or not -- and
drops with it any active time in an interrupted turn that no journal write had
committed. Both are now stated rather than implied, with the reason the record
cannot separate the two.

Two promises the tests did not hold:

- Three surfaces say user turns are still admitted at the ceiling, and the only
  case covering it admitted the turn one step below the limit. A case now
  reserves a user turn with the ceiling already spent and the hand-off in
  flight; adding a `spentBudget` gate to `beginTurn` reds it.
- The active-time ceiling is the only one whose spent state can change between
  queueing a continuation and delivering it. A case now queues under the
  ceiling with no host and binds past it, asserting the hand-off; making
  `bindHost` flush directly reds it.

* fix(goal): name what replacing a Goal costs, keep ceiling advice out of objective text

Round-three review, four findings.

Replacing a Goal is the only way to bound one already on the record, and the
docs offered it without the cost. `/goal set` goes through `createGoal`: a new
Goal at revision 1, turn, token and active-time meters at zero, and an
evidence cursor at the replacement, so everything the old Goal recorded drops
out of the citable catalog. Said so on every surface that offers the remedy --
the user guide, both settings rows and both schema descriptions, regenerated.

The ceiling settings were pointed at from two opposite places: the goal-draft
template slot said "outside the objective", while its exemplar, the user
guide's Budget row and strong example, the `propose_goal` objective example
and the Web Shell placeholder all wrote the pointer into objective text -- text
re-sent every turn to a model that cannot act on it, advising a setting that by
its own wording never applies to the Goal carrying it. One placement now:
never in objective text. The rules-of-thumb bullet names the settings, the
user guide's Budget row says to set them in settings, and the Web Shell pointer
moves from the placeholder into the hint rendered under the same field.
`SKILL.test.ts` pins both halves -- the settings are named in the skill body
and absent from the template and the exemplar -- so they cannot drift apart.
The strong example also gains the `On block:` clause the template asks for.

The signed-zero guard is one `includes` call: SameValueZero already matches
`-0` against a `[0]` exclusion, so the typeof-split ternary carried an arm no
setting can reach. The `-0` assertion from last round still pins it.

The settings table rows are trimmed to fit the widest existing row, so the
table is not re-padded, and one shortening that would have misdescribed which
values are rejected is reverted to the accurate list.

* fix(goal): stop the ceiling docs promising more than the runtime does

Round-four review, five findings, all documentation precision. Each one is
fixed by narrowing a claim rather than qualifying it further, so the surface
the next round can find fault with gets smaller.

- Bounding a Goal already on the record: "replacing it with /goal set or
  clearing it, which starts a new Goal" said clearing starts a Goal. Clearing
  leaves none. Only the replacement starts a new Goal at revision 1; clearing
  is followed by starting again. Both settings rows and both schema
  descriptions now say so, matching the user guide.
- "Time while the process is not running does not count" overclaimed. The
  only exclusion is the rebase a restart performs; a suspended process, such
  as a sleeping laptop, is charged. The claim now says exactly that.
- The quantified overshoot, "that turn plus the hand-off turn", holds only
  when a turn is running as the window runs out. A window that runs out while
  no turn runs is not noticed until the next turn ends. The bound is dropped
  in favour of saying the Goal can run well past the window.
- The Web Shell hint sent readers to settings for two keys that client's
  settings surface cannot reach. It now names `/config` in the chat, which
  runs in daemon sessions.
- The second weak-to-strong exemplar still wrote a bare turn count into its
  Budget. It carries the advisory marker like the first, and the skill test now
  checks every exemplar row instead of one.

---------

Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
2026-09-11 01:01:02 +00:00
Shaojin Wen
67fe20a838
fix(autofix): charge regressions to the brake and gate test weakening (#10188)
* fix(autofix): charge regressions to the brake and gate test weakening

Two holes let an autofix round ship a new problem for free.

A round that pushed a fix and turned CI red counted as a SUCCESS: the
consecutive-failure brake counts "rounds that pushed nothing", so the red
it created reset the counter, came back as the next round's input, and was
paid for out of the round budget. A PR could alternate regress/repair
indefinitely while every brake read it as converging.

Every acted round now stamps what it pushed and whether the head it pushed
onto was fully green; the next round charges a regression only when the
live head is exactly that sha, the marker says green, the window key
matches, and the head is red now. The observing round records it in
whichever report it posts, and the brake stops resetting on a regressing
round. Attribution is conservative on every axis: a moved head, a re-arm,
a pending check at push time, a cancelled run, or the loop's own lanes all
drop the charge.

The second hole: relaxing an existing assertion was invisible to the whole
gate. Build/typecheck/lint never read assertions, the package tests run the
weakened file, and the bite check reads only the tests a round ADDS. The
SKILL already required content evidence for deleting or weakening a test;
nothing enforced it. The gate now measures deleted test files, net
assertion removal, and added skip/todo markers, and rejects the round
unless each file is recorded in test-weakening.json with a reason. It
judges that the claim exists, not that it is right — the reasons ride into
the round report for a maintainer to read against the diff.

Deletions get their own merge-freight discriminator: content equality reads
"absent on both sides" as identical, so a round deleting a test the PR
itself added looked like freight and escaped. The merge base separates the
two cases.

* fix(autofix): attribute test weakening to round commits, close marker escapes

* fix(autofix): close weakening-gate merge/typechange escapes, own-lane charge leaks (#10188)

* fix(autofix): close weakening-gate comment, attribute, and name-framing escapes (#10188)

* fix(autofix): close weakening-gate masking escapes and delete/restore false charge (#10188)

* fix(autofix): close weakening-gate lexer, marker-shape, and verdict escapes (#10188)

- WEAKEN_ASSERT_RE: left-boundary class (console.assert( never fails a
  test) and a matcher requirement on added expect( lines (matcher-less
  expect passes for anything); the del side stays bare so a
  multi-line-formatted assertion removal still charges
- skip markers: concurrent/sequential/shuffle chains, tagged-template
  each tails, computed accessors, and newline-split chains measured on
  a joined view; sd counts comment-stripped del lines
- weaken_strip_comments gains string/template state (an in-string /*
  can no longer poison the strip); weaken_strip_code gains
  regex-literal and template-nesting state
- modify/delete merge resolutions that KEEP the file apply no freight
- the net-range deletion arm enumerates the explicit pre-round->tip
  pair and is judged even when per-commit measurement is UNAVAILABLE

* fix(autofix): close weakening-gate member-assertion, marker-spelling, and lexer escapes (#10188)

- WEAKEN_ASSERT_RE/ADD_RE: the member arm requires the CALL (an
  exported assert alias executes no assertion), and supertest's
  throwing member form .expect( counts on every arm
- WEAKEN_SKIP_RE: backtick-quoted computed accessors, the ?.
  optional-chaining spellings (it?.skip( and it.skip?.(), and the
  reversed chain with an execution modifier behind the marker
- weaken_strip_code: regex-vs-division is keyed on the last
  significant TOKEN (an operator including /, a regex-head keyword
  preserved across whitespace, or a control-head close paren tracked
  on a paren-kind stack), not the last output character
- weaken_strip_code/weaken_strip_comments: a ${} hole closes on the
  brace matching its own ${ (per-hole brace depth), not the first }
  inside the hole (an object literal or arrow body)
- the skip-marker net gains the whole-blob backstop twin of the
  assertion arm, so a genuine addition cannot net against an
  in-comment marker deletion or hide behind a block-comment span
  opened above the hunk
- twelve witnessed fixtures (each guard flips its own test red under
  mutation); fixtureWrite emits single-quoted shell words so
  backtick/${ fixture content survives

* fix(autofix): close weakening-gate merge coverage, freight phantom, and lexer escapes (#10188)

* fix(autofix): close weakening-gate cross-commit, merge-discard, and skip-surface escapes (#10188)

* fix(autofix): close weakening-gate matcher-credit, each-skip, and merge-recount escapes (#10188)

* fix(autofix): close weakening-gate tail-drop, skip-netting, fast-forward, and JSX escapes (#10188)

* fix(autofix): stop the weakening gate charging honest test edits (#10188)

Three of the four measured defects were false rejects the gate published
as machine-measured fact, and the fourth was test surface it never saw.

The matcher-tail census counted every member call in a stripped blob, so
deleting a spy and its restore read as three removed assertion lines;
scope it to continuation-shaped lines, the only shape that arm exists to
see. A fast-forwarded main recorded a recount base only for files it
added, so main's own delta was charged to a round that synced and then
renamed a symbol; record bases for modified files too and read the ledger
instead of set membership. The body-call skip arm charged vitest's
condition-valued dynamic skip, which is the runtime spelling of the
.skipIf environment guard the doctrine already exempts. And `*_tests.rs`
matched no pathspec arm, leaving two tracked Rust test files invisible.

Each fix carries a witness that a mutation probe confirms goes red
without it; the pinned guards for all four arms stay green.

* fix(autofix): measure test weakening with the parser, not a text census (#10188)

Replace the awk lexer, the four extended regexes, the matcher-tail census,
the per-commit accumulators, the verdict-time recount and the netting skip
with one instrument: count-test-surface.mjs parses the whole file with the
TypeScript compiler and reports its declared test surface (statement-level
assertion chains, test/describe registrations with their enabled state,
bare early returns ahead of assertions). Each file's round delta is
tip - pre-round - main's contribution, where main's contribution across a
merge is measured on git's own auto-merge of main's side (git merge-file
--ours) and across a fast-forwarded main commit on the commit itself, so
the same weakening measures the same before, during or after a merge, and
main's delta neither charges nor shields.

Narrow the pathspec to test-NAMED files (a README under integration-tests/
is not a test deletion), read the enumeration producers' status instead of
swallowing it, re-apply the reason bar when rendering, and state the
declared non-goals in the gate header: reachability, condition-valued
guards and by-reference options are runtime facts the runner-backed
instruments judge.

Bind the af-155 regression classifier to the commit its rollup describes
(headRefOid read with the rollup; a moved head classifies none), exclude
the loop's own dispatch-pending StatusContext by its exact context value,
classify verdict-less checks (EXPECTED, STALE, STARTUP_FAILURE) as pending,
stamp pre=none after a salvage-merged push, drop the unread CHECK_STATE
from the failure report's env, and render the regression disclosure on
every report shape.

Drop the hand-rolled CommonMark fence model from workflow-size.test.js: the
existing anchor/pointer/contents pins already prove navigation, and a text
model of a renderer is the class of instrument this change removes.

* chore: record the merged qwen-autofix.yml size

* fix(autofix): stage the surface counter with the pre-merge absence guard (#10188)

The stage step copies count-test-surface.mjs from the trusted base, but
the script is new in this PR: pre-merge the base lacks it, and a bare cp
under the step's -e kills every PR-event round whose workflow resolves
from the PR's own ref — the deferral the reviewer kept re-reporting.
Give the cp the heartbeat guard: rm -rf first so a planted leftover
cannot survive a failed copy and be executed as trusted (af-149), then
|| true so absence skips; the gate already fails open on a missing
counter (WEAKEN_MEASURED=false). Pin the pair and their order beside
the heartbeat pins.

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

* fix(autofix): close the weakening gate's fail-open paths and make green an allowlist

Answers rounds 21-26 by retiring mechanisms rather than adding entrances.

Head-state classifier (af-155): GREEN is now an allowlist, never the
fall-through. A charge-enabling verdict requires every counted check to
hold a state the classifier recognises as passing, so CANCELLED, NEUTRAL,
SKIPPED and any state GitHub adds later read as pending instead of minting
the premise. (R22-3)

Test-surface counter: a run that exits 0 without printing a readable
verdict is a measurement FAILURE, not a verdict. weaken_measure validates
the shape before returning it, so the file takes the existing fail-closed
arm ("test surface could not be measured") instead of being silently
uncharged while the round still reports itself measured. (R26-2)

Merge attribution: git merge-file's failure test is its exit status, not
its output size. --ours resolves conflicts and exits 0, so a legitimately
empty result (main emptying a test file) was never conflated with failure
-- the line is replaced anyway so the intent no longer has to be
re-derived from a size test, and pinned by a fixture that makes main's
side zero bytes across a real merge commit. (R21-4)

Report-post fallback: gated on the af-155 state the round AUTHORED, not on
its outcome. A no-op round's observation about the PRIOR round was
unrecoverable once its report failed to post, because the marker it read
is superseded by the next push and the workflow's handoff comment excludes
exactly the fixed/noop outcomes. (R22-1, R26-3)

Instrument staging keeps the pre-merge absence guard it was given here and
moves to its own step after npm ci, so the parser is a .cjs copy of the
typescript the TRUSTED lockfile installed; both digests travel in
expression context and are verified before the counter executes. A
verbatim absence probe runs the step against a script-less tree with
leftovers planted in both shapes.

Boundaries stated where they can be read: --ours is the attribution model
and its error deliberately lands on the charging side; the base axis is a
documented residual with the same bound as the flake carve-out; the SKILL
no longer promises assertion counting for the Python and Rust shapes,
which are watched for deletion alone.

508/508 in scripts/tests; five mutants (green default, verdict validation,
emptiness-as-failure, outcome-gated fallback, no-op event overwriting the
baseline) all killed.

* test(autofix): execute the paths the weakening gate's own guards protect

A reverse audit of the previous commit found two guards that no test
reached, both load-bearing in the fail-open direction, plus one hazard in
the counter's CLI dispatch.

git merge-file refuses a file carrying a NUL byte (exit 255, no output),
and the branch's own side has to stand in for the auto-merge then. Nothing
executed that arm: making the substitution unreachable left the whole suite
green. Without it the refusal's empty output reads as main having emptied
the file, which credits the round with the entire baseline surface and
masks a removal of up to that size. New fixture `binary-merge` -- main
appends a comment, the round drops an assertion, and the NUL makes every
side of the merge binary -- charges `net 1 assertion(s) removed` and fails
when the substitution is removed. The round rewrites the file rather than
filtering it, because a line filter reads a NUL-bearing file as binary and
would empty it instead.

The staging step's present-counter branch -- the one that runs in
production once this lands -- was pinned only by text. Swapping `mkdir -p`
and `cp` inside it left the suite green while killing every round, because
the step is unconditional and runs under `-e`. The probe now runs the step
body the way GitHub runs a `run:` block (`bash --noprofile --norc -e -o
pipefail`) in both shapes: the pre-merge tree that lacks the script, and a
tree that has it with a parser planted where npm ci puts one, asserting the
staged bytes and both digests.

The counter's CLI dispatch ran at module scope, so importing it executed it
against the importer's argv -- a positional the importing program owns
lands in the unknown-mode arm and takes that process down with exit 2. It
now runs only when the file is the program, compared as REAL paths: the
gate runs it from RUNNER_TEMP, which on macOS is reached through
/var -> /private/var, and a literal comparison silently disables the CLI
and reports every file unmeasurable. Both halves are pinned, including a
run through a symlink.

Also: the head-state classifier keeps the status axis it had, so a check
still in flight is pending even when it carries a conclusion from an
earlier attempt; and the dialect table is exercised for every extension the
pathspec selects, not only `.ts` -- deleting the JS entries was invisible
to 52 tests while every tracked `*.test.js` file is measured by the gate.

509/509 in scripts/tests. Mutants killed: the merge-file substitution made
unreachable, the staging order swapped, the main-module guard compared
literally, the JS dialects deleted, plus the five from the previous commit.

* fix(autofix): follow git's trivial-merge rule when merge-file refuses

A reverse audit of the previous commit found that its new binary-merge
fixture pinned THAT a substitution happens, not WHICH side it takes -- and
the side it took was wrong half the time.

When `git merge-file` refuses (a NUL byte makes the file binary, exit 255,
no output), the previous commit substituted the branch's side
unconditionally. That is right only when main's side is unchanged from the
base. When MAIN is the side that moved, it reads main's own weakening as
the round's and rejects a round whose only act was `git merge origin/main`
-- the exact property the neighbouring main-empties fixture already guards,
re-broken by a second trigger into the same substitution.

The fallback now follows git's own trivial-merge rule: a side that did not
move from the base contributes nothing, so the other side IS the result,
and only a genuine two-sided change resolves for the branch the way
`--ours` would have. Three fixtures pin the three shapes -- the round
weakens (charged), main weakens (accepted), both move (charged for the
round's own removal only) -- and both fixed-side choices now fail.

The head-state classifier's status axis becomes an allowlist too. The
denylist it had enumerated four in-flight states and missed REQUESTED, a
real CheckStatusState member that four other sites in this same workflow
already list; the axis is now `status == "COMPLETED"`, with an absent field
reading as finished because a commit status carries no status field at all.
Both axes are allowlists for the same reason a denylist forgets the member
it was not told about -- which is how CANCELLED entered the green arm in
the first place.

The classifier's rationale moves to qwen-autofix.md#af-155, leaving the
workflow 1024 bytes below the hard gate.

509/509 in scripts/tests. Mutants killed: always take the branch's side,
always take main's side, the four-member status denylist restored, the
status axis dropped entirely.

* fix(autofix): bound main's contribution by what the merge actually took

A third reverse audit found a false ACCEPT in the attribution model, in
both lanes: the auto-merge is computed from the two sides and never
consults the merge commit's own tree, so a merge that DISCARDED main's side
still books main's delta as a contribution.

A round can therefore merge main with `-s ours` and delete an assertion in
the same breath: the credit for main's discarded weakening absorbs the
round's own removal exactly, and the round ships uncharged. The previous
commit widened the reach of this by extending the trivial-merge rule to the
binary lane, where the same shape had been rejected before.

Main's contribution is now bounded by what the tip took. When the merge
result is the branch's side byte for byte, main contributed nothing to that
file, whatever an auto-merge would have produced, and nothing is
subtracted. A resolution that took main's version, or produced something
new, still goes through the model unchanged -- including `merge-conflict-
drop`, whose resolution commit differs from both sides.

Two fixtures pin the shape in both lanes: `strategy-ours-hole` and
`strategy-ours-hole-binary`, each a round that merges `-s ours` and removes
an assertion. Deleting the bound leaves both accepted.

509/509 in scripts/tests.

* fix(autofix): clamp main's credit by what the merge actually landed

The previous commit's bound was byte-exact: it fired only when the merge
result equalled the branch's side to the byte. A fourth reverse audit
showed one added comment line inside the merge reinstates the whole hole,
and the blast radius is main's ENTIRE modelled delta -- a round can strip
every assertion from a pre-existing test file and ship it uncharged.

Main's contribution is now the part the MODEL and the LANDED blob agree
on, and only where they agree in sign. Equal, and the clamp is the
identity: the ordinary merge measures as it always did. They diverge
exactly when a resolution took neither side whole, and then the clamp cuts
both ways -- a resolution that discarded main's side credits nothing, and
a resolution that weakened the file itself is not main's contribution
either.

`weaken_auto_blob` emits three lines now, always: the modelled auto-merge,
an optional override for the side to compare against, and the blob the
merge commit actually holds. Presence is deliberately NOT clamped: the
baseline is what the round had available to weaken, and letting the merge
result define it would let a round discard a test main added and answer
for nothing -- the one thing the deletion signal exists to catch.

What the clamp cannot see is identity. Main removing one assertion while
the resolution puts it back and drops a different one nets to zero, the
same way an assertion moved within a file always has; that boundary is now
stated in the header beside the model.

Fixtures: `ours-hole-plus-comment` is the evasion the byte-exact bound
missed, and `binary-resolution-takes-main` pins the binary fallback's
branch-side arm -- which the clamp had quietly un-covered -- at its
declared over-charging direction.

509/509 in scripts/tests. Mutants killed: the clamp returning the model,
the clamp returning the landed delta, the landed blob never emitted, the
binary fallback's else arm taking main's side. One survivor recorded: that
arm taking the merge base, which the clamp makes equivalent in every shape
a fixture can reach.

* fix(autofix): clamp only main's removals, never its additions

The clamp the previous commit added was sign-symmetric, and its two signs
have opposite consequences. Capping a REMOVAL cuts the shield, which is
what it was written for. Capping an ADDITION cuts the charge -- so a round
could discard whatever main added to a test file mid-round and pay
nothing. A fifth reverse audit demonstrated it through the real gate in
three spellings, none of them needing a merge strategy flag: an ordinary
merge resolution that drops the assertion main just landed, a
delete/modify resolution that drops the test main appended, and the same
resolution keeping that test registered `.skip`.

The commit that added the clamp already left PRESENCE unclamped for
exactly this reason, and said so; the reasoning was never carried into the
deltas. It is now: main's additions raise the baseline whatever the merge
kept, and only its removals are credited as far as they landed. It also
contradicted a promise the gate header makes -- the same weakening
arriving by fast-forward was charged while the one arriving through a
merge was not.

The event skip goes back to testing the model alone. Its second half was
added to let a landed-only divergence through, but the clamp returns zero
for every such event, so the only thing processing it could still do was
latch `baselinePresent` off a side main never held: a file the round
authored itself, touched while resolving a merge and dropped afterwards,
was charged as a deleted pre-existing test.

Six fixtures, all through the real gate: the three discard spellings
(charged), the round's own file edited in a merge and dropped (accepted),
main removing two where the resolution keeps one and the round then
removes another (charged for its own), and main disabling a test the
resolution keeps enabled before the round disables it (charged).

509/509 in scripts/tests. The four mutants that survived the previous
commit are now killed: both clamp branches, and `clampBag` returning
either bag unclamped. Presence clamped by the landed blob, and the skip
processing every event, are killed too.

* fix(autofix): measure main's contribution where main made it

A sixth reverse audit found two more defects, and both trace to the same
mechanism: main's contribution was read off a three-way SPLICE of the two
sides (`git merge-file --ours`), which mixes the round's own edits into
what is supposed to be main's delta.

The splice can invent a surface neither side ever had. Witness: the round
un-skips a suite, main adds a test inside that suite, and the resolution
takes main's file verbatim -- so the tip is byte-identical to main's and
nothing was weakened. The splice combined the round's un-skip with main's
addition into an ENABLED registration that existed in no tree, and the
round was charged for disabling it.

The splice also decided PRESENCE, through an endpoint that is the branch's
side in two arms. A path both sides added during the round, resolved to
main's file and then deleted by the round, read as "the baseline never
held it" and shipped uncharged -- while the gate's own
`weaken_baseline_holds`, the definition its fail-closed arm uses, said the
opposite.

So the splice is gone. Main's contribution is main's OWN delta, measured
on main's own side against the merge base, and presence follows main's own
side directly. That makes the two arms one rule: a modify/delete resolved
for main was already measured from the merge base. `git merge-file` leaves
the gate entirely, and with it the binary-refusal fallback, its
trivial-merge rule and the over-charge they carried -- a resolution that
takes main's version now charges the round for nothing, where the model
used to bill it for main's removal too.

What the merge actually DID with main's contribution still clamps it, as
before: removals credited only as far as they landed, additions never
clamped.

509/509 in scripts/tests, with the gate section 60 lines shorter. Four new
fixtures: the splice artifact (accepted), the add/add path dropped after
the merge (charged), a resolution that removes more than main did (charged
for the difference), and one that moves opposite to main (charged for the
round's own removal only). Mutants killed: main's delta measured against
the branch side, presence forced either way, removals credited from the
landed blob, the landed guard dropped, additions clamped down.

* fix(autofix): decide the baseline by what main LANDED, not by what it holds

A seventh reverse audit found three defects in the new model. Two are
false rejections with high reachability, one is a false accept that a git
tie-break decides.

Presence flipped true whenever main held the file at a merge, even when
main's side was byte-identical to the merge base -- main landed nothing
there. So a test file an EARLIER round removed, or renamed, is charged
`test file deleted` again in every later round that merges main, until
main also loses the file. The gate's own header promises a rename costs
one ack entry naming the old path; it was costing one per round.

Presence now moves only when main's side and the merge base DISAGREE about
the file existing. Main adding it during the round puts it in the
baseline, and the round answers for dropping it afterwards. Main deleting
it takes it out only when the merge adopted that deletion. Main merely
still holding a file it has always held says nothing.

The "not an event at all" early return is gone with it. It dropped main's
deletion from the running total while the NEXT merge's base already
reflected that deletion, so main's re-add was credited from zero a second
time: add, delete-with-the-round-keeping-its-copy, re-add was charged
three assertions where one was removed. Every merge event is recorded now,
and the clamp handles the resolution that kept the round's copy -- its
landed delta is zero, so main's modelled deletion credits nothing.

A criss-cross history has more than one equally valid merge base and git
picks one without promising which. Since main's delta is measured against
that base, the pick decided the verdict -- and one of them credits main
with the ROUND's own removal, waiving a real weakening. The file is now
refused rather than measured: `test surface could not be measured`, which
one ack entry answers.

509/509 in scripts/tests. Three fixtures for the presence rule and the
event chain; both presence mutants (latching main's hold unconditionally,
and clearing on main's deletion whatever landed) are killed. The stale
auto-merge prose in the counter's header, its clamp comment, an orphaned
doc comment in the gate and two leftovers in the suite are corrected.

* fix(autofix): chain main's events, and let the merge decide what it adopted

An eighth reverse audit found that the previous commit's headline fix does
not work and its fixture is vacuous, plus two false accepts it introduced.

The removed early return was load-bearing. Its case -- main deleted the
file, the merge kept the round's own copy -- is now recorded as an event
whose `landed` blob IS the round's copy, so a copy weaker than the merge
base credits MAIN with the round's own removal. The round guts a test file,
main happens to delete it, the resolution keeps the gutted copy, and the
round ships uncharged. The early return is back.

What that removal was meant to fix is fixed properly: main's events CHAIN.
Each event now measures main's side against main's side at the PREVIOUS
event, not against a fresh merge base that already reflects it, so the
events telescope to main's own net for the round. Add, delete-with-the-
round-keeping-its-copy, re-add was charged three assertions where one was
removed -- and it still was after the previous commit, because recording
the deletion with a landed delta of zero is behaviourally identical to
dropping it. The fixture missed it by re-adding byte-identical content, so
the third merge was not an event at all; it re-adds a comment line now.

Presence follows what main CONTRIBUTED, not what it holds. `mainHolds`
alone exempted a whole path for the round whenever the pre-round ref
lacked it -- including when main GREW it and the round restored main's
file and gutted it. The rule is now: main holding the file and having
moved it puts it in the baseline, whatever the resolution then did; main
deleting it takes it out only when the merge adopted that deletion.

The criss-cross refusal was charging honest rounds. More than one merge
base only matters when the candidates DISAGREE about the file, which is
now the test; when they hold the same blob the pick cannot change the
verdict and the file is measured. Two fixtures pin both sides of that.

516/516 in scripts/tests. Six mutants killed, each the exact regression it
names: the early return removed, the chain removed, presence from
`mainHolds` alone, presence cleared without checking the base, the refusal
made unconditional, and the refusal dropped. The stale presence comment
the previous commit missed, and `weaken_baseline_holds`'s claim to be the
same definition the counter reports, are corrected.

* fix(autofix): drop the event whenever main holds no side and the file stays

A ninth reverse audit found that the reinstated early return was too
narrow, that presence read byte movement where it meant surface movement,
and that the criss-cross guard compared the wrong identity.

The early return required the merge base to hold the file. One merge
later it does not: after main deletes a file the round kept, the NEXT
merge sees no main side AND no base, the event is recorded, and the
round's own copy becomes the landed blob that sets the clamp -- crediting
main with exactly the round's removal, one merge after the guard that
exists to prevent it. The base is not part of the test any more: main
holding no side while the file stays in the round's hands is the same case
however the history reached it.

Presence asked whether main's side moved BYTES. Main appending a comment
to a file an earlier round deleted contributes no coverage, and reading it
as a contribution re-charged that deletion in every round main happened to
touch the file -- and made the verdict depend on how many times the round
merged main. It asks about the measured SURFACE now: the totals and both
registration multisets.

The criss-cross guard compared `git rev-parse <base>:<path>`, which
returns a TREE oid for a directory while the measurement treats a tree as
no blob at all. Two bases that agree for the measurement read as a
disagreement, and an honest round was charged unmeasurable on a tie-break
that could not have changed the verdict. It compares blob-or-absent
identity now, the same question `weaken_blob` asks.

One finding is answered with a stated precondition rather than code. The
model reads main's side as MAIN's, which holds while origin/main does not
contain the round's own commits -- true here, where pull requests land
squashed and main's first-parent history carries no merge of a PR branch.
A guard for it was written, found unreachable from every history these
fixtures can build, and removed in favour of saying so in the header.

516/516 in scripts/tests. Three fixtures, and with them the two mutants
the audit found surviving are killed: the caller's skip of a dropped event
(previously indistinguishable, because no fixture had a prior main event
for the file) and the early return's base condition. The clamp's
`landedHolds` condition is now redundant for manifests the gate produces,
and says so.

* fix(autofix): read movement from the bytes where the surface is unmeasurable

A tenth reverse audit found that the previous commit's surface test
disabled the deletion arm for every non-JavaScript test file -- 45 Rust
and 18 Python paths in this repository.

`count()` reads no surface from those shapes, so two present blobs always
compare equal and "main moved it" could never become true. Once the
pre-round ref lacked such a path, nothing main did during the round put it
back in the baseline, and the deletion arm -- the only arm those shapes
have, and one the gate header promises never fails open -- was waived.
Movement now falls back to the bytes when neither side is a dialect the
instrument reads, because there "the surface did not move" is unmeasurable
rather than false.

The surface test also compared main's DISABLED registrations, which reach
no signal: only the tip's do. Comparing them raised the baseline for a
change the gate can never charge -- main adding an `it.skip` re-charged a
deletion an earlier round had already answered for, which is the exact
failure the surface test was written to remove. It compares the chargeable
quantities now: the assertion totals and the enabled-registration
multiset, of which `enabled` is a derived count.

The criss-cross comparison goes through `weaken_blob`, the reader the
measurement itself uses, instead of a second identity built from
`rev-parse`. A tree, a gitlink and a missing path are "no blob" to all of
them alike, and a mode-only difference is the same blob, so the guard
refuses exactly the disagreements that could change a verdict.

Two fixtures for the deletion arm: a Python file main grows while the
round keeps its earlier deletion, and a file main adds whose measured
surface is indistinguishable from an absent one. Both are charged; both
fail if the fallback or the presence rule's existence test is removed.

The `main-absorbs-round-commit` fixture is deleted. The audit showed it
measured nothing -- the file is byte-identical on both sides of its merge,
so the event filter drops it before attribution runs -- and its comment
claimed a mechanism that was not the one making it pass. The precondition
it stood for is stated in the header instead, with its justification
corrected: a squash-merge of the round's own work into main mid-round is
the spelling that breaks the model, not a merge, which is self-correcting
because the merge base carries the same commit.

516/516 in scripts/tests.

* fix(autofix): decide the byte fallback by what was measured, not by the extension

An eleventh reverse audit found that the previous commit's fallback keyed
on the file's EXTENSION rather than on whether the instrument read
anything. A `.test.ts` that registers nothing measures exactly like a
`.py` -- and this repository has one, a type-level fixture under the CUA
driver -- so its deletion could still be waived after an earlier round
removed it. The test is the measured surface now: no registration of
either kind and no assertion, on both sides.

The audit also showed the previous commit's narrowing of the surface
comparison changed real verdicts with no coverage, in either direction.
Main moving only DISABLED registrations is no coverage and reaches no
signal, so it must not put a file an earlier round deleted back into the
baseline; re-adding that comparison now fails a fixture, as does dropping
any of the three fields that remain.

The criss-cross guard's presence arm was equally uncovered: both shipped
criss-cross fixtures disagree about the file's CONTENT, and the variable
that tells "no blob" from "the first candidate" was free to be removed. A
fixture whose candidate bases disagree about whether the file exists at
all pins it.

Three fixtures, three mutants killed: the fallback keyed on the extension,
the surface comparison widened back to disabled registrations, and the
criss-cross presence guard removed.

The header's three claims are corrected. It said movement falls back to
the bytes for "the Python and Rust shapes" (it is any file that measures
to nothing, in any dialect), that presence follows main having moved the
surface (main ADDING the path raises it with no movement at all), and that
comparing merge bases through `weaken_blob` buys agreement on trees,
gitlinks and mode-only changes (the identity it replaced already had all
three; what it buys is one reader instead of two that can drift apart).
The byte fallback's cost -- main touching such a file re-charges a
deletion an earlier round answered for -- is stated rather than left to be
discovered.

516/516 in scripts/tests.

* fix(autofix): let a skips-only file reach the bytes, and pin every field

A twelfth reverse audit proved the previous commit introduces no new
false accept -- structurally, and over forty thousand generated histories
-- and found one inconsistency and a coverage claim that was false.

`isEmptySurface` counted a disabled registration as content while
`sameSurface` cannot see one. A file that is NOTHING BUT disabled
registrations therefore took the comparison branch, where the comparator
is blind to the only thing in the file, so no rewrite of it could ever
read as main contributing. This repository has one such file. It reaches
the bytes now, like every other file the comparison cannot read, while
main moving disabled registrations on a file that has other content is
still not coverage arriving.

Four fixtures, four mutants killed, and each of the comparison's three
fields is load-bearing on its own for the first time: main planting a
guard (the live total falls, the declared total stands), main deleting an
assertion already behind one (the declared total falls, the live total
cannot), and main renaming a test (both totals stand, the multiset moves).
Those three survived every test in the file before.

The previous commit's message claimed dropping any of the three fields
fails a fixture. It did not; only the re-added disabled comparison was
pinned. The claim is not repeated here, and the comparison's own header
now says what it leaves out and where those files go instead.

The criss-cross presence assertion also gains the path check its sibling
already had: exiting 1 does not discriminate, because every guard mutant
still exits 1 -- charging main's own deletion to the round, which is the
hazard the guard exists for.

516/516 in scripts/tests.

* fix(autofix): close the escape hatches the review found in the weakening gate

- count-test-surface: see through type-only wrappers (`as`, `<T>x`,
  `satisfies`, `!`) in constant folding, options objects and call
  chains; fold comparison/equality/logical operators of two constants;
  treat template-literal returns as non-thenable
- count-test-surface: an `if (true)` wrapper or a catch whose try holds
  an assertion no longer shelters a body skip; a registration callback
  handed by name resolves to its module-scope binding; a collector
  factory binding (it.skipIf / it.each / test.extend) registers nothing
- af-155: charge a regression only when the run's window IS the live
  re-arm key, and never stamp pre=green when the push carries a merge
  past the head prepare classified
- tests: exercise all five own-lane names in the charge classifier, and
  gate the verbatim staging witness on the host having sha256sum

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

* fix(autofix): arm-aware guard conditions, lane-list dedup, branch-side clamp (#10188)

Holes the round-27/28 review kept re-asserting on top of a211bc0:

- count-test-surface: the early-return silencer now fires only when the
  runner provably reaches the return on EVERY entry — underCondition is
  arm-aware for constant if/ternary/short-circuit operands (the executed
  arm is no condition; the skipped arm is dead code), so the
  environment-guard idiom `if (!cond) return;` keeps its assertions
  measured (R27-21, R27-27's sync/async asymmetry, R27-1's else-arm).
  HEAD's own contract.test.ts measured {assertions: 0} off that shape.
- constant() folds arithmetic and bitwise operators with JavaScript's
  own semantics; opaque placeholders and bigints never enter the fold
  (R27-19's remaining arms). `fixme` joins the disabling set (R21-2's
  Playwright arm).
- measure: a merge event's landed delta is measured against the branch's
  own side (new manifest `branch` field, emitted by the gate as
  weaken_emit's fifth line), so a resolution that discarded main's
  removal credits main with nothing (R27-20).
- af-155: the loop-owned lane exclusion lives in ONE workflow env entry
  (LOOP_OWNED_LANES) referenced by all three head-state classifiers;
  the hand-copied lists had drifted six lanes short (R27-23). Tripling
  the list inline would have crossed the 470000-byte workflow start-runs
  gate (the file stood 115 bytes under it), so the af-149 kill-block
  prose moved to qwen-autofix.md per the file's own doctrine.

Every guard change is pinned discriminatively and mutation-checked both
ways; the gate-level fixtures re-spell their weakening shapes
unconditionally, with the conditional spellings pinned as no-charge.

* fix(autofix): Playwright-aware kinds, placeholder-safe folds, fallback numbering (#10188)

Round-30/31/32 leftovers:

- count-test-surface: the prefix arm no longer folds an operator over a
  placeholder value — `-[]` is -0 (falsy) at runtime, never the
  placeholder's -1 (R32-1); the object/bigint opacity guard is hoisted
  and shared by the unary and binary arms.
- count-test-surface: Playwright's namespaced API is booked by kind —
  `test.describe` is a suite whose disabled state propagates,
  `test.beforeEach` is a hook, and the utility members (`test.step`,
  `test.use`, …) register no phantom test (R30-2).
- push-and-report: the marker-only fallback now also carries the eval
  marker, so a fallback-posted round consumes its round number and the
  next round does not reuse it (R31-2's numbering arm).
- review-verification: the weakening rejection's ack list rides its own
  workdir file and the remedy names it — the tail-bounded rejection
  document used to cut a long list from the front while telling the
  agent to ack "every file listed above" (R32-2).

Each new guard is pinned discriminatively and mutation-checked; the
Playwright row fails red without either the member gate or the kind
override, and the placeholder row fails red without the prefix guard.

* fix(autofix): measure a merge's landed delta by which side the resolution took (#10188)

The R27-20 branch-side baseline was right for a resolution that KEPT the
branch's side and wrong for one that took main's whole: the
binary-resolution-takes-main gate fixture (caught on CI) charged the
round for main's landed removal because `landed - branch` folds the
round's own discarded edit into the delta. The clamp now reads the
resolution per event: took main's side whole → main's delta landed
exactly; kept the branch's side whole → none of it did (the R27-20
case); a mixed resolution is still measured against the branch's side.

Mutation-checked in both directions, per the fixture family: forcing the
old merge-base baseline leaves binary-resolution-takes-main green but
reopens the R27-20 fixture red; dropping the kept-branch arm does the
reverse. The shipped rule keeps both green.

---------

Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: probe <probe@local>
2026-09-10 15:04:34 +00:00
Shaojin Wen
dfafbf240e
fix(ci): widen the replayed supersede-cede timeline margin to deflake Scripts (#11581)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-10 14:49:56 +00:00
Shaojin Wen
ac1edef974
fix(review): keep host-trusted state out of the container's writable surface (#9983)
* fix(review): keep host-trusted state out of the container's writable surface

Two instances of one root cause, both reachable on `main` today and neither
opened by the sandbox — before it, the reviewed repository's commands ran as
the host user with no filesystem restriction at all, so containment narrowed
this reach rather than creating it. What it did not do is close it, and a
`required` that claims containment should not leave host-trusted state in the
directory it hands the container read-write.

**Leases.** `cleanupReviewWorktreeLeases` matches a lease by session ids alone
and then force-removes whatever worktree and deletes whatever branch it names.
Those files lived in the review temp dir — the bind mount. Reviewed code that
edits one makes another session's cleanup destroy the wrong tree; one it
plants with a foreign session id is swept by nobody and wedges that PR on that
machine until someone deletes the file by hand. They now live one directory
over, in `.qwen/review-leases`, which nothing mounts. A lease left behind by
an older build is deleted when the same target is leased again.

**Gitfiles.** These cannot move — git requires `<tree>/.git` — so the check
moves instead. The existing identity gates cannot see the attack and never
could: the writer chooses both the gitfile's target and that target's
backpointer, so the round-trip agrees; `--show-toplevel` still names the tree;
no symlink is involved; and asking git for the common dir resolves THROUGH the
rewritten gitfile, so that answer comes from the same hand. Location is the
question that has an honest answer — a real admin entry lives under
`<repo>/.git/worktrees/`, outside the mount, and a planted one has to be
inside it. `restoreProbeTreeTracked` now refuses an entry that resolves inside
the review temp dir.

Reproduced end to end before fixing, and the reproduction is the reason the
gate is where it is: a rewritten gitfile pointing at a planted admin entry
whose own `commondir` names a planted repository, both under the mount,
passes all three existing gates — and `git checkout --force` then runs that
repository's `filter.evil.smudge` ON THE HOST. With the new gate the same
shape is refused while the entry it replaced still passes.

Three mutants, three reds: a gate that always admits, one that admits when the
entry cannot be resolved, and one that refuses when there is no mount at all —
the last would refuse every ordinary repository.

* fix(review): close the shapes the first cut of this gate left open

Six blockers from round 1, all verified before fixing.

**The predicate failed open at both boundaries of its own question.** An entry
whose realpath IS the mount root produced an empty relative path and was read
as outside — while the root is exactly as writable as anything under it — and
a child legitimately named `..evil-git` produced a relative path starting with
the characters an escape would. Both are places a planted entry can sit. The
hand-rolled arithmetic is gone; `isSubpath` already answers this correctly and
is already tested.

**Every gate sat behind `.git` being a file.** Reviewed code that replaces the
gitfile with a `.git` DIRECTORY of its own — `rm .git && git init .` inside
the mount — skipped the lot, and the restore then ran against the repository
it had just made. That shape is now refused before the branch, and only under
a mount: outside one, a `.git` directory is what every ordinary checkout has.

**The gate was on the second host-side write, not the first.** The probe
tree's own creation — `git worktree add` — resolves the repository through the
REVIEW worktree's gitfile, a second rewritable pointer in the same mount,
written after the build phase has already run the PR's code in the container.
`worktree add` checks files out, so it executes whatever filter that pointer
leads to, before anything inside the restore could fire. Both call sites now
ask the same question through one helper rather than two inline copies.

**The legacy-lease removal was a wedge.** `force` only swallows ENOENT, so a
DIRECTORY at the old lease path threw EISDIR out of acquisition — and
`mkdir .qwen/tmp/qwen-review-lease-pr-42.json` is one command inside the
mount, after which every review of that PR on that machine failed, with
nothing able to remove it: the rollback rethrows, the sweep skips the lease
shape, and `rm -f` cannot remove a directory.

**The move stranded the only automated orphan sweeper.** The review job's
`if: always()` heal step swept the old path only, so a cancelled or timed-out
run left a dead lease at the new one, on a runner whose workspace persists.
It now sweeps both, recursively.

Tests follow the same rule they were missing: the gate's production call sites
are driven, not just its predicate. Every previous fixture put its tree under a
bare `tmpdir()`, where `mountRootFor` answers null and the gate short-circuits
before its logic runs — which is how deleting the wiring shipped green. The new
fixtures sit under `.qwen/tmp` and plant a COHERENT admin entry, because an
incoherent one fails earlier for a reason that has nothing to do with the gate.
Seven mutants, seven reds — including passing the probe tree where the review
worktree belongs.

* fix(review): gate the third write, and stop breaking the lane that pins the sweep

**The workflow edit broke its own pinning test, and this PR's required check
with it.** `scripts/tests/review-worktree-cleanup-workflow.test.js` asserts the
sweep line literally, derived from `paths.ts` so a rename there fails the build
rather than silently no-op-ing the sweep on the runners. Adding `-r` broke the
substring. The test now pins BOTH locations — the new one where this job's own
runs leave leases, the old one where a persisted workspace can still hold one —
which is what the test existed to enforce and what the move should have updated
in the same breath. It went unnoticed because the commit was verified with
vitest alone; `npm run test:scripts` is a separate lane of the same required
job.

**The revert phase was a third ungated write, reached exactly when the others
refuse.** `git checkout base -- …` runs against the same mounted probe tree,
and its only guard was a symlink-only lstat walk that a rewritten gitfile
passes untouched. Worse, a restore refusal becomes `inconclusive` without
throwing and the mutation phase's catch continues on purpose "so the revert
probe below still runs" — so guarding the first two writes and not this one
left the route open precisely where the other two closed it.

**A claim in the gate's own rationale was false.** The comment called
`worktree add` "the FIRST host-side git write of this phase"; `discardWorktree`
above already runs `worktree remove --force` and `worktree unlock` with the
same cwd. They materialise nothing, so no filter runs — but that distinction
is the entire reason the gate can sit below them, and the looser sentence
invites the next maintainer to add a checkout above it.

Tests follow the same rule as the code. The ADMIT path had no coverage, so a
mutation mangling the parsed gitdir survived: every refusal fixture would still
refuse, because a mangled target fails to resolve and the location check fails
closed — while in production the same mangling refuses every healthy tree. It
is asserted now, relative spelling included, since git writes that pointer
relative when it can. And the revert gate is driven through the phase itself,
by a fake runner that rewrites `.git` from inside the probe tree — which is who
does it in production.

Nine mutants, nine reds.

* test(review): gate the new cases off the platform where containment cannot exist

The three tests added for this gate are deterministically red on Windows, and
the reason is this PR's own sibling: `mountRootFor` refuses every absolute
Windows path, because a drive letter is a colon and the `-v` grammar has one
separator. Containment is unavailable there by design, so the gates never
speak, `out.json` never carries the refusal, and the assertions fail for a
reason that has nothing to do with what they test. The Windows lane runs only
under `merge_group` and reports as skipped on the PR page, so green here was
no evidence — the first red would have landed in the queue and stalled the
batch.

Gated with the same named predicate the sibling suite uses, so the reason is
stated once rather than repeated three times.

Two more from the same review. The fake runner embedded the planted path in a
JS string literal by interpolation, which a Windows path's backslashes
self-mangle; it is generated through `JSON.stringify` now. And both new tests
had pasted the ~22-line fixture that `scaffoldModifiedPr` already provides,
leaving three copies to evolve in lock-step — the helper takes the worktree
location as a parameter instead, since the location is exactly what differs
and exactly why the gates fire.

The three gates were re-mutated after the refactor, one at a time: three
mutants, three reds.

* test(review): assert the property, not the sentence that explains it

Both gate tests asserted a refusal MESSAGE, and `worktreeCreateFailureDetail`
serializes that message identically whether the gate throws before the write
or after it. So the mutant that matters — moving `worktree add` above the
gate, which is exactly what the comment beside it warns a refactor might do —
checked files out through the planted pointer, ran its filters on the host,
and left both tests green.

Creation: the probe tree not existing is what says the spawn never ran.

Revert: the planted repository now carries a real `filter.evil.smudge`, and
the assertion is that its canary was never written. That is the property
itself rather than a proxy for it — with the gate removed the filter EXECUTES
on the host inside the suite, which is what the test now catches.

The fixture had to become coherent for this to mean anything: an empty planted
directory fails the checkout for reasons unrelated to the gate, so it copies
the real admin entry and points `commondir` at a planted common dir, the shape
the reproduction in the PR description uses.

* fix(review): ask git where the repository is, and gate the entrances that were left

**Stop re-implementing git's resolver.** The gate parsed `gitdir:` and resolved
it in Node while the write that followed resolved the same file in git, and the
two disagree in ways that are individually small and collectively unbounded:
JS `trim()` strips U+00A0 where git's `read_gitfile` trims only C-locale space,
so `gitdir: <NBSP><real entry>` resolved outside the mount here — admitted —
and to a planted entry inside it there; and `resolve()` is lexical where a
spawned git resolves relative targets against the tree's physical path after
chdir. Every divergence admits a pointer whose real referent only git sees, and
enumerating them has no last corner.

So the resolver that decides is now the resolver that acts: `rev-parse
--absolute-git-dir`, run in the tree, with hooks and fsmonitor inert. What
stays ours is the only judgment that was ever ours — WHERE the answer lives.
The fixtures had to become real repositories for this to mean anything, which
is an improvement on its own.

**Three more entrances, all probed to host execution.** Round 2 recorded that
per-call-site gating does not close the class, and I argued a shared layer
would be noise. That was wrong, and the review paid for the answer: base-tree's
rebuild had no defence at all; scratch-tree's screen matches
`filter.*.smudge|clean` and not `filter.*.process`, which also executes; and
`--resume` runs an index-refreshing `git status` through the same pointer, so a
planted `core.fsmonitor` fires inside the very command that collects the
ruling's evidence — the attack does not need the resume to succeed. All three
now ask the same question, and `status` carries an inert fsmonitor besides.

**Absence is the caller's question, not this one's.** The first cut refused a
tree that does not exist, which is the state `--resume` legitimately asks
about — 26 tests, measured. It returns null there now.

* fix(review): the entrances round 6 found, and two limits stated rather than papered over

Case, first: the verdict is a string comparison, and Node's JS `realpathSync`
hands back the spelling it was asked for whenever the lookup succeeded — so on
a case-insensitive filesystem (default macOS APFS, a host this pipeline
supports) a planted entry spelled with different case resolves INSIDE the
mount and compares OUTSIDE it. `realpathSync.native` asks the operating system
for the stored name, so both sides arrive spelled as the filesystem holds them.

Two more entrances. scratch-tree's REUSE path reaches a `checkout --force` of
its own through the scratch tree's pointer, and fetch-pr's FRESH path checks
out through the review worktree's. The reuse one is not a refusal, though: an
unusable leftover is exactly what the rebuild exists for, and refusing there
turned "rebuild over it" into "fail the command" — three tests said so.

And `--resume` refused to CRASH where this file's own contract, the option's
describe and the docs all promise refuse-to-FRESH. Throwing propagated out of
`runFetchPr`, so `cleanStale` — the thing that removes the planted tree — was
never reached. It returns a refusal reason now and the fresh path sweeps.

The two new refusal tests were red on Windows, for the third time in this PR
and for the same reason: `mountRootFor` refuses every absolute Windows path,
so containment is unavailable there by design and the gate never speaks. Both
gated off win32 like their siblings.

Two limits I could not close, stated where they are rather than left looking
proven. The `--resume` gate's wiring is pinned by no test: `fetch-pr.test.ts`
mocks `node:fs` without `existsSync`, so the tree never exists there and the
gate is inert — deleting it ships green. And the reuse-path gate is defence in
depth rather than a demonstrated check: with both trees pointed at one planted
common dir and a `filter.*.process` the existing screen misses, removing the
gate still executes nothing, because the reset declines that tree for its own
reasons first.

* fix(review): stop editing git's answer, and gate the pointer that is actually used

Two blockers, both inside the previous round's own fixes.

`.trim()` on `rev-parse`'s stdout re-opened the exact divergence the paragraph
above it closes. It strips U+00A0, U+FEFF and the U+2000 block, so an admin
entry whose directory NAME ends in one is resolved by git with the character
and judged here without it — and a twin of that name minus the character,
symlinked outside the mount, is where the judgment then lands. Only the
terminator `rev-parse` adds may be removed.

And the fresh-path gate checked the wrong pointer. `git()` sets no cwd, so
`worktree add` resolves the repository from `process.cwd()` — while the gate
asked about `wt`, which `cleanStale` has just removed, and a tree that does
not exist has no pointer to distrust. It was a no-op on every reachable path.
It asks about the launch directory now, which is the one the nested geometry
`mountRootFor` documents lets an outer review's containerized phase rewrite.

* fix(review): let the launch directory resolve the way git does

Last round's gate asked `untrustedGitfile` about `process.cwd()`, and that
function demands `<tree>/.git` — right for the trees this pipeline builds,
wrong for the directory a command was launched from. Any subdirectory of a
checkout has no `.git` of its own; git walks up. So in the nested geometry the
pipeline runs its own reviews in — cwd at `<review tree>/packages/cli` — the
gate answered "has no .git to resolve" and fetch-pr aborted before the
`worktree add` that would have succeeded. 77 tests in one suite, at the
reviewer's HEAD.

My checkout is not under `.qwen/tmp`, so `mountRootFor` returned null here and
the gate never fired: the suite stayed green while the dogfood lane broke.
That blind spot is now a test — a subdirectory of a mounted checkout, asserted
to resolve.

The launch directory gets its own question: ask git, from there, what
repository `worktree add` will find, and judge only where that answer lives.
No shape check, because the shape belongs to a tree and this is not one.

* fix(review): three failures of my own, and the block gate that stops the fourth

`realpathSync.native` is not always there. A suite that mocks
`node:fs.realpathSync` as a bare `vi.fn` gives it no `.native`, and reaching
through it threw a TypeError straight into the fail-closed catch — refusing
every worktree creation in any checkout that happens to sit under `.qwen/tmp`,
which is the geometry this pipeline reviews itself in. Falls back to the plain
call, and a test asks that shape directly, because the suite that carries the
mock never reaches this gate from an unmounted checkout.

The real-git fixtures ran without `isolateHostGitConfig`, which every other
real-git block in the same file installs. On a host with `commit.gpgsign=true`
and no usable key the fixture commit fails and the whole block goes red for
reasons unrelated to the gate — reproduced with a poisoned HOME.

And one more case was left ungated for the Windows lane. Rather than gate it
and wait for the fifth, the whole block is gated: every case in it builds a
layout under `.qwen/tmp`, and on Windows neither the question nor the fixture
exists.

Worth recording why these three reached the branch at all: my checkout is not
under `.qwen/tmp`, so `mountRootFor` answers null here and every one of these
gates is inert on my machine. Green locally proves nothing about the geometry
they are for.

* fix(review): gate the reads and the launch directories, not only the writes

The location gate this PR added was asked at the writes and nowhere else,
which is how the same class kept resurfacing: a class closed one call site
at a time re-opens at the next call site somebody adds. Four entrances stood
open behind it, and two more of the same shape were a census away.

`worktreeResidue` measured BEFORE the gates its two call sites carry, and a
measurement is not a safe read: `status` refreshes the index, an index
refresh runs the repository's clean filter, and every identity check the
probe carries is honest about WHICH repository answers and blind to where
that repository lives. A coherent plant writes both halves of the round
trip, HEAD comes from the copied admin entry so the sha pin matches, and no
symlink is involved. The gate now runs first, and a refusal is
unmeasured-with-reason — the channel every caller already reads as "not
clean".

`fetch-pr` asked at step 4, after `cleanStale`'s force-remove and after the
PR fetch, both of which discover their repository from `process.cwd()`. In
the nested geometry `mountRootFor` documents, that launch directory is
inside the outer review's read-write mount, and a fetch through a rewritten
pointer loads the planted repository's transport config. The gate moves to
the top of the command, before the lease is even read; the step-4 ask stays
as the narrower window around the checkout `worktree add` performs.
`captureLocalDiff` gets the same treatment for the local lane, before its
first `rev-parse`.

`comment-status` and `repo-context` read through the pointer without asking
at all. No execution witness is claimed for `log`/`ls-tree`/`show` — what is
at stake there is the answer: `changedSinceComment: false` sourced from a
planted repository is this pipeline certifying that the code behind a
blocker thread did not move, and `repo-context` exists precisely to take
identity files from the merge base rather than from the PR head.

And the containment predicate judged against the innermost `.qwen/tmp` only.
The MOUNT has to stay innermost — the first occurrence would pull
`<repo>/.git` and every sibling checkout into the container — but "which
directory may a container have been given read-write" and "where may
reviewed code already have written" are different questions, and in the
nested geometry they have different answers. The judgement now takes the
outermost layer. It costs the honest layouts nothing: `git worktree add` run
from inside a linked worktree still writes its admin entry under the main
repository's `.git/worktrees/`, outside every layer.

`mountRootFor` moves from `sandboxed-exec` into `worktree` (re-exported from
its old home) so the gates can default to it instead of taking it as an
argument every new call site has to remember to pass.

Tests: `host-execution.canary.test.ts` asks the property once for every
route, with a live plant as its oracle — a rewritten gitfile naming an admin
entry whose commondir names a planted repository carrying a filter git
executes, plus a canary the filter writes on the host. Its first case
asserts the canary IS written when the same command runs ungated, so a
fixture that stops being an attack fails there rather than certifying the
gates that walked around it. Six mutations, six distinct reds. The committed
fixtures that wrote a `gitdir: `-prefixed backpointer now write the bare
path git actually writes; the prefixed form failed the round-trip check
first and masked every route behind it.

* fix(review): gate the base tree's reuse fast path too

The census that found `repo-context` and `captureLocalDiff` found one more,
and this one returns before the gate rather than running beside it: the
base-tree reuse branch answers "already built at <sha>" on two facts that
both live inside the mount — a marker file in the base tree, and
`rev-parse HEAD` resolved through that tree's own `.git`. A planted
repository answers the right sha for a working tree holding anything at
all, and the A/B's BASE side is then the reviewed code's own; every "the
base behaves differently" verdict a shard reports would be its author's.

Not a refusal: an unusable leftover is what the rebuild exists for.
Falling through discards the tree — removing the plant with it — and
creates a fresh one through the review worktree's pointer, which the gate
before `worktree add` already checks. Same shape as `scratch-tree`'s reuse
path, for the same reason.

The canary suite gains the arm: with the plant in place the report must not
say "reusing it". Removing the gate reds it, and the mutant's own note names
the planted tree as an already-built base.

* fix(review): ask the launch-directory question where all of it goes through

Hoisting the gate inside `fetch-pr` fixed one command. The census that
followed found the same shape in `load-rules` (this review's own rules, read
with `show <base>:<path>`), `submit` (where a submission goes),
`match-remote` (which remote gets fetched), `run`, `revert-hunk` and
`cleanup` — none of which knows it is asking this question, and none of
which should have to.

They all share one thing: every wrapper in `lib/git` runs git with no `cwd`,
so each discovers its repository from `process.cwd()`. That is the choke
point, so the question moves there — four functions in one file instead of a
gate per command, which is the pattern that kept re-opening this class.

The throwing wrappers throw. `gitProbe` answers its own documented "could
not be run at all" value instead, because that is a probe's contract and
every `gitOpt` caller already handles it; the command does not run either
way, which is the part that matters. `releaseWorktree` is the case that
shows why this is the right split: through a poisoned launch directory its
`worktree remove` is refused, and the `rmSync` behind it still clears the
path — the remedy keeps working without git acting through the plant.

Memoized per directory; outside a review temp dir it costs no syscall at all
(`mountRootFor` returns null on a string scan). The cached admit is the same
TOCTOU residual the per-tree gates already document and do not close.

`captureLocalDiff`'s own gate goes: it now contributes nothing but a nicer
message, and a duplicate that has to be kept in step with the real one is a
liability. `fetch-pr` keeps its own, because that command changes state that
is not a git call — the lease read and write — before it makes one, and the
ordering test pins exactly that.

Seven mutations, seven distinct red sets, baseline green.

* refactor(review): let the gates default to the mount root everywhere

Every production call site now asks `untrustedGitfile(tree)` and
`untrustedRepositoryFrom(cwd)` rather than threading `mountRootFor` through
by hand. That is the point of moving it into this module: the argument stays
injectable for the fixtures that drive the judgement with their own root, but
a new call site cannot get it wrong by forgetting to pass one — and two
spellings of the same check in one file is the kind of thing a reader has to
stop and reconcile.

The test-only reset seam on the launch-directory verdict goes with it: the
verdict is keyed on the directory, and every fixture stands its plant up in a
fresh temp dir, so a cached answer can never be served for a different one.

* fix(review): gate revert-hunk's tree reads, and reflow a comment

The last tree-scoped command that asked nothing. `git apply` writes into the
tree without running a filter, so no execution is claimed here — what the
gate refuses is deciding a probe's outcome on answers that come out of the
plant. `--show-toplevel` still prints this tree when the gitfile has been
rewritten (that is the shape the location gate exists for), and `check-attr`
and `ls-files` then report the planted repository's EOL regime and index: the
two facts this function refuses a conversion on. A scratch tree is inside the
directory the sandbox hands the reviewed code read-write, so that writer is
the code under review.

A harness fact like every other refusal there — nothing is claimed about the
hunk, and the tree is untouched.

That leaves one deliberate exception, stated rather than left to be found:
`cleanup` still runs `worktree prune` and `branch -D` from the launch
directory ungated, because it is the remedy — refusing there would leave a
planted tree with nothing able to sweep it, and its `rmSync` clears the path
whether or not git can act through the pointer.

* fix(review): let a refusal say it refused, and re-ask where git resolves

Six review Criticals with one theme: a gate that answers correctly, then
reports through a channel its callers read as an answer.

`gitProbe` returned the launch-dir refusal as `{out: null, status: null}`
and every consumer used the status-blind `gitOpt`, so `releaseWorktree`
deleted the directory with `rmSync`, reported `freed: true`, and left
behind the registration and branch that wedge the next `worktree add`;
`load-rules` wrote an empty rules file into every agent brief while
printing "No review rules found". `status: null` already documented "the
command could not be run at all" — git answers a path absent at a ref
with 128 — so the fix keys on that and carries the refusal text beside
it, rather than adding a third state. `releaseWorktree` now reports the
release it could not make, and `load-rules` separates a source it could
not read from one that is absent.

The launch-dir verdict was memoized for the whole process, so a pointer
rewritten after the run's first git call was served that first clean
answer forever — and in the nested geometry the outer review's
containerized build holds the directory read-write for the length of a
command, which is a rewrite between calls, not between a check and its
use. Only the negative is cached now, and only outside a review temp dir
where it costs no syscall.

fetch-pr's step-4 refusal was thrown inside the try whose catch rolled
the fetched ref back with an ungated `branch -D`, so the gate's own
answer executed the plant's reference-transaction hooks on the host and
the run died as `Failed to create worktree at …`, indistinguishable from
an infrastructure failure. The ask is hoisted out, and the five remaining
spawns in fetch-pr and cleanup that resolved their repository from
`process.cwd()` go through lib/git's gated wrappers. Cleanup still
sweeps: the `rmSync` that clears the path is not a git call, and a
measured ungated prune was clearing the PLANT's registrations, not the
real one.

A lease moved out of the mounted directory with no fallback read, so an
older build's live lock was invisible to the gate and the next
acquisition deleted it before sweeping its worktree mid-run — #9205, the
incident the lease exists to prevent. The gate reads both locations for
one release, and acquisition refuses a foreign legacy lease instead of
removing it.

base-tree's reuse fast path policied the tree's pointer but not its
contents, and `rev-parse HEAD` does not move when working files change,
so the reviewed build could overwrite the base checkout and the A/B
compared the PR against a copy of itself — a regression it breaks then
reads as pre-existing on both sides. Tracked dirt falls through to the
rebuild; untracked build output still reuses, or the concurrent-shard
clobber the fast path prevents comes back. Measured: `npm run build`
here modifies no tracked file.

revert-hunk decided where `--tree`'s repository resolves but never asked
whether `--tree` IS that tree, so a symlink at the scratch path aimed
`git apply -R` at another legitimate tree while the report certified the
scratch path. The leaf is refused, and re-read immediately before the
write. The bounded ancestor walk `resetScratchTree` also does is not
added here: this command has no common dir to bound at, and an unbounded
walk refuses every tree under a linked `/tmp`.

Also one definition of the attack fixture instead of ten copies across
five suites, which repairs the one copy whose admin-entry backpointer
carried a `gitdir: ` prefix git never writes there.

* fix(review): ask a pointer where it resolves to, and stamp the run that built a tree

Four review Criticals with one theme: a gate that asked half the question,
and a marker that recorded what it vouched for but not who vouched.

A gitfile rewritten to `gitdir: <repo>/.git` names the repository's OWN
common dir, which resolves OUTSIDE the mount, so every gate that asked only
where the answer lives admitted it and then acted on the main repository
through it — measured, `status` refreshed the main index and `rev-parse
HEAD` answered the main head, so the reads a review treats as fact came
from a tree nobody verified. Both questions are now asked in one place, so
the tree gate, the launch-directory gate and the residue probe close
together instead of one entrance at a time, and the two `rev-parse` calls
share one sanitized spawn instead of duplicating its boilerplate.

scratch-tree's reuse route asked about the scratch pointer only, never
about the review worktree's — the one `headSha` and every comparison inside
the reset resolve through — so an unpinned run answered `available: true,
reused: true` with the main head and reset the verifier's tree to the
user's own commit. Suspicion there falls through to the rebuild, whose gate
refuses, rather than refusing on the spot.

`mountRootFor` matched no temp dir for a process standing AT `.qwen/tmp`,
because the marker ends in a separator: the launch-directory question went
unpoliced there and `lib/git` memoized that negative as trusted.

The base-tree reuse marker recorded a sha but not a run, and `cleanStale`
never releases `-base`, so an earlier round's tree was certified as this
run's A/B base after a whole containerized phase could drop untracked
executables into it — `dist/cli.js` is what a host-side A/B runs, and
`--untracked-files=no` cannot see it. Refusing any untracked file at all
would disable every legitimate reuse and bring back the concurrent-shard
clobber the fast path exists to prevent, so the marker carries the run
epoch the deadline stamps and the session ledger already key on: same-run
reuse survives, an earlier run's tree rebuilds.

And the lease move left `isReviewPlumbing` naming its directories by hand,
so a checkout holding a lease captured that churned lease JSON as the
user's untracked change and an interleaved local round could never again
report "no changes". The pattern is built from the `paths.ts` constants
now, which is what keeps the next move from re-opening it.

Two existing witnesses were repaired rather than left green for the wrong
reason: the canary's base-tree fixture and the different-sha marker case
both hand-wrote a one-line marker, which the epoch fence now turns away
before the gate each exists to exercise. Both stamp the epoch, and a probe
removing the reuse pointer gate reds the canary again.

* fix(review): fail closed where a gate could not be run, and stop editing git's answer

Seven review Criticals, all in code this pull request added: four ways a gate
reported a success it had not earned, one that threw where its contract says it
degrades, one lane-breaking test, and one regex that edited the very answer it
exists to pass through unedited.

`rev-parse` answered null for three different things — git ran and said this is
no repository, git timed out, and git died on some other fatal — and two of the
three gates read all three as "no objection". The launch-directory gate budgets
30s against the protected commands' 120s, and the residue probe's spawns carry
no budget at all, so a config sized to parse between the two left the gate
silent while the command it gates resolved through the pointer nobody had
judged: measured, `status` refreshed the planted index and ran its clean filter
on the host. The null is classified once, at the spawn that produces it, and
both gates refuse the class they cannot judge. A genuine not-a-repository still
passes through — that is the caller's own error path to own, and refusing it
would answer a question nobody asked. Pinning `LC_ALL` is what makes that
classification depend on git's behaviour rather than on the runner's locale.

The same strip that removed git's record terminator also removed a `\r` that
was the last byte of the path itself, which is precisely what the docstring
above it forbids: a plant named `evil\r` was judged at its twin `evil`,
symlinked outside the mount, and admitted while every gated command resolved
through the plant.

`gitProbe`'s launch-dir pre-check read `process.cwd()` outside its try, so a
review whose worktree was swept out from under it threw ENOENT through
`releaseWorktree`'s documented never-throws contract and aborted the sweep
before the branch delete and the lease release — leaving the stale lease that
refuses every later cleanup of that target.

`cleanup` collapsed a refusal into each of its two benign answers: `refExists`
said "no such branch", so the branch leg was skipped silently and the lease
released over a surviving branch, and `pruneWorktrees` said "nothing to prune",
so the symlink arm announced `Removed … link` over a registration that stayed.
Both report the refusal and hold the lease now. A genuine prune failure stays
swallowed, because it must not mask the error that got us here.

And the rewritten `toContain('common dir')` assertion depended on the
mount-relative gate answering first, which it never does on Windows — the same
lane this pull request has broken three times before. Split by the file's own
convention rather than branching one expectation on `process.platform`, so each
lane asserts a single unconditional sentence.

Every guard above has a witness that reds without it, including the admit arms:
a genuine not-a-repository keeps passing, and the residue probe's `status` is
shown never to run at all.

* fix(review): fail cleanup's branch probe on any non-answer, survive a deleted cwd

With --verify --quiet only exit 1 is genuine absence: a null status
(spawn failure, timeout kill) or a 128 fatal used to read as "no such
branch", so the delete was skipped, the lease released, and "Nothing
to clean" printed over a surviving branch (R19-1). Name the non-answer
and hold the lease instead.

Also capture the launch directory at runCleanup's entry and hand it to
the ancestor walks as an explicit stopAt: their default reads
process.cwd() outside any try, so a deleted cwd threw uv_cwd out of the
best-effort sweep before any degradation could run (the cleanup half of
R19-4). The holder-skip note now names the path the lease was found at,
which during the rollout can be the pre-move path (R20-1).

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

* fix(review): walk --tree's ancestors for a planted symlink, not only its leaf

The redirect gate checked the leaf of --tree alone, at entry and again
before the mutating git apply. A symlink swapped in at an ancestor
between the review temp root and the leaf — after the create/sweep
checks ran — was invisible to every check in the chain, and git apply -R
then reverse-applied into whichever tree the link names while the report
certified the scratch path (R24-1). Walk the ancestors bounded at the
outermost review temp root, at both checks; a --tree outside any review
temp root keeps the leaf check so a linked /var refuses nothing.

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

* fix(review): mirror new leases at the legacy path, bound the legacy read

The dual-location lease read was one-directional: a pre-move build stays
blind to leases written at the new path, so for the rollout window an old
fetch-pr passes its gate over a live lease and force-removes the new
session's worktree mid-run (R16-1). Mirror the lease at the legacy path
after the new-path wx write wins; an honored foreign lease surfacing
before the mirror backs the acquisition out instead of clobbering it.

The legacy read also re-granted gate authority to the one directory
reviewed code can write: a planted legacy lease naming a foreign session
was a permanent denial of service (R24-2). A legacy lease is now honored
only while its mtime predates LEGACY_LEASE_CUTOFF_MS (the release date of
the first build carrying the move — MUST be set before shipping).

Release and diagnostics see both locations (R20-1): clear removes the
legacy path too (recursive, so a directory at the lease name cannot
EISDIR-wedge), and the held-lease messages name the path the lease was
actually found at via the new readReviewWorktreeLeaseAt. The finalizer
sweeps the legacy directory only for leases content-identical to the
new-path twin — the mirror is readable from inside the mount, so copied
credentials prove nothing, and a plant must never drive a host-side
removal. readLease lstats before opening, so a planted FIFO hangs no
gate read (R24-4).

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

* fix(review): fence base-tree reuse with a per-run secret kept outside the mount

The reuse fence keyed on inputs the sandbox's read-write mount exposes:
the run epoch was the plan's mtime and the plan lives inside the mount,
so reviewed code could forge the stamp and have the pipeline certify an
attacker-shaped tree as the A/B base (R15-4). The stamp's second line is
now a random per-run nonce persisted beside REVIEW_LEASE_DIR — created
atomically by the run's first host process, adopted by its shards,
unreadable and unwritable from inside the mount. The failed marker
carries the same fence: a planted sha-only marker no longer settles the
A/B lane into "already failed" with no build attempted (R22-1).

At marker write the untracked path set the build legitimately left is
recorded host-side, and reuse requires the current set to be a subset —
an untracked plant dropped after the stamp (dist/cli.js, what a
host-side A/B executes as the BASE side) falls through to the rebuild
that sweeps it (R25-1). And when a tree THIS run built fails a state
check — legitimate codegen dirt, a concurrent shard's snapshot update —
the command declines busy instead of discarding the live tree another
shard may be mid-A/B in; discard-and-rebuild is kept for cross-run
leftovers, forges, and plants (R26-1).

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

* fix(review): fail the trust gates closed when the mount root is refused

mountRootFor's null was overloaded: "outside any temp dir" and "inside
one, but refused" were the same value, and every trust gate read a
refusal as "nothing to police" — measured end-to-end with a symlinked
.qwen/tmp, where a gated worktree add ran through the plant and its
smudge filter wrote its canary on the host (R26-3). The gates now ask
the containment question lexically (insideReviewTmpLexically, no
filesystem) beside mountRootFor, and where the spelling says inside and
the mount says null they refuse with a reason of their own — including
a launch directory that no longer exists at its spelling, the rename
attack's shape.

The launch-dir gate's memo made that inversion permanent: a null
produced while mountRootFor consulted the filesystem was cached TRUSTED
for the process lifetime, so one  from inside the
outer mount switched the gate off and a planted filter.<x>.clean
executed as the review user (R26-2). Only the pure no-marker string
scan is memoized now; inside a review temp dir the question is re-asked
on every call, as documented.

Also: releaseWorktree's not-freed predicate keyed only on refusal, so a
probe's {status: null} (spawn failure, timeout kill, deleted cwd) read
as "no objection" and freed:true was certified over a surviving
registration and branch — the next add met "missing but already
registered" (R24-3); and its first statement read the cwd outside any
try, so a deleted cwd threw uv_cwd through the never-throws contract
(R19-4). comment-status evaluated the same trust gate twice per run and
could certify a trust state its own threads[] contradicted; the verdict
is now computed once and handed to makeGitProbe (R26-4). mountRootFor's
walk bound moves from the checkout's parent to the geometry-aware root
(a linked direct parent refused every macOS /tmp checkout, R19-3), and
its Windows refusal covers the colon-less UNC shape as a class
(R19-2).

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

* fix(review): round-trip the admin entry's backpointer in the location gate

untrustedPointer admitted a gitfile rewritten to a SIBLING worktree's
legitimate admin entry: the entry is outside the mount and is not the
common dir, so both location questions passed while every host-side
command through it measured and mutated the sibling tree (R23-2). The
gate now carries the same backpointer round-trip the write paths always
have: the entry's gitdir file must resolve back to the tree it is asked
about, or the tree is refused. Only a definitive mismatch is this arm's
to name — an unreadable or dangling backpointer redirects nothing, is
refused by the downstream identity checks with their own reasons, and
the one writer who could corrupt a backpointer to duck the mismatch
cannot reach an entry outside the mount at all.

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

* fix(review): count a lossy decode of git's answer as no answer (R8-2)

encoding: 'utf8' maps an undecodable byte in git's byte-exact rev-parse
output to U+FFFD, so a gitfile target holding an invalid UTF-8 byte was
judged at the U+FFFD spelling while git acted on the original bytes: a
plant under the raw-byte name inside the mount and a decoy under the
U+FFFD one, symlinked to the tree's real admin entry outside it, passed
every question the location gate asks — the round-trip included — while
the gated write resolved through the plant. No gate may judge a spelling
git never printed: a U+FFFD anywhere in the answer now counts as the
question not answered, which every caller already fails closed on.

The witness plants the full shape (raw-byte admin entry, U+FFFD decoy)
and is itself gated to Linux: NTFS is UTF-16 and APFS rejects invalid
UTF-8 names with EILSEQ, so neither the fixture nor the attack can
exist there.

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

* fix(review): fail the prune arm when git could not be run at all (R23-8)

The symlink arm's pruneWorktrees() surfaced only the probe's refusal, so
the third shape {out: null, status: null, refusal: null} — a spawn
failure, the timeout kill, a deleted cwd — read as a successful prune:
the arm announced "Removed ... link", wrote no stderr line, and released
the lease over a registration git never swept, and the next worktree add
met "missing but already registered" with nobody told why. Same
shape-conflation class as R19-1 at a second site. Only genuine non-zero
exits stay swallowed.

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

* test(review): type the lease-read mocks with their parameters

tsc --build (unlike the vitest run) rejects the readReviewWorktreeLeaseAt
delegation calling a zero-argument vi.fn with two.

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

* fix(review): close what the trust-artifact move's first pass missed

Four follow-ups from an adversarial audit of the fence:

The untracked record was git status's collapsed path set: dist/ and
node_modules/ recorded as one entry each, so a plant dropped INSIDE a
directory the build left — the exact executable a host-side A/B runs —
changed no membership and passed the subset fence. The record is now
file-level (ls-files --others, which never collapses), so an addition
inside a recorded directory IS a membership change.

The trust file was keyed by the plan's path and mtime, and the plan lives
inside the mount: a utimensat backdate to an earlier run's exact stamp
re-keyed this run to that run's file, adopting its nonce. The key now
takes max(mtimeMs, ctimeMs) — ctime cannot be set from userland, so the
identity cannot be rolled back.

The busy arm over-generalized: a rewritten pointer or moved HEAD on a
tree this run built has no benign cause, and declining left the plant
standing for the run. Those arms now fall through to discard-and-rebuild
— while tracked dirt AND untracked additions (an A/B's own snapshot
--update or cache output is the benign shape) decline busy instead.

And the discard itself could not rebuild a planted tree: worktree remove
refuses a tree whose gitfile no longer points back, so the removal
cleared the coherent plant's admin dir while the real registration
survived and the next add met "missing but already registered" — the
deferred refusal->rebuild wedge. When the remove fails, the reverse scan
now also runs, narrowed to the tree's own basename so a tampered sibling
entry is never deleted.

Also pins the sweep's per-field equality with a same-target doctored
twin (the worktreePath comparison had no witness) and gives
readReviewWorktreeLeaseAt a legacy found-at-path witness.

* fix(review): close the audit's second pass — witness gaps and one substitution route

From the adversarial audit of the takeover batch:

- releaseWorktree's symlink arm now has its own witness for the
  status-null prune (the main arm had one; the sibling arm was pinned by
  nothing — the mutation survived green).
- The launch-dir memo's comment now names the rename attack's actual
  premise — Node's cachedCwd, not a kernel property, so a future
  in-process chdir is the thing that re-opens the window — and the canary
  pins the stale-spelling premise instead of assuming it.
- scratch-tree's --standalone shape asks the location question before
  buildStandaloneTree: headSha was read through the review worktree's own
  gitfile, and with no --fetched-sha pin a planted HEAD would put the
  plant's content in the tree presented as the commit under review.
- revert-hunk's outermost-bound arithmetic gains a nested-geometry
  witness (a swap at the OUTER layer), so a drift to lastIndexOf reds it.

* fix(review): anchor the scratch sweep at the captured root (R30-6)

The entry-captured repositoryRoot was handed to the ancestor walks but
not to scratchWorktreesOf: its resolve(worktree) and redirectedAncestor
default stop both read process.cwd() again, so a cwd deleted mid-run
threw uv_cwd out of the sweep — past the entry guard that exists to
catch exactly that. Both now anchor at the captured root. The witness is
the call count: a full cleanup reads process.cwd() exactly once.

* fix(review): the base-tree fence reads only host-side state

Rounds 27-30 found the nonce-in-marker design's hole: the markers carried
the run secret in cleartext inside the tree — a child of the read-write
mount — so reviewed code read it and forged a settling failed-marker (the
A/B lane suppressed as "infrastructure") or an ok marker over a tree it
shaped. The fence now reads the trust record alone, kept outside the
mount: per tree it carries the pinned base, the build's verdict, and the
untracked inventory per file with its size+ctime pair — an in-place
rewrite of a recorded file is as visible as an added one. Markers in the
tree are informational baseSha notes, excluded from every fence decision.

The trust file is one per plan path, rotated in place on identity drift
so no earlier generation survives to adopt; the run identity is the
worktree lease's mtime when one is held (host-side, mount-untouchable)
and the plan mtime with the ledger's tolerance otherwise. A mid-run
mergeBaseSha rewrite conflicts with the pin and declines rather than
rebuilding at a sha named after the fact. Same-run trees with a torn or
missing record decline busy instead of being discarded; pointer/HEAD
rewrites still discard-and-rebuild. The record step re-asks the pointer
question first, and the git helpers carry the inert-config pins and a
512 MiB buffer so a real tree's ignored listing cannot silently drop the
record.

* fix(review): the mount-null is three answers, and the gates split them

Where containment cannot exist — a drive-letter colon, a UNC share, a
colon-bearing POSIX checkout — the review never ran sandboxed, so the
mount is no trust boundary and the gates' docstrings promise silence.
The fail-closed arm read every null as a refusal and refused
unconditionally there (measured: 43 red tests under the declared Windows
model). mountRootFor now shares one implementation with a discriminated
nullKind (outside / unmountable / refused); the gates fail closed only on
a redirect or a root that vanished mid-check, and stay silent where
containment cannot exist.

The pointer gate's round-trip is now exact: the admin entry must own the
tree the question is about (untrustedRepositoryFrom compares against
git's own --show-toplevel, which a borrow cannot bring into agreement),
an owner that cannot be resolved fails closed, and a redirect planted
between the mount root and the owner is refused before anything resolves
through it — the round trip must not agree with a link. The mount root is
threaded through the questions rather than re-asked mid-gate, so a
mid-gate flip of the mount cannot split the answer. The flat-case walk
bound moves to .qwen: the checkout's own directory is the user's layout,
not the pipeline's.

releaseWorktree's not-freed predicate keys on the prune alone now: a
remove that answered 0 already cleared the registration, and keying on
the remove's null published freed:false over a release the prune had
completed. Witnesses pin each arm, including the symlink arm that had
none.

* fix(review): the legacy lease is never believed, the mirror never fatal

The mtime-bounded honored read is gone entirely: a pinned-to-the-past
cutoff froze the honored population at release day while remaining a
utimes-forgeable permanent DoS channel from inside the mount. A
legacy-path file is residue now — the mirror still replaces it (loudly,
naming the displaced session, when it parses as another session's lock)
so pre-move builds keep seeing the lock, and only the new path answers
the gate. The mirror write is best-effort (an obstruction in the mounted
directory is mount weather, warned and skipped, never a failed
acquisition) and replaces via tmp-file + renameSync, which never follows
a planted symlink and never opens a readerless FIFO.

The finalizer is driven solely by the trusted directory: one destructive
pass per target, the legacy twin deleted only when content-identical —
and never listed, so a poisoned mount cannot shape the sweep. The lease
directory re-roots lexically to the outermost enclosing repository in the
nested geometry, so a review launched from inside another review's
worktree keeps its host-trusted state outside every layer's mount.

fetch-pr's step 4 also asks the destination's ancestors: a symlink left
standing by a declined releaseWorktree would otherwise have mkdirSync and
worktree add create and check out the PR's code at the link's target —
outside the review temp dir, unsandboxed. And the review job's lease
sweep feeds both globs through remove_review_tree's repair ladder instead
of a bare rm -rf, so a mode-000 wedge is reported rather than silently
surviving. Tests that delete the process's own cwd or need a mount are
gated to hosts where those exist.

* fix(review): judge the launch directory where the kernel stands (R30-47)

The launch-dir gate judged Node's cached process.cwd() spelling while
every wrapper spawns with no cwd, inheriting the KERNEL cwd — and the two
diverge exactly once an ancestor is renamed. A leaf rename inside the
mount plus the spelling re-stood as a symlink to an honest sibling made
the cached spelling pass every arm while git ran in the renamed plant.
The gate now asks the kernel (/bin/pwd -P) where the process stands and
judges that spelling; a divergence is the rename, refused. Windows keeps
the cached-spelling judgement: no /bin/pwd there, and no containment to
protect either. The canary witness drives the full leaf-rename shape and
goes red with the kernel judgement disabled.

* fix(review): the second audit round's findings on the batch itself

- base-tree trust: key under the OUTERMOST repository in nested geometry
  — the lease re-roots there (R27-6) and the trust file must follow, or
  it lands inside the outer review's writable mount where the fence's
  record is the outer code's to forge; runIdentityMs then reads the lease
  where it actually lives.
- redirectedAncestor: keep walking past a nonexistent component — a leaf
  ENOENT under a symlinked ancestor used to end the walk with the link
  itself un-lstat'd, which defeated the R27-9 gate when the link sat
  above an unresolvable leaf.
- createReviewWorktreeLease: a same-session refresh preserves the lease
  mtime — the base-tree run identity keys on it, and a resumed run would
  otherwise rotate the trust state and discard the standing base tree.
- untrackedInventory is a null-prototype map: a file literally named
  __proto__ would otherwise land in the prototype setter, invisible to
  the fence.
- mirror/atomicWrite tmp files are removed on a failed rename; the
  standalone refusal no longer prescribes a remedy (--fetched-sha) that
  the same gate would refuse.

* fix(review): the mirror's tmp cleanup must not throw either

The mirror is documented never fatal, but the rename-failure catch called
rmSync unguarded: an rm error there escaped createReviewWorktreeLease
after the new-path lease had already won, rolling back the acquisition —
the fatal outcome the contract exists to prevent, one failure deeper.
Same wrap in the trust store's atomicWrite, where a failing cleanup would
have masked the original write error.

* fix(review): import rmSync in the trust store, type the rmSync mock's path

esbuild-transpiled vitest runs don't typecheck, so the round-3 commit
built red in CI: atomicWrite's new catch used rmSync without importing
it, and the lease suite's rmSync mock typed the path PathOrFileDescriptor
where the real signature takes PathLike.

* refactor(review): split the base-tree reuse fence out of this PR

The fence landed here on 2026-09-08 to answer R29-1, and it has been the
cluster the review keeps returning to: 11 of the 18 open Criticals live in
`base-tree.ts` / `lib/base-tree-trust.ts` or in code those two pulled into
other files, against 22% of the diff. Round 33 said it plainly — a cluster
that keeps producing siblings is one root cause being fixed instance by
instance, and splitting it ends the loop faster than another round.

The dependency runs one way: the fence uses the location gates from
`lib/worktree.ts`, nothing in the gates uses the fence. So it lifts out
whole, and it comes back as its own PR stacked on this one.

What leaves:

- `lib/base-tree-trust.ts` and its suite — the host-side trust store.
- `base-tree.ts` and `base-tree.test.ts` return to their state before the
  fence commit. The location gates this PR is about (`untrustedGitfile` at
  the reuse fast path, at the worktree read and at the rebuild) predate the
  fence and stay; `GIT_NEUTRALIZE`, `gitOutZ` and the 512 MB `maxBuffer`
  were introduced for the fence's inventory listing and leave with it.
- The canary's base-tree case goes back to certifying on the in-tree marker
  — which is what the command reads again — and the fence's own
  record-step canary leaves.
- `createReviewWorktreeLease`'s mtime preservation: it exists only so the
  fence's run identity survives a resumed run's re-acquisition, and it is
  R33-1's subject. Its tmp-file cleanup on a failed mirror rename is
  unrelated and stays.

`packages/cli` is green (120 files, 5997 tests), `tsc --noEmit` clean, and
the one deterministically-red test on Linux (R33-2) leaves with the fence
file it lived in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vBGpv2LrLQ8QNEYu2gsNn

* fix(review): the seven Criticals the split leaves behind

Every one is mutation-probed: the guard is removed or negated, the case
named beside it goes red, and the rest of the suite stays green.

`releaseWorktree` (R32-2). The `refusal !== null` disjunct that forced
`freed: false` was not gated on "neither arm cleared the registration",
while `couldNotRun` beside it was. Each probe re-asks `launchDirRefusal()`
from scratch, so a refusal on one arm negated a release the other had
completed — the same miskeying R31-1 and R28-11 record for the null-status
arm. A refusal is already inside `couldNotRun` for its own arm, so it now
only ever supplies the REASON for that verdict, never makes one.

`untrustedPointer`, question 3 (R32-10). The containment test that gates
the `redirectedAncestor` walk was bounded at the DEEPEST review temp root
while the location question two above is widened to the OUTERMOST. A
sibling of the enclosing review's worktree, relinked at this tree, sat
outside the walk entirely and the realpath round trip agreed with the link.
Both questions ask against `outermostReviewTmpRoot` now.

`untrustedPointer`, question 2 (R33-3). `resolvedCommonDir` dropped the
`notARepository` half of the taxonomy, so a timeout, a spawn failure or a
git too old for `--path-format` read as "no objection" — and question 2 is
the only one of the three that sees `gitdir: <repo>/.git`. It carries the
taxonomy now and refuses every no-answer that is not git's own.

`dropWorktreeRegistration` (R32-12). git appends a counter to a worktree id
whenever the basename is taken, so the reverse scan's `id === basename`
narrowing could not match `wt1` — the entry the tree actually owns — and
left the registration standing after `rmSync` took the directory. Matched
against git's own id grammar instead; the gitdir-names-this-path and
tree-is-gone narrowings still carry the "never a sibling's" half.

`fetch-pr` step 4 (R32-11). The destination guard walked `dirname(wt)` and
never `wt`, so a link AT the worktree path redirected the create and the
checkout. The walk starts at the leaf; `redirectedAncestor` lstats its
first component before any stop test, so the ancestors are unchanged.

`clearReviewWorktreeLease` (R32-4). The legacy delete is the one
mount-resident removal in the clear path and it ran unfenced, after the
trusted lease was already released — a planted mode-500 directory or a
Windows handle threw out of `runCleanup`, whose tests pin it as never
throwing. Loud instead, like the mirror write.

The mirror displacement warning (R32-3). It interpolated a session id read
out of the writable mount verbatim; a newline in it forges a second stderr
line and a raw ESC repaints the terminal. Through `inertPath` now, the
flattener the rest of the repo already uses, and bounded — the warning
names the displaced run, it does not carry its payload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vBGpv2LrLQ8QNEYu2gsNn

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: wenshao <nigolaschao777@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 12:30:35 +00:00
易良
585589ac67
fix(dev): install hooks during worktree bootstrap (#11496)
* fix(dev): install hooks during worktree bootstrap

* fix(dev): keep worktree hook setup out of the shared git config

Husky runs `git config core.hooksPath .husky/_` with no --worktree, so
from a linked worktree the value lands in the config every worktree of
the repository shares while `.husky/_` is created only in the checkout
being bootstrapped. Skip the Husky step and report it when the key is
unset and this checkout does not own the repository config, so a
bootstrap can no longer repoint hook resolution for roots that never
received the wrappers. A primary checkout still installs hooks, and an
already-configured `core.hooksPath` is untouched.

Also drop the caller's success exit, which `install()` made unreachable
when it started exiting on every successful path, and bring the
pnpm-worktree-bootstrap design doc in line with a hook step it still
recorded as deliberately skipped.

The new fixture runs the real script against a throwaway root whose
`.git` is a file or a directory and whose config comes from a real
`git init` repo, which makes both new branches reachable and pins the
fail-closed guard: the injected `GIT_CONFIG_*` constant holds one value
for the child's whole lifetime and cannot express the unset state that
asks husky to write.

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

* fix(dev): ask git which root owns the worktree hook config

`ownsRepositoryConfig()` inferred repository-config ownership from the
filesystem shape of `.git`, and the proxy is wrong at both ends. With no
`.git` at all, `statSync(..., { throwIfNoEntry: false })` returns `undefined`
and the predicate folded that absence into "owns the config", so a
repository-less checkout ran husky into its `.git can't be found` soft failure
(exit 0) and the fail-closed check then turned a successful dependency install
into exit 1 blaming Husky. A `.git` file is not only a linked worktree either:
`git clone --separate-git-dir` checkouts and submodules have one too and do own
their config, so hooks were declined where they would have been correctly
scoped.

Ask git instead: `rev-parse --git-dir` differs from `--git-common-dir` only in
a linked worktree, and a failed `rev-parse` names the no-repository state, so
the skip notice stops asserting "linked worktree" and the bootstrap does not
gain a hard git dependency.

Also bind the fail-closed check to an artefact husky's own write produced, not
only to the config value. husky 9.1.7 exits 0 on every soft-failure path
(`index.js:16` git command not found, `index.js:17` refused `git config` write)
before the `mkdirSync(_())` on line 19, and a linked worktree inherits
`core.hooksPath` from the config it shares, so re-reading that value compared
it against itself and passed exactly when husky had created nothing.

Restore the registry-fallback case's hermeticity: `PATH` holds only the stub
directory again, which now also pins that the retry needs no ambient git.
Rebuild the ownership fixture from real git layouts, because `rev-parse`
resolves nothing for a `mkdirSync`'d `.git` or a hand-written `gitdir:` file,
and give the stub husky a failing mode so the exit code husky returns is pinned
rather than the install result's.

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

* fix(dev): surface hooks-path read failures in worktree bootstrap

- getHooksPath() no longer collapses a refused git config read (exit 128/2/3) into "unset": only an absent key (exit 1) or a missing git binary keeps the skip path; anything else fails the bootstrap with the read error instead of a green, hook-less worktree.

- The linked-worktree skip notice now names the recovery path: re-run this script once the primary checkout has hooks installed.

- Tests cover a git stub exiting 128, the real-world unset HUSKY state, and pin the recovery sentence in the skip notice.

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-10 09:44:47 +00:00
易良
11e629b3a1
perf(export): split the transcript renderer's embedded CSS into a versioned asset (#11485)
* perf(export): split the transcript renderer's embedded CSS into a versioned asset

The export renderer carried the web-shell component stylesheet as a ~2.3 MB
string literal, so every reader parsed and compiled 4.1 MB of JS (56% of it dead
CSS) before a transcript could render. Lift that literal out at export build
time into a version-pinned, SRI-protected export-transcript-document.css served
from unpkg and loaded via a nonce-bearing <link>, dropping the renderer JS to
~1.83 MB.

The transform is an esbuild onLoad plugin in the web-templates export build that
strips the injected CSS constant from web-shell's dist/transcript.js; web-shell
source and runtime behavior are untouched. The document's fail-closed load-error
path is extended to the stylesheet so a missing CSS asset fails the same way as
a missing renderer.

* fix(export): match the transcript CSS entry on Windows paths too

esbuild hands plugin callbacks the platform-native absolute path, so the
extract-transcript-css `onLoad` filter never matched on Windows: the callback
did not run, `extractedTranscriptCss.css` stayed undefined, and the mandatory
extraction guard below aborted the build. That build is not platform-gated —
`scripts/prepare.js` runs it from `prepare`, so `npm ci` itself would fail on
every Windows contributor and on the windows-latest legs of test_windows and
desktop-release.

Widen the separator to `[\\/]`, keeping the `transcript\.js$` tail so the
barred `web-shell/dist/index.js` package root still does not match. The filter
moves to transcript-css-entry.mjs because build.mjs is a top-level-await script
with no harness — the same reason scripts/sdk-node-exporter-stub.js exists — so
scripts/tests/transcript-css-entry-filter.test.js can pin both separators.

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

* fix(export): latch a stylesheet failure ahead of the parser block

The `<link id="transcript-stylesheet">` sits in `<head>` while the `window`
error listener that catches its failure is registered by an inline script in
`<body>`. Chromium parser-blocks that script on the pending stylesheet, so when
the CSS failure settles first the error event is dispatched with no listener to
receive it: nothing marks the render as failed, both renderComplete guards in
document-main.tsx pass, React mounts the transcript without any of the
component CSS, and the requestAnimationFrame stamps
`data-render-complete="true"`. The reviewer measured this fail-open above
roughly 2.1 MB of document HTML (272 of the 1,000 permitted blocks) for a 404,
an SRI rejection, a truncated body and a destroyed socket alike, and fail-closed
for a *late* failure — so size, not failure kind, decides it.

Latch the failure in `<head>` before the `<link>` is parsed and act on the latch
from the existing body IIFE. The head script only records: `showLoadError()`
writes `document.body.dataset` and `#app`, neither of which exists while the
parser is still in `<head>`. It carries `nonce="__EXPORT_NONCE__"` because the
document CSP allows no inline script, which is safe — `formatters/html.ts:53`
replaces every occurrence. The listener is capture-phase because resource error
events do not bubble.

Not the `link.sheet === null` variant: the reviewer measured `sheet` non-null
for a 404, a truncated body and a destroyed socket, so it only detects SRI
rejection.

scripts/tests/export-transcript-document-template.test.js pins the position,
the nonce, the capture phase and the record-only shape; all five cases go red
against the unpatched template. The behavioural witness (real Chromium, large
document, instant CSS abort) belongs to the playwright transcript gate, which
is out of budget on this host.

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

* fix(scripts): name the missing export renderer assets, pin the CSS gate

The bundle copy became all-or-nothing over two artifacts but its `else` warning
still named only the renderer, so the one new way to reach that branch — a tree
built before the split, then `npm run bundle`d without rebuilding web-templates,
which has the JS and no CSS — told the operator to go looking for a
`export-transcript-document.js` that was sitting right there, and silently
discarded it. List the paths that are actually absent, matching the sibling Web
Shell warning twenty lines above. Stays warn-and-skip: prepare-package.js is the
release gate.

Also pin that release gate. Every fixture that reached `preparePackage` staged
`dist/export-transcript-document.css` unconditionally, so deleting the new
required-path entry left the whole test:scripts lane green; a release built with
`npm ci --ignore-scripts` would then publish documents whose stylesheet 404s on
unpkg for that version. `verifyBundleArtifacts` reports through console.error +
process.exit(1) rather than a throw, so the new case stubs exit instead of
copying the audio-capture sibling's `toThrow` idiom.

Both cases were flip-checked: restoring the old warning text, and deleting the
CSS line from prepare-package.js, each turn their case red.

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

* fix(export): pin the stylesheet-failure id contract and sync the design docs

The <head> latch, the body listener and the <link> each spell
'transcript-stylesheet' independently and nothing compared them, so renaming
either listener's id left the whole suite green while the latch recorded
nothing - reinstating the fail-open the latch was added to close. Derive the id
from the <link> and assert both listeners compare against it. Verified red under
both mutations: latch id -> 'transcript-renderer' (1 failed | 5 passed), and the
mirror with the body listener's id wrong and the latch intact (same).

Both design docs still specified the two shapes the previous round replaced: the
forward-slash-only onLoad filter that never matches on Windows, and the
body-listener-only fail-closed extension. Section 1 now quotes the shipped
TRANSCRIPT_CSS_ENTRY_FILTER and names transcript-css-entry.mjs, section 2
describes the <head> latch (position, nonce, capture phase, record-only),
section 3 names the module-level render guard, and "Files affected" lists the
three omitted files. EN and zh-CN are updated in the same commit.

Also correct the shape-guard comment in build.mjs: the document nonces every
<style> created through document.createElement, so the CSP would not block an
un-stripped duplicate, and a 367-byte regrowth stays inside both byte budgets.
That throw is the only guard on the duplicate-injection path.

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

* fix(export): close out transcript CSS review comments

* fix(export): tighten transcript CSS closeout

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-10 07:15:32 +00:00
Shaojin Wen
f6540d1994
fix(web-shell): make the workspace provider guard self-diagnosing and reload on root retry (#11421)
* fix(web-shell): make the workspace provider guard self-diagnosing and reload on root retry

The strict useDaemonWorkspace guard fails closed with a message that
cannot distinguish an absent provider from a duplicated module copy
(two DaemonWorkspaceContext instances in one page, seen in dev when the
module graph is refreshed under a live page), so the root boundary
showed a dead-end fallback that only a manual reload could clear.

The provider now registers a per-module-copy marker on first render,
and the guard's error states which of the three cases it hit: no
provider rendered, a provider rendered from a different module copy
(with both copy ids), or a provider from this copy rendered and the
consumer is outside its subtree. The standalone root boundary's retry
now reloads the page, since re-mounting the same broken module graph
would throw again.

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

* test(web-shell): restore spies in afterEach so a failing retry test cannot leak the console.error mock

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

* fix(web-shell): guard the root-fallback reload behind token survivability and sharpen copy diagnostics

Review round on ca4cdfb7 found that the unconditional reload could strand
the shell unauthenticated when the daemon token never reached storage,
and that the diagnostics had four misreport shapes. Retry now reloads
only when a reload can still authenticate (URL or per-tab persisted
token) and falls back to an in-place reset otherwise, with the button
label matching the action via a new retryMode prop on RootErrorFallback.
The copy registry tracks rendered-vs-provided per module copy, prefers
the duplicate-copies branch when foreign copies exist, carries the
module URL in each copy id, and is capped so dev hot re-evaluation
cannot grow it unbounded.

Mutation checks performed: removing the reload-survivability guard,
the label plumbing, the harness try/catch, the URL-bearing id, the
helper-local root, the foreign-copy precedence, or the registry cap
each turns the corresponding new test red; reverting restores green.

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

* fix(web-shell): address review round 3 — tokenless reload, shared token parse, bounded-map util, wording honesty

- main.tsx: the survivability gate now also passes when no token was
  resolved at boot (tokenless trusted loopback strands nothing), and the
  reload carries the live theme/language across, since session switches
  strip those one-shot URL params.
- config/daemon.ts: the URL token grammar is parsed once in
  readTokenFromLocation(), shared by getDaemonToken() and the
  survivability predicate so the spellings cannot drift.
- DaemonWorkspaceProvider.tsx: copy ids fall back to a module name when
  import.meta.url is lowered away (esbuild iife export documents); the
  registry insert goes through a new utils/bounded-map helper (the
  package's fourth copy of that loop, and the first with the correct
  exit test); both guard branches admit unmount/lost-client explicitly.
- export-html build.mjs: silences the deliberate empty-import-meta
  warning and fails the build if it resurfaces.
- Tests: boot-order replay pinning that the predicate never consults
  getDaemonToken()'s cache, a same-element re-render pinning 'provided'
  as terminal, a live-copy-eviction test that seeds foreign copies after
  the live one, the tokenless-reload and theme/language-carry cases in
  main.test.tsx, the zh-CN reload label, and bounded-map unit tests.
  Each new guard was exercised as a mutation first (removed or reverted)
  and confirmed to turn its test red.

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

* fix(web-shell): address review round 4 — one-shot param strip, live-URL reload, a firing import.meta guard, finish the bounded-map migration

- main.tsx: boot strips the one-shot theme/language/lang params once the
  initializers have read them, so the retry reload's URL carry cannot
  become a permanent override of stored preferences; the reload URL is
  built from the live location.href.
- export-html build.mjs: the import.meta guard now keeps the warnings
  (logLevel: 'error') and fails only on reads outside the tolerated
  prebuilt transcript entry — the previous logOverride: 'silent'
  discarded them and made the check unreachable.
- bounded-map: App.tsx's same-name private copy and the
  useSessionArtifacts tail migrate to the shared helper, deleting the
  drifted duplicates (their 'if (!oldest) break' exit would stop
  evicting on an empty-string key).
- tests: the zh-CN x default-reset copy cell; the reload-carry test now
  stubs location after the last navigation and pins the live session
  path and workspace in the carried URL; scripts/tests gains an
  end-to-end case that drives the export build with an injected
  import.meta read and asserts the guard's failure.
  Mutation checks: removing the boot strip, reverting the guard to
  logOverride: silent, mutating the zh-CN retry copy, or building the
  reload URL from origin each turns the corresponding new case red.

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

* fix(web-templates): extract the import.meta guard into a tested predicate and stop silencing the build

Round 5 found three problems with the round-4 guard wiring: the e2e test
drove the real build with an injected probe and left src/export-html/dist
wiped on every green run (the build rm -rf's it up front and only
repopulates on success), its existsSync bail-out reported a vacuous pass
instead of a skip on trees without the prebuilt transcript, the
file-path allowlist could not see a fourth import.meta read arriving
through that same prebuilt bundle, and logLevel: 'error' suppressed
every other warning class the build emits.

The guard is now a pure predicate in import-meta-guard.mjs — file
allowlist plus a count ratchet (the deliberate guarded ternary accounts
for exactly three empty-import-meta warnings; a fourth fails) — called
from build.mjs, unit-tested with fabricated warnings. The e2e half keeps
driving the real build but snapshots and restores the dist directory,
restores the probed file in finally, and uses describe.skipIf so a
missing prebuilt transcript reports skipped, not passed. logLevel is
gone: warnings print at the default level again while
documentBuildResult.warnings stays populated for the guard.

Mutation checks: removing the ratchet reds the fourth-read unit case and
the transcript-probe e2e; disconnecting the guard call reds both e2e
cases; reverting to logOverride: 'silent' reds the document-entry e2e.

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-10 06:33:33 +00:00
Shaojin Wen
ad92d2bf8f
feat(autofix): run one bounded self-review before an address-review commit (A/B) (#11516)
* feat(autofix): run one bounded self-review before an address-review commit (A/B)

Measured on the takeover fleet (40 PRs, 79 acted rounds, 1369 inline
findings blamed at their review heads): after a round pushes, 73% of the
next review's new Criticals and 93% of its Suggestions sit on that
round's own delta, while Critical density is the same on bot and human
deltas (~2 per review, no decay across rounds). So a fresh adversarial
pass over the delta before the push has the right scope, and ONE bounded
pass, never a loop, is the right shape.

Armed by the QWEN_AUTOFIX_SELF_REVIEW repo variable (off|ab|on; ab splits
on PR parity), resolved once in prepare and never on a github-hosted
runner. An armed round gets a 180m agent budget under a per-arm step cap
(190/130), skips the same-run repair chain so the 345-minute job bound
still holds, and skips the pass below 150 changed lines or 75 minutes
left. The skill runs the review inside the session's own sandbox and
records self-review.json after its commit; the gate validates the record,
binds it to the pushed commit's tree id and publishes one token string
that the round report renders as an autofix-self-review marker. Advisory
only: nothing rejects.

Design record: qwen-autofix.md#af-156.

* ci(autofix): keep the workflow under its size allowance

The self-review arm added 4,978 bytes to qwen-autofix.yml, past the
4,096-byte ratchet, with 3,113 of them comment prose that af-156 already
carries. Compress those comments to pointers; the mechanism is unchanged.
2026-09-10 05:26:56 +00:00
Shaojin Wen
ecae037176
fix(review): reply carried findings into their thread, resolve fixed ones (#9940)
* fix(review): reply carried findings into their thread, resolve fixed ones (#9906)

A carried finding re-posted under its original id used to open a NEW review
thread every round — the Create Review API opens a thread per comment, only
the replies endpoint joins an existing one — and a finding ruled `fixed`
retired from the ledger while its thread stayed open forever.

submit now reads the PR's review threads once before posting and matches them
by the carried id that already leads every claim line:

- a still-standing carried finding is posted as a reply in its original thread
  instead of a new inline comment; a fresh inline comment stays the fallback
  for resolved/gone/foreign originals and for (fix-induced) re-reports
- each `fixed` ruling carried in the new state.fixedFindings channel replies
  `R<id> fixed by <what>` into every live own thread under the id and resolves it
- a payload that re-reports an id it also rules fixed is refused (inline and
  bodyCriticals channels), and duplicate rulings dedupe by id

The skill docs' "GitHub stacks same-line comments in the original thread"
premise is corrected to match. Ledger marker format and counts unchanged.

* fix(review): stamp ledger ids onto fresh findings so their threads stay reachable (#9906)

The thread lifecycle matched threads by the ledger id leading the root
comment, but fresh findings posted id-less roots — ids were minted only
into the ledger marker at compose time — so a finding ruled fixed the
round after it was reported could never resolve its own thread, and a
`still stands` carry opened a second one. Stamp each freshly drafted
comment with the id the marker records for it (the claim-line shape
Step 6 writes on carries), so a thread is reachable from the round it
is born; id-less roots posted before the stamp degrade to the pre-fix
behaviour, and the module header says so.

Also close the review round's findings: the attribution-off readback
leg now strips leading render-nothing residue exactly like presubmit's
(the two ends disagreed on one posted shape); reply routing prefers a
`(fix-induced)` root over an older unmarked original under the same id;
the fixed-vs-re-post contradiction gate scans every id-carrying channel
(cannot-tell Criticals, duplicate drops, Critical deferrals, and
floor-rerouted comments before their removal); the failed fixed-ruling
warning states the true retry guarantee; SKILL.md discloses the 240-char
`by` cap and instructs naming a moved location in the claim text.

* fix(review): normalize ledger-id stamping so stamped ids survive their own readback (#9940)

* fix(review): scan every rendered channel for fixed-vs-re-post contradictions (#9940)

* fix(review): close the contradiction gate's remaining channels and pin its guards (#9940)

* fix(review): close the constructed-channel escapes and pin the lifecycle read (#9940)

Address round-5 review findings on the thread lifecycle:

- fetchReviewThreads dedupes by thread id — a stale/echoed cursor
  re-fetches the same page to the cap, and the resolve leg is not
  idempotent, so duplicates multiplied a ruling's reply and resolve
- the contradiction gate now scans the channels compose CONSTRUCTS:
  the floor-rerouted deferral entries' path/body and the Budget-gap
  disclosure lines — both render verbatim and ride no payload state
  field
- the gate's by-scan leg is dropped: every id it could catch is a
  member of fixedFindings, singly ruled fixed in the same payload, so
  it could only refuse self-consistent payloads (its own
  post-dedup-index citation and the FIXED_BY_MAX token fabrication go
  with it)
- among several marked threads the NEWEST leads — the standing claim
  under an id is the latest re-report's
- a draft whose body opens a code fence takes no stamp (text before
  the backticks would break the fence the gate validated); the skip
  is disclosed
- the thread-lifecycle planning line is phrased as a plan ("to
  resolve"), not an accomplished fact

* fix(review): police the fixed-vs-re-post gate on ledger state, not prose (#9940)

Over six review rounds the contradiction gate in `submit` grew a
whole-token id scan (`LEDGER_ID_SCAN`) over every free-text channel the
body renders — cannot-tell lines, duplicate-drop notes, deferral titles
and paths, downgrade reasons, rerouted comment bodies, transcript-derived
budget-gap lines, even `modelId` — and round 6 (R6-1) found it refusing
whole payloads over text the body never renders and over lines no
re-compose can redraft. Every one of those channels is prose: a retired id
mentioned there is a cross-reference, not a re-report — the ledger never
carries it, the next round rules on nothing under it, and the thread it
names is legitimately closed.

The gate now refuses exactly what would make the ledger marker and the
resolved thread state disagree: a drafted comment whose claim line carries
a fixed id and a body Critical leading with one — the two channels the
ledger builder carries, read through its own readbacks (`carriedFindingOf`
and the new shared `bodyCriticalClaim`) — plus a ruling naming an id this
same pass mints. It reads both channels from the payload as the model
authored it, captured before the Aone anchor degrade and the floor
removal, so a degraded or rerouted re-post still counts and every refusal
cites an authored index by construction (R6-3: the reduced-position and
fabricated `state.bodyCriticals[k]` citations are gone). `LEDGER_ID_SCAN`,
`floorEnforcedEntries` and `budgetGapDisclosures` are deleted; the
save-artifact `Omit` names the live-only stamp inputs instead.

Also: the open-Criticals re-check now records a `fixed by this diff`
ruling in `fixedFindings` whenever the thread root leads with a ledger id
— a blocker that left the ledger through `cannot tell` or `superseded`
still has its thread, and the posting pass is its only closure (R6-2); the
VOLUME line and `postedInline`'s contract say the count covers comments
whether they open a thread or reply into one (R1-4); the fixed-ruling
reply's `by` takes the strips every posted body takes; the carried-reply stderr
line is phrased as a plan; the `by` cap cuts by code point; posting.md
states the reply disposition as GitHub-only and re-describes the same-SHA
re-run under the reply mechanism.

Tests: the prose-channel refusal cells become one `it.each` negative
control (ten channels post and resolve), the budget-gap cell is flipped
into R6-1's witness, two Aone cells pin authored-index citations past the
anchor degrade, and a floor-reroute cell pins the authored index on the
GitHub path.

* fix(review): read the contradiction gate through the ledger's own projections (#9940)

R7-1: the fixed-vs-re-post gate read the payload through weaker
projections than buildLedger consumes, so a payload re-reporting an id
while ruling it fixed could post through two demonstrated entrances.

Stacked severity markers (Aone route): the gate's comment leg stripped
ONE marker while the relocate leg iterates to a fixpoint, so a
stacked-marker carry re-posting a fixed id passed the gate while the
relocated entry carried the id standing. The shared readback strip
(markerStrippedBody) now iterates the whole marker run, aligning the
gate, the thread matcher, the stamp, the ledger builder and the
relocate leg — which drops its now-redundant second strip. This also
closes the sibling entrance where the stamp re-minted a stacked carry
into a double-id line.

Split forged footer span (GitHub route): the bodyCriticals leg read
raw authored entries while buildLedger reads the collapsed entries
compose's ingestion produces — a forged footer span split across a
hard break hid its id from the gate while the builder rejoined it and
carried it standing. The leg now reads through tryIngestBodyCriticals,
the same acceptance table buildLedger consumes (index-preserving, so
refusals still cite the authored index).

Four new cells refuse both entrances; a flip cell pins that the
hard-break entry alone posts with the ledger carrying its id.
Mutation probes confirm each guard is witnessed.

* fix(review): shape contradiction refusals as exit-3; read threads through forced colour (#9940)

* fix(review): read the stamp's fence guard through residue; share the marker separator with the readback (#9940)

* fix(review): stamp drafts whose fence opens past the marker's line (#9940)

The round-8 fence guard read through leading residue before testing, and
the residue class swallows newlines — so the skip also swallowed drafts
whose fence opens on line 2 or later, shapes the stamp provably cannot
flip. A bare-marker draft with a balanced fence on line 2 passed every
gate, reached stampCarriedId, and went out un-stamped: its thread root
carried no id, the marked readback leg read the fence opener, and every
later carried re-post matched nothing, posted inline, and opened a new
thread — the multiplication this PR exists to kill.

Skip the stamp only when the fence shares the marker's projected first
line: refuse only if no bare newline was consumed outside HTML comments
(newlines inside comments stay render-invisible, so a comment-led
same-line fence keeps the skip).

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

* fix(review): gate deferred-Critical re-posts and share the separator readback (#9940)

The fixed-vs-re-post gate never scanned the deferral channel, but a
deferred Critical does not stay deferred: the split relocates it into
the body Criticals, and the relocation prefix strips the carried id
from position 0, so the ledger carries the claim renumbered. One pass
could reply `fixed` into a thread while the same review re-asserted
the claim as an open blocker under a minted id. The gate now reads
Critical deferral titles the way the closure mint already reads them,
refusing the contradiction at its authored index; Suggestion deferrals
keep the prose cross-reference carve-out.

The readback's separator step also drifted from the post-time strip:
a multi-line comment between the separator colon and the carried id
truncated the claim line to `<!--` while the attribution-off post
exposed the id, so the draft re-minted a fresh id and the original
thread was orphaned. markerStrippedBody now shares MARKER_SEPARATOR_RE
with stripSeverityPrefix, so both marker-strip fixpoints agree on the
stacked-marker shape and the post-colon residue shape alike.

The cross-reference cell that pinned the Critical-deferral post-through
becomes a refusal cell with a posting flip half.

* Merge remote-tracking branch 'origin/main' into fix/review-thread-lifecycle

* fix(review): CR/HTML-block stamp skips and full-width-colon id readback (#9940)

* fix(review): unify carried-id readback, quote-aware stamp skip (#9940)

* fix(review): address rounds 14-17 review findings on the thread lifecycle

Six code fixes, each with its pinning test, plus the test hardening the
round-14 probe findings asked for:

- inline-counts: make the residue token's comment alternative admit
  exactly one parse, so MARKER_SEPARATOR_RE stays linear — the lazy
  form gave a residue run 2^N decompositions and stripSeverityPrefix
  hung on residue-led colon-less drafts (~30 comments = minutes) on
  every GitHub submit and attribution-off post (R14-1)
- submit/compose-review: surface the floor reroute's deferral entries
  from compose and scan their Critical records in the contradiction
  gate — a rerouted blocker whose carried id sat below the claim line
  escaped the fixed-vs-re-post refusal and one pass both resolved the
  thread and carried the claim forward (R14-2)
- submit/compose-review: read deferral titles through the head-slot
  tokeniser's own id read (readClaimHead(...).id) in the gate AND the
  closure mint — the anchored read over .stripped kept a source tag
  ([probe]) at position 0 and read no id there (R15-1)
- compose-review: bound fixedFindings ids by LEDGER_MAX_ID and the
  round range like isLedgerFinding, and refuse a `by` that renders as
  nothing — an unbounded id rode disclosures verbatim, an invisible
  `by` posted `R<id> fixed by` with no account of what fixed it
  (R14-3, R14-4)
- presubmit: gate the footer recognition disjunct on top-level
  comments at both sites — the lifecycle's own fixed-ruling reply
  carries the footer and inherits its thread's commit_id, so ungated
  it bucketed into overlap and the drop rule discarded a genuinely new
  finding at the location (R16-1)
- thread-lifecycle: strip same-line render-nothing residue after the
  blockquote unquote in stampCarriedId's skip — a quoted opener led by
  an HTML comment (`> <!-- x -->` + fence) was stamped and the
  insertion flipped the quoted structure (R17-2)
- submit.test: reset the spies and exitCode per expectRefusal CALL so
  the loop-converted gate tests keep isolating their vectors (R17-1),
  and pin the round-14 probe holes: the marked-tier age-independence,
  the interleaved real-gh CLICOLOR shape, the rootCreatedAt fetch
  mapping, the floor-alignment posted marker, the attribution-on reply
  footer, the stampedFresh no-thread-read short-circuit, and the
  diverted-finding-rides-the-marker property (R14-5..R14-11)

* fix(review): close the second residue-token ambiguity in the marker separator

`\s` and `\p{Cf}` both match U+FEFF — the only codepoint in both, verified
by enumerating the whole Unicode range — so as two alternatives of
INVISIBLE_RESIDUE a FEFF run had 2^N decompositions and the failed
`[::]` made the engine explore them all. Round 14 closed this shape for
the comment token; the character half stayed open, and the bounded-time
cell it added exercises comment runs only, so the suite stayed green:
N=24 measured 318ms and doubles per character.

The two character alternatives become ONE class, so a character matches
the token exactly one way. The accepted codepoint set is unchanged (a
probe compares the separator, the leading strip and the global sweep
across the residue shapes the tests pin, byte-identical), and FEFF stays
residue in all three consumers.

The new cell feeds a 64-character FEFF run to stripSeverityPrefix under
the same bound as its round-14 sibling, and re-checks that leading FEFF
still classifies, still separates a carried id, and keeps the two
marker-strip fixpoints agreeing. Mutation-checked: splitting the class
back into alternatives takes the cell to 150s (red). (#9940 review R18-1)

* fix(review): align the carried-id readers and close the by-gate's exit gaps

Round 20/21 findings on the thread lifecycle, each with its pinning cell
(all six mutation-checked: reverting the fix reddens its cell):

- presubmit: extractCarriedIds reads the marked leg through
  ledgerClaimLine (forged footer spans, comment-marker lines and leading
  residue stripped) and the id through readClaimHead(...).id — the same
  projection buildLedger, the contradiction gate and the thread matcher
  apply. A span-led or source-tag-led re-post ([probe] R1-2: …) read
  id-less here while the ledger kept carrying it, so the standing
  re-post was dedup-dropped every round (R21-3 and its sibling)
- submit: the contradiction gate's deferral and floor-reroute legs scan
  EVERY entry, not only Criticals — the closure mint reads an id from
  every entry of the same two channels, and a deferred Suggestion is
  still a finding channel the body publishes as standing; a
  Critical-only gate let one pass resolve R2-3 as fixed while deferring
  it (R21-2). The cross-reference table's deferral-title row moves to a
  refusal cell; an id-less deferral title still posts
- compose-review: the `by` gate projects through rendersAsNothingAtExit
  (submit strips the reply through the attribution-off chain, so a bare
  marker rendered as prose here and as nothing there), and the gate
  runs again on the capped value — a visible tail past FIXED_BY_MAX
  sliced to invisible-only; that cut is the tool's, so it degrades to a
  by-less ruling instead of refusing
- compose-review.test: pin the closure mint's source-tag-led re-post
  read on the deferral channel (R20-1)

* fix(review): drop the inline-Suggestions clause the diversion empties

The opener's "Suggestions are inline." is keyed to the count compose
takes from the PRE-diversion posting set, and the thread lifecycle's
diversion runs after the compose and after the consistency gate — so a
round whose only inline findings are carried re-posts into live own
threads (the steady state of a long-lived review) posted the clause over
an empty comments array while the JSON honestly reported
inlineComments: 0.

submit reconciles the clause after the diversion: when the pre-diversion
set had inline Suggestions and the post-diversion set has none, the
clause is stripped from the body in both languages, with the space that
joined it to its neighbour. The event is untouched — it is keyed to the
confirmed count, carried findings included, and a diverted finding still
stands. The clause text is named once (INLINE_SUGGESTIONS_CLAUSE) so the
writing end and the stripping end cannot drift. Pinned by a lifecycle
cell (one carried Suggestion, comments [] + one reply, clause absent,
event COMMENT) and a control on the fresh-beside-carried cell; removing
the reconciliation reddens the new cell. (#9940 review R23-1)

* fix(review): count the marked set for the clause guard, let carry-replies carry the re-post exemption

Round-24 findings, both mutation-checked (reverting each fix reddens its
cell):

- submit: the round-23 reconciliation counted `finalComments` and
  `reviewComments`, whose visible severity markers the attribution-off
  rewrite had already stripped, so under attribution off both counts were
  zero and the guard never fired — the clause posted over an empty
  comments array on exactly those runs. The guard now counts the MARKED
  post-floor posting set (`payload.comments`, a 1:1 source of
  `finalComments`, so the diverted indices address both); attribution on
  is unchanged. Pinned by an attribution-off twin of the round-23 cell
  and a keep-the-clause control (R23-1, fix-induced)
- presubmit: own-account replies carrying a posting signal reach the
  classifier as re-post EXEMPTION carriers only. The thread lifecycle
  answers a carry inside its root thread, which makes the root
  replied-to (`resolved`, never a target) while the round-16 exclusion
  kept the reply — the only comment still carrying the id — out of the
  set; with another own root overlapping the location the carried
  finding had no carrier and the drop rule discarded its re-post as a
  duplicate every round. A reply adds a `repost` entry keyed on the
  wanted ids it carries and nothing else: not the overlap bucket, not
  the ambiguity count, not the id-less fallback; same account/SHA/id
  gates as the root match, both platform paths, fixed-ruling replies
  inert. Pinned by the three-comment steady-state cell (both posted
  shapes) and a negative cell (foreign account, stale SHA, fixed reply,
  unwanted id all carry nothing); the round-16 pin stays green (R24-1)
- posting.md: the report-shape note no longer claims every repost entry
  is also an overlap, and the overlap bullet names the carry-reply as
  the carrier

* fix(review): let carry-replies carry across commits, refuse non-canonical ruling ids, share the fence-opener rule

Round-25 findings, each mutation-checked (reverting the fix reddens its
cell):

- presubmit: the round-24 reply-carrier leg was gated on the current
  SHA, but a reply inherits its ROOT's commit id — after any new commit
  the carry-reply failed the gate forever while its root bucketed
  `stale`, and a still-standing carried finding was dedup-dropped
  whenever a current-SHA own comment overlapped its location. The SHA
  gate is dropped from that leg only; the account gate and the wanted-id
  filter remain (the wanted-id match is the evidence the current round
  still carries the finding), fixed-ruling replies stay inert. The
  stale-SHA expectation moves out of the negative cell into its own
  positive cell (R24-1, fix-induced)
- compose-review: ingestFixedFindings also requires the canonical
  spelling `R[1-9]\d*-[1-9]\d*` — the shape tolerated leading zeros
  (`R01-2`) that no minted entry or posted root carries, and every
  downstream join is raw-string equality, so such a ruling passed every
  gate, retired nothing, and slipped past the two-way refusal beside a
  standing `R1-2` re-post. Tightened in the ingest gate, not the shared
  token; the mint numbers from 1, so no real id is refused
- review-footer/thread-lifecycle: the fence-opener rule is one exported
  predicate (`fenceOpener`) that both `scanLines` and the stamp skip
  apply — a backtick run whose info string carries a backtick opens no
  fence (CommonMark), so the delimiter-only skip over-stamped nothing
  and posted an id-less root behind a disclosure naming a fence that did
  not exist; tilde fences and plain-info backtick fences keep their skip.
  Under the mutation `scanLines`' own pin reddens with the new cell,
  which is the point of sharing it

* fix(review): skip the stamp on every line-leading construct, keep a fresh claim's (fix-induced) prose out of the marking

Round-26 findings, each arm mutation-checked (reverting it reddens its
cell):

- thread-lifecycle: the stamp's first-line skip guarded only fences and
  type-1/6 HTML blocks, so a blockquote, ATX heading, list item,
  thematic break or type-3/4/5 raw-HTML opener on the marker's projected
  first line was stamped and demoted to paragraph text by the
  attribution-off post — silently, in a structure the gate never
  validated. The skip now covers those too (the blockquote tested BEFORE
  the unquote — the quote itself is the construct; the others through a
  stamp-local OTHER_LEADER_RE, HTML_BLOCK_OPEN_RE left to the line
  model), and one line-2 arm: a block that cannot interrupt a paragraph
  (indented code, an ordered list not starting at 1) directly under the
  marker, with no blank line between, was a block of its own behind the
  empty attribution-off first line and became continuation text of the
  `R<n>-<k>:` paragraph the stamp writes above it. Fences, bullets and
  `1.` on line 2 keep stamping; a blank line in between keeps stamping.
  The stderr disclosure and its comments name the widened set (R26-1)
- thread-lifecycle/inline-counts: a FRESH claim starting with the
  `(fix-induced)` prose token read as prose on the draft (no id for a
  marking to hang on) but as a genuine marking once spliced behind the
  minted id — permanently, so a later still-standing carry paired with
  the mislabelled root ahead of the true original. The stamp now lets
  the readback arbitrate: while carriedFindingOf reads the stamped body
  as marked, the first head-slot occurrence on the claim line is
  removed (tags cannot contain it; residue ahead of it stops the read).
  Genuine carries return verbatim before this. The token is stated once
  (FIX_INDUCED_TOKEN_RE) and the anchored readback is derived from it —
  source and flags identical to the former literal (R26-2)

* fix(review): make the carried-id stamp, both readback legs and the fixed-ruling gates decide once, and close the round-27 join and construct holes

Five reverse-audit rounds over the thread-lifecycle stamp and its readback,
plus the round-27 review findings (R26-3, R27-1). Every fix carries a cell,
and every new arm was mutation-checked (removing it reds a cell).

Readback / stamp (lib/inline-counts.ts, lib/thread-lifecycle.ts):
- Ledger ids are canonical at every join: `readClaimHead` returns
  `canonicalLedgerId`, `planThreadActions` joins carried and fixed ids
  canonically, the contradiction gate compares canonical spellings, and a
  non-canonical `fixed` id is refused naming the canonical one (R26-3).
- One line model for the stamp and both projections: `severityOf` reads no
  marker off an indented code line; `separatorStrip` folds a soft break and
  its continuation indentation (a lazy continuation), keeps an indented code
  block only behind a block boundary (`blockBoundaryIn`: a blank line or an
  HTML-comment line up to three columns in), finds the separator colon with
  comments masked and never on a code line, and leaves a format character,
  NBSP or comment leading the content line where it stands.
- Indentation counts columns (`indentColumns`, tab to the next multiple of
  four) everywhere the strip, the readback and the line model test it.
- The stamp skips the constructs that cannot interrupt the id paragraph or
  would re-shape it (setext underline, empty list item, ordered list not at
  1, link reference definition incl. next-line destination and escaped
  labels, type-7 lone tag with quoted attributes) and re-attaches the break
  run for the ones that open a block (blank line, table header with or
  without a leading `|`, block-level tag, a marker line that is itself an
  HTML block); indentation alone never refuses (R27-1).
- `(fix-induced)` on a fresh claim is arbitrated on both projections (a
  footer span split across a soft break reaches the head slot only on the
  attribution-off exit), and a model-carried id reads `fixInduced` from
  either leg; the strip loop is one pass per token run.
- Linear time: `residueLineBreaks` is a merge walk, `stripSeverityPrefix`
  tests emptiness once at the fixpoint, the last marker is found with
  comments masked, `LONE_TAG_RE` / `TABLE_DELIMITER_ROW_RE` / the HTML
  opener are unambiguous.

Line model (lib/review-footer.ts):
- Type-1 and type-6 HTML block start conditions end at the tag name
  (`<div class="x">foo` opens a block); a type-1 block whose closing tag sits
  on the opener line ends there; `<pre-x>` is a type-7 tag; four columns is
  code before it is an opener or a fence; a single-line footer-span strip
  keeps the line's indentation.

Compose / submit / presubmit:
- `by` gates read both projections, unclosed comments are detected by a
  forward scan (`<!-->`/`<!--->` are empty comments), the cap's retreats run
  only on a cut, and raw tag openers go inert after the gates;
  `fixedRulingLine` is the last belt before the reply write.
- The body without the inline-Suggestions clause is rendered ONCE beside the
  body (a same-length placeholder run replaces the clause, so every budget
  decision is shared) and `submit` posts it when the thread diversion drains
  every inline Suggestion — no text search over model prose.
- Downgrade reasons are quoted like every other model line, capped per entry
  (400) and as a list (2000 code points, disclosed in `remediation`).
- `bodyCriticalClaim` reads no id off an indented code line; presubmit's
  `matchedIds` reports every spelling the findings file wrote.

Docs: posting.md notes the downgrade-reason cap.

* test(review): loosen the linear-time stamp cell for loaded CI runners

The 150 ms bound tripped at 194-534 ms on the self-hosted runner under
load 50 while the quadratic forms it guards against take seconds; the
cell now takes the best of two runs against a 2 s bound.

* fix(review): let the parser arbitrate the id stamp, and close the round-28 readback, entry-channel, reply-carrier and census holes

Round 28 of the review (R27-1 still standing, R28-1..R28-8), each fix
pinned by a cell and mutation-checked.

Stamp (lib/thread-lifecycle.ts, lib/review-footer.ts): the hand-listed
CommonMark constructs are gone. `stampCarriedId` builds its candidates —
the canonical one-line shape, then the shape keeping the separator's
line-break run — and accepts the first whose top-level block skeleton
(markdown-it, block-only, render-nothing HTML blocks dropped) is the same
before and after the insertion on BOTH projections; otherwise the
documented id-less degradation. Closes R27-1 (A) a `>` inside a quoted
attribute and (B) the incomplete-tag over-skip, and every construct class
by construction.

Readback (lib/inline-counts.ts): the block-boundary state resets on a
paragraph line; the separator colon is content on or after a code block
the residue opened, with the marker line's HTML-block-ness reaching that
search; `markerStrippedBody` keeps residue between iterations so the post
and readback fixpoints agree (R28-1, R28-6). `stripFooterSpans` routes a
bare-CR body to the line-aware path (R28-7).

Entry channel (compose-review.ts): `bodyCriticalClaim` and the ledger's
body leg read through one head — the one-line channel has no indented
code — `collapseEntry` trims one-line entries, and a code-led comment the
floor reroutes records a `(quoted code)` title no reader takes an id from
(R28-2). `escapeTagOpeners` pairs backtick runs the CommonMark way, so an
unpaired run is literal and the tags inside it go inert (R28-5). The stop
re-rule's grant keys are canonical ledger ids (R28-8).

Presubmit: the own-reply re-post carrier joins by id at any location —
GitHub reports `line: null` on an unmapped anchor (R28-3).

Fixed-ruling reply: carries `<!-- qwen-review-fixed-ruling -->`, never the
posted comment-marker shape; .github/workflows/qwen-autofix.yml skips
review-bot comments carrying it in the inline census and the three digest
legs, with a scripts/tests case executing the extracted jq (R28-4).

* fix(review): seed the post-colon strip from the colon's line, join the repost exemption on the id, define the fixed-ruling filter once

- separatorStrip seeds the post-colon walk from the colon's own line: the
  marker line's HTML-block state applies only while the colon sits on it;
  a colon on a later line is a paragraph line (or a comment-led HTML block
  that ends on that line), so an indented claim under it is a lazy
  continuation the carry must read, not code (round 29, R29-2). The walk
  itself (scanResidue, behind codeBlockStartIn and markerLineOpensHtmlBlock)
  now runs over PHYSICAL lines with HTML-block state — a comment-led line
  opens a block that runs to the first line containing `-->`, and a
  comment that opens later on a line hides no break — where the comment-
  span view misread `<!-- a --><!-- b\n-->:` and `<!-- a -->:<!-- b\nc -->`
  colon lines as one HTML-block line and kept the claim under them as code.
  The walk also tracks the code block it opens: codeIndentedAfter (the
  marker-line guard behind severityOf) now asks whether the marker's OWN
  line is code — inside a block that runs on to it, not after a code line
  it ends nor as a lazy continuation under a format-character line — and
  bareClaimLine reads no claim once any line of its lead is code, so the
  bare and marked readback legs agree; blank lines are spaces and tabs
  only, an indented NBSP line is code; and "same line" in the no-colon
  strip is physical — a comment glued to the marker that spans a break
  goes with the run instead of leading the attribution-off post as an HTML
  block (fuzz audit vs markdown-it, 940k bodies).
- posting.md: the overlap drop exempts a finding whose id appears in
  matchedIds of ANY repost entry — a root leg matches at the finding's
  (path, line), a carry-reply leg on the id alone at the reply's own
  location, which may be unmapped or a line the finding moved off; pinned
  in SKILL.test.ts and presubmit.test.ts (R29-1, R29-3).
- qwen-autofix.yml: FIXED_RULING_FILTER is one workflow-level env value,
  spelled with a single backslash and handed to all four jq sites as
  --arg frf; the scan step's shell copy spelled [^\\n], which as an --arg
  value compiled to "neither backslash nor n" and let a ruling note whose
  `by` clause contains an `n` count as feedback (R28-4 residue).

* fix(review): close four Criticals the Critical-only pass found, and harden the channels around them

A full Critical-only pass over the PR's new surface (nobody had completed
one — four consecutive review runs timed out) found four Criticals, each
reproduced before it was fixed and pinned by a cell that fails without the
fix. Two reverse-audit rounds then broke the first cut of three of these
fixes; what lands here is the re-worked version.

- submit: the contradiction gate read the drafted body while the thread
  diversion read the attribution-off post, so a payload that re-posted an
  id it also ruled fixed slipped the refusal and then replied "still
  stands" AND "fixed by" into one thread and resolved it. The gate now
  reads the union of both projections — a superset of what the diversion
  acts on, which is the safe direction; the diversion reads the drafted
  body, the projection the ledger builder and stampCarriedId also read.
- compose-review: downgrade reasons were escaped per reason but render as
  one paragraph, and backtick runs pair across the `; ` join, so one
  reason could free the next one's `<details>` and fold every later
  paragraph away. Escaped once over the join, and the 2000-point total is
  charged against the string that actually posts. A reason too big for
  the budget no longer evicts the shorter ones after it.
- compose-review: model-written blockers reached the body with raw HTML
  openers intact — one ordinary sentence naming a tag without backticks
  hid every later blocker, the disclosures and the footer. Escaped, along
  with the cannot-tell list, the duplicate-drop list and the "Not
  reviewed:" disclosure paragraphs: a list item bounds `<details>` at its
  `</li>` but not a RAWTEXT element (`<textarea>`, `<style>`, `<script>`),
  which swallowed the footer from all three.
- pr-context: this PR moved a still-standing carry from a new root to a
  reply, but "Blockers to re-check" tested root bodies only, so a Critical
  carried into a Suggestion-rooted thread left the mandatory section for a
  240-char snippet under "do NOT re-report". Promotion now reads the whole
  thread (from any author — widening it can only add a thread), the
  standing claim is this account's own newest re-post, and the section
  renders the root in full ALWAYS with the re-post beside it, bounded so
  one long thread cannot starve later blockers. A `fixed` ruling note is
  neither: its `by` clause routinely carries blocker prose.

Hardening the same audits asked for: planThreadActions refuses to match
on an empty login, rules each thread once, never resolves a thread it is
replying a carry into, and discloses a ruling that resolved nothing;
fetchReviewThreads names itself when the read comes back empty; the
fail-closed identity guard reads replies too, since the lifecycle carries
Criticals there (renamed accordingly); a markdown link opening a claim is
no longer read as an axis tag; and SKILL.md/posting.md drop three
statements the code does not implement.

* fix(review): close the three Criticals of review round 30, and the holes the reverse audit found in the first cut

- escapeTagOpeners paired backtick runs across blank lines, inventing a
  code span CommonMark never forms and leaving a tag opener inside it
  live. Reachable through the `Not reviewed:` disclosures, the one escaped
  channel whose entries are verbatim model prose that nothing folds to a
  single line. Pairing is now block-scoped — and a FENCED block is a block,
  not a span: its blank lines do not end it, its `<` is already inert, and
  the first cut escaped there, rendering a literal `&lt;` to the reader for
  no safety gain. Fences are masked in one line-wise pass; a lone CR ends
  a line too; both lookups are cursor-based, so the scan stays linear
  (measured 14-22 ms on 100k-character adversarial inputs).
- isRuling matched FIXED_RULING_MARKER as a substring, so a genuine
  Critical that merely QUOTES the marker — a review of the file that
  defines it — was demoted out of the mandatory "Blockers to re-check"
  section, and the two halves of this diff disagreed on one input (the
  workflow census already matched an anchored shape). Both now use one
  shape, exported as FIXED_RULING_SHAPE_RE and anchored at BOTH ends of
  the note's line: `^` alone is only worth anything while the severity
  marker leads the body, and under `attribution: false` the post strips
  it, so a Critical whose claim line opened by quoting a ruling still
  demoted itself. A real note ends at the marker; a quotation carries on
  past it. The census filter gets the same end anchor, and a test pins the
  two spellings against one corpus.
- stripSeverityPrefix's `!kept` exemption was over-broad: a marker, a
  blank line and an indented marker kept the second one, so submit's
  renders-as-nothing refusal was disarmed and the post carried a comment
  whose only content is a bare machine marker rendered as code. The
  original test still runs for an unkept remainder; a kept one is now
  tested against a narrower projection — markers, the separator grammar
  they trail (either colon) and whitespace, never comments — so a quoted
  `    <!-- qwen-review -->` line still reads as the code audit 6 kept.

Every fix is pinned by the acceptance criterion its finding named, plus
the arms the reverse audit reported unpinned: the fence mask and its
interior runs, the blank-line rule, the CR line ending, both anchors of
the ruling shape and its `by` clause, the separator and format-character
halves of the marker projection, and MARKERS_RE's global flag.

* fix(review): escape tag openers against the renderer GitHub runs, and anchor a ruling note to the whole comment

The escape that keeps a model-written entry from posting a live HTML element
was derived from the CommonMark prose. GitHub does not render with the prose:
it renders with cmark-gfm and then hands the raw passthrough to an HTML
parser, and all three disagree about which constructs FORM, how far each one
HIDES, and which backtick runs PAIR. Every rule here is now cmark-gfm's own,
measured against it. A/B over 40000 fuzz lines, each escaped and embedded in a
realistic body, rendered by cmark-gfm and parsed by parse5:

                                         before   after
    leaked a raw element                    847       0
    live <details>/<summary>/<select>        31       0
    second blocker or footer folded away     29       0

0 across 160000 lines on four independent seeds.

`<details>` is the canary because GFM's tagfilter — which GitHub runs — turns
`<script>`, `<style>`, `<textarea>`, `<iframe>` and their siblings into visible
literal text. They cannot swallow anything; `<details>`, `<summary>` and
`<select>` can, and did.

- escapeTagOpeners derived CommonMark BLOCK structure by hand so it could
  decide which backtick runs may pair. Every round patched one more block
  start into that model and the next round found another: blank lines, then
  fences, then indented code, then list items, ATX headings, a fence
  interrupting a paragraph. The model is deleted. Every channel that reaches
  the escape folds to a single line first — `ingestEntryList` for the entry
  channels, the `\s+` normalisation for downgrade reasons, `collapseEntry` for
  the `Not reviewed:` disclosures (it folded only `\n`; it now folds `\r` and
  `\r\n` too), `scriptLintGate`'s own push, and compose's refusal of a line
  break in a ruling note's `by` — so there is no block structure to model and
  none is modelled. `escapeScopes`, `BLOCK_PARSER_NO_HTML` and
  `HTML_BLOCK_START_RE` are gone.
- Per line the escape is ONE left-to-right pass, which IS the leftmost-first
  rule: code spans, autolinks, raw HTML, GFM autolink literals and link
  destinations all compete in it, and the construct that starts earlier
  consumes the others. Each rule is cmark-gfm's, not an approximation, because
  BOTH directions of a wrong answer leak: believing in a construct the
  renderer does not form steals a backtick from the delimiter pool and shifts
  every later pairing.
    * A CDATA section is `"<![CDATA[" ([^\]] | "]" [^\]] | "]]" [^>])* "]]>"`,
      not "is there a `]]>` later" — an array index inside the fixture puts a
      third `]` in front of the terminator and the section does not form. What
      it HIDES ends at the first `>`, because the raw span reaches an HTML
      parser that closes a bogus comment there; the rest of the span is RAW
      TEXT, where no backtick is a delimiter.
    * A comment needs its `-->` and refuses a text that ends with `-`, and the
      HTML parser closes it at the first `-->` or `--!>` substring, with none
      of cmark's arithmetic. Only a failed COMMENT poisons the `<!` family;
      poisoning on a failed declaration refused a comment cmark still formed.
    * A declaration is `<!` plus UPPERCASE letters plus WHITESPACE, so
      `<!ENTITY<details>` and `<!entity <details>` form nothing and the tag
      behind them is live. Autolinks are tried first, as cmark does:
      `<!--@t>` is a mailto autolink and no comment forms.
    * A GFM autolink literal covers `ftp://` and every case of the three
      schemes, is refused without cmark's domain (first character
      alphanumeric, no `_` in either of the last two labels) and behind an
      unmatched `[`, and its boundary is read against the line's CONTENT —
      `>www.z.test/p` links because the block phase strips the block-quote
      marker first. It runs past a backtick and past a backslash, so the code
      span this pass believed in never formed.
    * Code spans are cmark's scanner, not the spec's: it carries a memo a
      successful scan overwrites, so the spans it forms are a strict SUBSET of
      the spec's, and it refuses to open one on a run longer than 80.
- A `<!` at the start of a line's CONTENT opens an HTML BLOCK (types 2, 4 and
  5), which is passed through raw — the inline phase never runs inside one, so
  the code span and the backslash escape this pass reads to decide an opener
  is inert do not exist there. That `<` is escaped and the rest of the line
  rescanned. Reachable from a body Critical under `review.attribution: false`,
  which is a top-level line, and from the two list channels.
- The scan is linear: every terminator is located once per line into a sorted
  array read through a monotone cursor. 800000 backtick runs went from 7.6 s
  to 0.18 s; the unterminated-opener shapes went from four minutes to 23 ms.
- rendersAsNothing listed the entity references that render nothing, and the
  list named ten of the thirty-three HTML5 names and twelve of the numeric
  spellings that do — so `**[Critical]** &Tab;` passed the gate and posted a
  blocker with no visible content, counting toward the verdict and re-promoting
  as unanswerable. The list is replaced by a decision: decode the reference and
  ask whether what it decodes to is invisible. The old arm also matched
  case-INSENSITIVELY, and named references are case-sensitive — `&NBSP;` is
  five characters a reader sees.
- The same projection's `<!` arms ran to `]]>` and to the next `-->`, took the
  four openers as four independent global passes in the wrong order, read a
  declaration as `<!` plus any letter, and deleted `<script>` and `<style>`
  spans that GitHub renders as visible text. Every one of those threw the
  whole compose away over a body that renders perfectly well — a security
  Critical that pastes its payload is exactly the shape that hit it. The
  family is now ONE left-to-right scan, which is also what makes it linear:
  an alternation with no end-of-input arm retries at every later `<`, and
  720 kB of unterminated `<![CDATA[` took nineteen seconds in a projection
  that runs twice per entry.
- floorEnforcedReroute decided its `(quoted code)` guard on the first PHYSICAL
  line while `collapseToLine` builds the title from the first non-trim-empty
  one. A body led by an NBSP, a BOM, an ideographic space or a form feed got no
  prefix, and the NEXT line's ledger id led the title — an id the claim-line
  read cannot see, which the fixed-vs-re-post contradiction gate then refuses
  the whole post over.
- Both ruling-shape matchers decided "is this a ruling note" from a first LINE.
  A comment that quotes a whole ruling and then states its own finding
  underneath — the natural way to say "this was ruled fixed last round and is
  not" — was classified as a note and dropped from the census wholesale, the
  live Critical with it, at all four jq sites and in pr-context's blocker
  section. Both now anchor over the WHOLE body and admit only the canonical
  attribution footer after the marker. The two engines are kept in agreement by
  construction: no `\s` (Oniguruma matches U+0085 and JS does not; JS matches
  U+FEFF and Oniguruma does not), no `.` where the dialects disagree on line
  breaks, and no two whitespace runs that can split a run of spaces two ways —
  that last one is quadratic, and real jq aborts the census with
  `retry-limit-in-match` rather than returning false.
- The writer and the readers of a ruling note now agree on one set of line
  breaks. `isFooterSafeModelId` admitted U+2028 and U+2029, and `reviewFooter`
  took its fallback version unchecked, so a modelId or a `CLI_VERSION` carrying
  either built a CANONICAL note that neither the shape matcher nor the census
  filter recognises — the note is re-posted every round and the loop never
  converges.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-10 05:19:32 +00:00
易良
005fc97b33
fix(desktop): unblock macOS and Linux desktop packaging (#11518)
* fix(build): stop bundling the OpenTUI library for the other libc

The OpenTUI asset copier ships the native render library from every
installed `@opentui/core-*` platform package. On Linux that is two
packages, not one: the glibc and musl builds differ only by the `libc`
field, so a glibc host installs both and the bundle carries both ELF
libraries.

That breaks AppImage packaging. linuxdeploy walks every ELF file in the
AppDir to deploy its dependencies, and the musl library needs `libc.so`,
which no glibc system resolves; `ldd` exits non-zero and linuxdeploy
aborts with nothing but `failed to run linuxdeploy` reaching the log.
Desktop release 0.2.3-preview.0 failed this way on Linux, because the
desktop app copies this dist tree into its bundled runtime verbatim.

Skip the Linux package built for the libc this host does not use. The
remaining platforms are untouched: their libraries are not ELF, so the
Linux bundler ignores them and the completeness gate keeps working. It
also drops roughly 27 MB from every Linux bundle.

* fix(desktop): sign every Mach-O binary in the bundled runtime

The macOS signing step walks a list: the ripgrep vendor binaries and the
Node.js runtime. That list covered everything the bundled runtime carried
when it was written, but the runtime is a copy of the CLI's dist tree, so
its native payload changes whenever a CLI dependency does — with nothing
in this package changing.

A renderer library arrived that way and shipped unsigned. Release
0.2.3-preview.0 built for twenty minutes on both macOS targets and then
lost the archive to the notary service: "The binary is not signed with a
valid Developer ID certificate."

Enumerate the Mach-O files under the staged runtime and sign all of them,
keeping the JIT entitlement on the Node.js binary alone. Verify the
signatures in the same step so a gap fails the build in seconds instead
of surfacing from the notary service after the build.
2026-09-10 03:33:27 +00:00
Shaojin Wen
89da433888
feat: delegate a subagent turn to an external agent over ACP (Claude Code first) (#11003)
* feat: delegate a subagent turn to an external agent over ACP

A subagent definition can now declare an `executor` block naming an external
agent process. The turn runs there over ACP and is re-published as the same
AgentEventEmitter events the in-process path emits, so the JSONL transcript
writer, SubAgentTracker's nested-permission bridge, virtual subagent sessions
and the Web Shell subagent panel all work unchanged. Claude Code is the first
agent wired up.

Delegating rather than exposing a foreign agent as a peer session backend keeps
the parent Qwen session authoritative: no re-keying of workspace identity from
cwd to (cwd, backend), and no change to the bridge's one-channel-per-runtime
invariants. Agent definition files already mirror Claude Code's
`.claude/agents/*.md` schema verbatim, so this extends that compatibility from
the definition layer to the execution layer.

Core side: a `SubagentExecutor` interface narrowed to the members production
callers actually use, which `AgentHeadless` now implements so drift fails at
compile time; `createAgentHeadless` returns the interface and dispatches on
`config.executor`; `AgentCore.buildChatSystemPrompt` is extracted as
`renderSubagentSystemPrompt` (a verbatim move, the private method delegates) so
an executor that never builds an AgentCore produces a byte-identical prompt;
`dispose?()` is composed into the returned dispose so the external process
cannot outlive its subagent; and the host factory is injected through
`Config.setExternalAgentExecutor`, following the existing
`setSessionWorkflowEnabledProvider` inversion, so core gains no ACP dependency.
The types a host needs are exposed on a narrow `./subagentRuntime` subpath
rather than the package root. Because the key (`subagentRuntime`) does not mirror
its source stem (`subagent-runtime.ts`), the wildcard cannot resolve it, so every
resolution map that fronts core's exports carries an explicit entry: the cli
`tsconfig` paths and vitest alias, `integration-tests/tsconfig.json` paths, and
the skill-review-harness loader's named map. A cross-package contract test pins
the export entry, the barrel's re-exports and the cli resolutions; the repo's own
`integration-tsconfig-core-paths-sync` and `text-capture-core-loader-sync` guards
pin the other two, so a future named export that forgets a mirror fails CI rather
than silently falling through to a stale `dist`.

Everything that could silently substitute a different agent, or grant authority
nobody approved, fails loud instead. Three bypass paths are closed. Frontmatter
rejects a malformed `executor` block outright rather than dropping it — dropping
it leaves `config.executor` undefined, so no downstream check engages and the
turn runs in-process under Qwen's model with nothing on stdout, stderr or the
debug log — and the block is re-validated at the consumption point, because
session-level subagents are injected as plain objects and bypass frontmatter
parsing entirely, and at serialization, so a save/reload round trip cannot drop
the backend. An executor-bearing file whose frontmatter YAML does not parse is
rejected too, but only such a file: the validation reads the original document
node and refuses when `parseDocument` reports an error AND the file claims a
top-level `executor` — by AST key, by the sanitized frontmatter value, or by a
raw-text probe anchored to column 0 with optional quotes, so a quoted
`"executor":` is caught (the lenient parser keeps the quotes in the key and a
repaired `parseDocument` nests it, so both miss it) while an indented `executor:`
under `mcpServers:` or inside a `description: |` block scalar is not (it declares
nothing and must not hard-refuse an unrelated definition). The error must also be
able to reach the executor. The guard refuses when `parseDocument` — the real
YAML parser — lost a text-claimed executor key (`!hasExecutor`): YAML errors are
not line-local, so a compact-mapping error on an earlier line drops the whole
remainder, and the lenient `parseSimple` fallback must not be trusted to rebuild
it — it is a line-based heuristic that mangles block scalars and sequences,
turning `command: |` plus an indented `npx` into `command: '|'` and so spawning
an executable literally named `|` that the file never declared. It also refuses
when `parseDocument` kept the node but an error sits at or after the executor's
line, where a repair could rewrite it into a different command/args. Only an
error strictly before the executor line that `parseDocument` survived
(`hasExecutor` still true) leaves the subtree byte-faithful, so a valid
external-agent definition — e.g. one carrying a duplicate `name:` from a bad
merge — keeps loading instead of vanishing from `/agents` over an unrelated
quirk. And because `parseDocument` tolerates an unresolved YAML alias
(`command: *undef`) with an *empty* `document.errors` while `toJS()` throws when
it resolves the node, the `toJS()` read is wrapped so that case is refused as an
invalid executor block rather than escaping parseSubagentContent as a raw parse
error the errors-based guard cannot see. For a real
executor the lenient parser would otherwise repair a malformed document into a
node that dispatches a different command/args than the file declares, or drops
the key and runs in-process. Every other definition keeps loading through the shared
parser's lenient `parseSimple` fallback — a description containing a colon, a
tab-indented field, or a duplicate key from a bad merge must not silently vanish
from `/agents`. The refusal message carries `invalid executor block` so
`warnInvalidSubagentFile` surfaces it on stderr instead of the debug log.
`convertToRuntimeConfig` rejects executor definitions,
which is the path team and background spawning use. Workflow `agent()` rejects
them before spawning, because token budgets, schema output and workflow tool
restrictions cannot be enforced across the process boundary — that covers bounded
and unbounded workflows alike. Cold resume is denied for a transcript carrying
`executor: 'acp'` or a legacy `external-acp:*` model label; the label is
deny-only and never selects an executor. With no factory registered, `create()`
throws before warming providers or resolving a model, and an ordinary Error from
an external factory propagates unwrapped instead of being relabelled as an
AgentHeadless construction failure. This deliberately diverges from the lenient
drop used for mcpServers and hooks: losing those degrades a capability, losing
this one substitutes a different agent.

The permission mode is the host's effective, already-clamped approval policy —
not the definition's raw request and never the external agent's own config. The
Agent tool resolves the definition's `approvalMode` against the parent session's
mode and folder trust and stamps the result onto the runtime context, and the
manager reads that resolved value back when it dispatches, so a definition
cannot escalate the peer past the parent session's limit. The executor requires
the resolved mode to be present in `session.modes.availableModes` and applies it
with `session/set_mode` before the first prompt, because the agent's own
`defaultMode` was measured to let a Write execute with no permission request
emitted at all. When the agent offers no option matching the user's outcome the
executor denies rather than falling back to the first offered option — approving
"proceed once" against `[allow_always, reject_once]` must not answer
`allow_always`. `optionKindForOutcome` is exhaustive over the enum with a `never`
check and a runtime default that denies, so an unmapped outcome cannot become a
grant. A host-policy denial (headless, permission avoidance, a display-only
TOOL_WAITING_APPROVAL listener, or a routed interactive question) rejects the
TOOL by selecting a `reject_once` option rather than answering ACP `cancelled`,
because `cancelled` tells a conforming peer the whole turn is over and would
abort the delegation on its first sensitive tool; it falls back to `cancelled`
only when the peer offered no reject option, and the stream-json responder path
still answers normally. An explicit user rejection of a single tool — the
dialog's Cancel/Esc, which `selectPermissionOption` maps to undefined — likewise
selects the peer's `reject_once` option instead of answering `cancelled`, so
denying one action scopes to that tool and the turn continues, matching the
button's "suggest changes" promise and the in-process sibling (which records
"User did not allow tool call" and lets the model proceed) rather than abandoning
the remaining work and billing a fresh prompt to re-delegate.

Child-process handling: the child is spawned detached with `sanitizeChildEnv`,
because `executor.command` comes from a project-level file, so a repository the
user merely cloned picks the executable and must not receive the daemon bearer
token. The handshake races a 10s deadline against both the `error` and `exit`
events, since a command that spawns and stays silent would otherwise hang
`create()` forever. Turn wall time defaults to 10 minutes when
`max_time_minutes` is omitted, matching the workflow subagent default, and an
invalid value throws before spawn. The child is owned by acp-bridge's
ProcessRegistry with `ownsProcessTree`, so disposal escalates SIGTERM to
SIGKILL and a SIGTERM-resistant descendant cannot survive; `dispose()` suppresses
only the two shapes where the foreign root is already gone — the signal it was
sent, and a root that exits on its own mid-snapshot (a Linux race where the
prompt rejects on stdout EOF before the process `exit` event, invalidating the
initial tree snapshot) — via the exported, unit-tested
`isExpectedExternalAgentCleanupExit`. The second shape covers the peer's own exit
status for ANY code/signal (`exited uncleanly during shutdown`), which acp-bridge
raises only after it has driven every owned process group to empty, so it reports
a foreign agent that exited — already reflected in `terminateMode` — not a cleanup
failure; letting `dispose()` reject on it would replace the turn's declared
terminal state (a user-CANCELLED turn would surface to the parent as `failed`).
Every genuine cleanup-PROOF failure (a truncated snapshot, an absent or
non-group-leader root, a failed snapshot/signal/inspect, or an exceeded exit
deadline — i.e. a descendant that may have survived) still propagates. A
post-handshake exit handler drains parked approvals;
its ERROR emit is guarded by `rawListeners`, because AgentEventType.ERROR is
Node's 'error' event and the background, resume and workflow emitters attach no
listener, so an unguarded emit would turn a child crash into an uncaught
exception. stderr goes through acp-bridge's `createStderrForwarder` for chunk
buffering and credential redaction. `methodNotFound` returns a real
`RequestError`, the only form the SDK preserves as -32601 rather than
repackaging as -32603, and the SDK's `extMethod` hook answers the same way
instead of hanging the agent.

Token usage for an external turn is unknown rather than free: the adapter
exposes only a context-window gauge, and feeding a level into accumulating
statistics would inflate totals past the window. The Agent tool therefore
suppresses execution stats for external subagents, skips live and completion
stat refresh, reports no execution summary, and appends "[External executor
token usage and cost are unavailable.]" to the result. That notice is a suffix
appended after the model-visible text and its empty-text fallbacks on both the
foreground and background completion paths, never baked into the text itself —
baking it in would make a non-GOAL background run that produced no text publish
the notice in place of the real failure reason (`Agent terminated with mode:
TIMEOUT`), because the `finalText || <reason>` fallback would see a non-empty
string. Transcript metadata records `executor: 'acp'` and no `persistedCliFlags`,
so the provenance is inspectable and the resume denial above has something to key
on. Stop reasons are mapped narrowly — end_turn to GOAL, max_turn_requests to
MAX_TURNS, cancelled to CANCELLED, and refusal, max_tokens and anything unknown
to ERROR — so a truncated external turn cannot be reported as completed, and a
peer that *refused* the task is not relabelled as a user cancel (CANCELLED
renders "cancelled by the user", a false statement that also hides the refusal
from telemetry). A wall-time expiry is classified by the timer's own
`ExternalAgentTimeoutError` identity rather than re-reading `Date.now()` in the
catch, because a second realtime clock can miss the monotonic deadline by under a
millisecond and misreport a turn that merely reached its budget as ERROR instead
of TIMEOUT. The continuation loop also re-checks that budget before dispatching
each round: `remaining` clamps to 0 once the wall time is spent, and without a
guard `connection.prompt(...)` is evaluated first — a new, billed model turn
really reaches the peer — one tick before `wait`'s 0ms timer rejects with
`ExternalAgentTimeoutError`, so the catch disposes (SIGTERM/SIGKILL) a peer that
just started work and the message the round already drained and recorded as
delivered is never processed. The loop now sets `TIMEOUT` and breaks before that
dispatch when `remaining <= 0`, mirroring the in-process sibling, which checks
the budget at the top of every round; it sets `terminateMode` directly rather
than throwing, because the catch classifies wall-time expiry by error identity.
And a continuation `execute()` turn — a resident external agent
re-invoked per incoming user message — emits the task as a user-side
`EXTERNAL_MESSAGE`, matching the in-process sibling, so the JSONL transcript does
not lose every message after the first. When a turn ends with a tool call still
open (cancel sets `cancelled` before the peer's terminal `tool_call_update` can
arrive, and timeout or peer crash leave it mid-flight), `runTurn`'s finally
flushes every entry left in the tool map as a failed `TOOL_RESULT` +
`TOOL_RESPONSES_FINALIZED` before `FINISH`, so the inline frame and Web Shell row
stop showing a tool executing forever, the JSONL `functionCall` is paired with a
`tool_result`, and the `FINISH` totals sum — the in-process sibling closes the
same gap via `onAllToolCallsComplete`. The session-update entry point drops any
update once the turn is over for ANY reason — its guard now also checks
`disposed` and the integrity-abort signal, not just `cancelled` — because on the
timeout and crash paths the catch awaits `dispose()` before the finally clears
`executing` and `cancelled` stays false, so a peer still writing would otherwise
be appended to `finalText`, emitted as `STREAM_TEXT`/`ROUND_TEXT`, and counted
into the `FINISH` totals for up to `EXIT_DEADLINE_MS` after termination, handing
a partial result to the parent verbatim on the non-rethrowing TIMEOUT path.
Safe mode also refuses an external executor at the dispatch gate, not only by
filtering discovery: `loadSubagent` resolves a `subagent_type` from disk
regardless of safe mode, and a cloned repo can ship `.qwen/agents/<builtin>.md`
carrying an `executor` that resolves before the built-in of the same name, so
safe mode now throws rather than spawn a definition-supplied binary.

The design doc now describes only the shipped product and these fail-loud
rules; the rejected peer-backend alternative is recorded as rejected.

Verification: core and cli `tsc` 0 errors, ESLint and Prettier clean, `git diff
--check` clean, cli executor tests 48/48, core subagent/agent/runtime suites
1483/1483, cross-package contracts and the two core-export sync guards 17/17.
Mutation
proofs, each run in both directions: removing the frontmatter executor guard
fails 8 tests, removing the workflow `agent()` rejection fails 6, replacing the
deny fallback with the first offered option fails 3, returning a plain Error
instead of `RequestError` fails the -32601 wire assertion, narrowing
`isExpectedExternalAgentCleanupExit` back to the signal-only form fails the
cleanup-exit classification test (which now also pins tolerating the peer's own
non-zero exit code), answering a host-policy denial with `cancelled`
instead of a `reject_once` selection fails 5, deriving the peer mode from the raw
definition instead of the host-resolved policy fails its dispatch test, reading
the sanitized frontmatter value instead of the original YAML node fails the
null-args rejection, making the `document.errors` refusal unconditional (instead
of executor-scoped) fails the non-executor lenient-load test, un-anchoring the
executor-claim text probe (dropping the column-0 / optional-quote form) fails
both the quoted-key refusal and the nested-key lenient-load tests, disabling the
turn-end `flushOpenTools` fails the open-tool flush test, deleting the
`loadCliConfig` executor registration fails the registration test, narrowing the
session-update guard back to `cancelled`-only lets a post-timeout `LATE` chunk
into `finalText` (failing the post-terminal-drop test), and disabling the
safe-mode dispatch refusal lets an external executor run in a trusted safe-mode
folder (failing the safe-mode refusal test), routing a user's Cancel back to ACP
`cancelled` (dropping the `reject_once` fallback at the respond site) fails the
user-cancel denial test, and reverting the executor-error guard to fire on any
`document.errors` entry instead of scoping to errors that reach the executor line
fails the unrelated-error-before-executor load test while the quoted-key and
unterminated-quote refusals stay green, removing the continuation budget guard
lets a 0-`remaining` round dispatch a second prompt (failing the
no-dispatch-after-budget test, which then sees two prompts and GOAL instead of
one and TIMEOUT), and reverting `astLostExecutor` to also require
`frontmatter.executor === undefined` lets a `parseDocument`-dropped executor load
through the `parseSimple` fallback as `command: '|'` (failing the dropped-node
refusal test), and re-throwing the raw error from the `toJS()` wrapper instead of
the graceful `SubagentError` fails the unresolved-alias refusal test (the
rejection becomes the raw "Unresolved alias" YAML error, not /invalid executor
block/). Mapping
`refusal` back to CANCELLED fails the continuation test, and dropping the
continuation `EXTERNAL_MESSAGE` emit fails the transcript test. The wall-time
test was the flake the timeout-classification fix removes; it now passes 5/5
deterministic runs. The real-subprocess suite is gated
`describe.skipIf(process.platform === 'win32')` like the repo's other
real-process suites, because Windows tree-kill reports a numeric exit code that
`dispose()` surfaces as unclean — the `test_windows` lane is merge-queue-only, so
an ungated suite there would eject the entry. The executor tests
drive a real ACP child process speaking the wire protocol, across
init/session/mode/prompt hangs and exits, max_tokens and unknown stop reasons,
duplicate permission callIds and optionIds, ask-user denial, descendant tree
kill, env sanitization and unsupported-extension responses. A separate
end-to-end run drove the real `@agentclientprotocol/claude-agent-acp` adapter and
a real model through this executor: `session/set_mode` `default` landed before
the prompt despite conflicting factory inputs, one permission request was emitted
and answered, a `Cancel` produced no file while a `ProceedOnce` selected the
adapter's `allow-once` (not its broader `allow-always`) and wrote exactly the
requested content, and both turns reported zero tokens — which is why the Agent
tool suppresses the summary rather than presenting zeros as free.

Not done: the real-adapter run above is a direct-factory harness, not the full
settings-loader → CLI → Web Shell path, and it does not exercise a browser
approval dialog, a second permission request in one turn, or headless auto-denial
against the real adapter; only macOS has been exercised, so the Windows `.cmd`
spawn path is unimplemented and untested (R3-1, left open — `cross-spawn`, the
repo's safe Windows launcher, is not a `packages/cli` dependency, and `shell:
true` around a project-supplied command is an injection surface, so this needs a
dependency decision and a Windows-testable design rather than a guess); the
optional mid-turn external-message waiter is unimplemented (R3-6, left open —
queued messages are still drained between turns, but an external subagent cannot
park for a not-yet-arrived one); and the Web Shell approval dialog still uses the
`info` confirmation variant, so it renders no file diff and has no screenshot
here. `ROUND_START`/`ROUND_END`/`USAGE_METADATA` are still not emitted, but the
one Critical consumer — the workflow stall watchdog, which arms only on those
events — can no longer be reached by an external agent at all, because workflow
`agent()` rejects external-executor definitions before spawn; `USAGE_METADATA`
also has no honest source (the adapter exposes a context-window gauge, not
per-turn token deltas). The round-4 through round-7 fixes in this commit were
audited to convergence by mutation proof and direct full-diff review; the earlier
rounds' agent-driven directionless audit did not converge because those agents
died on infrastructure failures.

* fix(subagent): close the round-10 review Criticals on the external executor

Five Criticals from the round-10 review of the external-agent executor and its
definition loader. Each is mutation-verified (reverting the fix turns its test
red) and the surrounding guarantees are pinned.

R10-1 — the executor-frontmatter parser derived both the claim and the value
from a column-0 regex plus the lenient `parseSimple` fallback instead of the
real YAML AST, which got the verdict wrong in both directions. Under-detection:
a TAB-indented top-level `executor:` is invalid YAML the AST drops, and a
column-0 probe missed it, so the definition loaded executor-less and ran
in-process under a Qwen model with nothing on any channel. Invented dispatch:
`parseSimple` hoists an `executor:` line sitting inside a `description: |` block
scalar into a top-level key, so a file declaring no executor loaded as external
and dispatched a command that exists only as prose. Both legs now come from the
AST: the claim probe is indentation-tolerant (`^[ \t]*`), `claimsExecutor` no
longer reads `frontmatter['executor']`, and `executorRaw` is `undefined` (never
the `parseSimple` value) when `parseDocument` has no top-level executor node.
The accepted cost — an `executor:` token nested under another key in an
otherwise-malformed file is now refused — is a visible, user-fixable
over-refusal, which beats an invisible substitution or an invented dispatch.

R10-2 — the load-time executor refusals were file *skips*, so `loadSubagent`'s
session>project>user>extension>builtin fall-through substituted a
lower-precedence in-process definition (or a case-insensitive builtin) of the
same name and the delegated turn ran under a Qwen model — the exact substitution
this feature prevents, with only a discovery-time `console.warn`. Executor-block
refusals are now recorded by declared name during the level scan (the
`parseSubagentContent` re-wrap preserves the `SubagentError` and its
`subagentName`), and the by-name fall-through path throws the recorded refusal
before descending or reaching `getBuiltinAgent`. Scoped to executor refusals (not
parse failures generally) so an arbitrary malformed repo file cannot disable an
unrelated builtin, and scoped to the no-level fall-through path (the explicit
-level path does not fall through, so it cannot substitute) to keep the
management callers' contract unchanged; `isNameAvailable` treats a refusal as
"name taken".

R10-3 — `isExpectedExternalAgentCleanupExit` swallowed acp-bridge's
`exited before its initial process-tree snapshot completed`, which
`mergeAsynchronousSnapshot` records and returns BEFORE `collectOwnership`, so a
detached (`setsid`) descendant was never enumerated, signalled or reaped — the
tree was not proven gone, yet `dispose()` resolved silently and the parent saw a
clean teardown. That shape is now `isUnprovenExternalAgentTreeExit`: `dispose()`
reports it (`debugLogger.warn` + an `AgentEventType.ERROR`) and resolves rather
than rethrowing (it is awaited between terminal-state classification and return,
so rethrowing would convert a classified TIMEOUT/CANCELLED into a thrown ERROR).
`isExpectedExternalAgentCleanupExit` is narrowed to the peer's own unclean exit
(raised only once the tree is proven gone); every other cleanup-proof failure
still propagates.

R10-4 — `runTurn` destructively drained the external-input queue and recorded the
messages as delivered before the round-top budget check, so a wall-time expiry
(or a bottom-of-loop abort) discarded user messages already emitted as delivered
— the transcript certified delivery of a message never sent, and the registry had
already spliced it out. The drain is now gated on the budget and the abort signal
first, mirroring the in-process sibling (`agent-core` checks the budget before
`drainExternalInputs`): when the budget is spent or the signal aborted, the loop
breaks before draining, so the message stays queued for a future turn.

R10-5 — an absent `max_time_minutes` silently meant "10 minutes then terminate"
for an external agent, while meaning "no cap" everywhere else, so the same
definition behaved differently based only on whether it declared an executor, and
a TIMEOUT turn (handed to the parent as the answer with no truncation marker) was
reachable by default. The cited rationale was void: `DEFAULT_WORKFLOW_SUBAGENT_
MAX_TIME_MINUTES` is read only on the workflow path, which hard-rejects external
executors upstream, so it never reaches this executor. An absent value is now
passed through as `undefined` (no timer), matching `agent-core`; a caller that
wants a cap supplies `max_time_minutes` (validated positive, finite, ≤ the Node
timer range).

Verification: core and cli `tsc` 0 errors (after a full `npm run build` to refresh
the workspace `dist` the merge had left stale); ESLint and Prettier clean; `git
diff --check` clean; cli executor suite 50/50; core subagent/agent/runtime
suites 2510 passed (6 skipped); cross-package contracts and the two core-export
sync guards 17/17. Mutation proofs, each run in both directions: reinstating
`?? 10` fails the no-default-cap test; removing the pre-drain budget/abort gate
fails the queue-preserved test (the message is drained, recorded delivered, and
lost); making `dispose()` swallow the snapshot-race error fails the
unproven-tree-report test; reverting the claim probe to column-0 anchoring fails
the nested-token refusal test; restoring the `parseSimple` fallback for
`executorRaw` fails the block-scalar-prose test (it loads `command:'npx'` from
prose); and no-oping the recorded-refusal throw fails the by-name dispatch test
(it resolves the builtin instead of rejecting). Reverse-audit converged after one
fix (scoping the R10-2 throw to the fall-through path so the explicit-level
management callers keep their null contract), then clean passes over the
production and test diffs.

* fix(subagent): close the round-11 review Criticals on the external executor

Six Criticals from the round-11 review: five on the executor and its definition
loader, one a regression the round-10 R10-2 fix itself introduced. Each is
mutation-verified (reverting the fix turns its test red).

R11-1 — a file that CLAIMS an executor but fails an EARLIER validation (a
missing description, a bad approvalMode) was skipped with nothing recorded, so
loadSubagent's fall-through silently substituted the builtin of the same name.
The executor claim and the trusted declared name are now computed at function
scope, right after the frontmatter is parsed (BEFORE any validation can throw),
so the catch converts ANY load failure of an executor-claiming file into a named
executor refusal the fall-through throws. (A file whose own name is unparseable
stays undefined-keyed and falls through to the generic wrap — it cannot be
matched by name anyway.)

R11-2 — the wall-time budget was computed per-turn (timeoutMs - elapsed this
turn), so a resident agent re-invoked per incoming message (resetStats:false,
which preserves durationMs) got a FRESH max_time_minutes cap each continuation
and could overrun the whole-delegation budget indefinitely. Both remaining
computations now subtract the accumulated durationMs, matching the in-process
sibling's preserveStats base: max_time_minutes caps the whole delegation. Both
production continuation callers (background-agent-resume, agent) pass
resetStats:false, so the cumulative cap reaches the real path.

R11-3 — the host approval mode was used AS the peer's mode id, but the
vocabularies differ (host/qwen: auto-edit/auto/yolo; Claude: acceptEdits/
bypassPermissions), so a host auto-edit could select a peer mode that does not
exist (or, worse, a qwen peer's broader auto). resolvePermissionMode now maps the
host policy to a peer-vocabulary-independent TOKEN (default/plan/acceptEdits/
bypass) and connect() picks the first id the peer actually advertises via a
canonical-first alias table, refusing (naming the policy) when none is
advertised. The mapping never WIDENS: acceptEdits prefers the peer's narrowest
edit-only alias.

R11-4 — the executor refusal was keyed by the lenient parseSimple name, which
strips only double quotes, so a single-quoted `name: 'Explore'` was recorded
under "'explore'" and missed the 'explore' dispatch lookup, falling through to
the builtin. The refusal is now keyed by the real YAML AST's name (parseDocument
strips both quote styles), with the lenient value only as a fallback when the
AST read throws.

R11-5 — the approval confirmation's prompt rendered the OPTION LABELS (which the
dialog already shows as buttons) and dropped the action's arguments, so the user
approved "Write" without seeing the `rm -rf ./build` it would run. The prompt is
now describeExternalAction(toolCall): the title plus the rawInput, bounded to
300 chars and stripped of C0/C1/DEL control chars (foreign-process data, never
rendered as markup).

R10-2 (fix-induced) — extension agents load via loadSubagentFromDir, which skips
+ warns on a refusal, so the R10-2 level-scan recording never ran for them and
the extension leg of the fall-through read an empty map. loadSubagentFromDir now
takes an optional refusal collector; the runtime loadExtension records each
extension's executor refusals onto extension.agentExecutorRefusals, and
listSubagentsAtLevel('extension') merges them into the 'extension' refusal
bucket before findSubagentByNameAtLevel returns, so the by-name fall-through
refuses them too. (The install/update consent path also calls
loadSubagentFromDir, but only for display — it does not feed the dispatch
fall-through, so it needs no collector.)

Verification: core and cli tsc 0 errors; ESLint and Prettier clean; cli executor
suite 57/57; core subagent-manager suite 199/199; core subagent/agent/runtime
regression net 2513 passed (6 skipped). Mutation proofs: dropping the cumulative
durationMs subtraction fails R11-2 (turn 2 ends GOAL, not TIMEOUT); removing the
peer-canonical acceptEdits alias fails R11-3 (the auto-edit dispatch finds no
advertised mode); reverting the approval prompt to the title fails R11-5 (no
`rm -rf ./build`); neutralizing the catch conversion fails R11-1 (loadSubagent
resolves the builtin); keying by the lenient name fails R11-4 (single-quoted
name resolves the builtin); and no-oping the extension-refusal merge fails the
R10-2 extension leg. Reverse-audit converged after one fix (the R11-2 test burned
the whole budget in turn 1, tripping the R10-4 pre-drain gate's GOAL-to-TIMEOUT
reclassification; redesigned so turn 1 ends GOAL and turn 2 hits the cumulative
cap), then clean passes over the production and test diffs.

* fix(subagent): close the round-12 review Criticals on the external executor

Six Criticals from the round-12 review — three on the executor, one a
regression the R11-5 fix itself introduced, and two on the definition loader's
refusal bookkeeping. Each is mutation-verified (reverting the fix turns its
test red).

R12-1 — execute() re-rendered and re-sent the entire system-prompt bundle on
every continuation turn, although the live ACP session has held it since turn 1
(NewSessionRequest has no system-prompt channel, so the prompt content block is
the bundle's only channel and the FIRST turn must still send it). A resident
external agent re-invoked per incoming message re-sent the definition's
systemPrompt + the appended rules + the whole memory hierarchy (~16.5 KB) every
turn, billed by the peer and pushing its context toward compaction, while the
transcript recorded only the task. The bundle is now gated on the first turn:
`const continuation = this.started` is hoisted above the render and
continuations send only the task.

R12-2 — the turn-entry `emitInputs` recorded the caller's messages as delivered
BEFORE the round-top wall-time guard, so an over-budget continuation wrote
EXTERNAL_MESSAGE user records for a prompt that was never dispatched (the guard
breaks TIMEOUT first). The entry emit moved to the loop's commit point — after
the budget guard, before the prompt — keyed on the ENTRY round (`entryRound`,
not `this.round === 1`, which is broken under resetStats:false because the round
counter is preserved across continuations). Emitting before the dispatch keeps
user-before-assistant order in the JSONL transcript.

R11-5 (fix-induced) — the R11-5 confirmation carried the peer's arguments but
rendered them as MARKDOWN (the `confirmationDetails` omitted
`renderPromptAsPlainText`), so a glob `**` was eaten and a `[label](url)` could
mis-render, misrepresenting the action being approved. The info variant now sets
`renderPromptAsPlainText: true` (the bound and control-strip stay — the flag is
not a substitute for them). Consolidating the local control-char stripper into
the centralized `stripAnsiAndControl` was considered but requires a new
cross-package core export; the flag is what neutralizes the rendering, and the
local stripper already kills the ESC byte so no escape sequence survives.

R12-3 — `resolveResumeTarget` reaches the `loadSubagent` that R10-2/R11 made
throwing, unguarded, so background-agent discovery swallowed the throw into a
debug-only per-sidecar warning and the recovered row VANISHED from /tasks
instead of listing with a `resumeBlockedReason`. The `loadSubagent` call is now
wrapped and the throw converted into the existing `unavailableReason` shape
(both call sites already fold it into `resumeBlockedReason`). The
executor-provenance early return stays ahead of the call, so the four
cold-external-resume provenance legs still refuse before any load.

R12-4 — `executorRefusals` was only rewritten when a level scan reached the end
of its `try`, so a scan that could not read the directory left the previous
scan's refusals in place and `loadSubagent` kept throwing for a file that no
longer exists. Both non-scan paths now reset the level's map: the `readdir`
catch and the `project == home` early return.

R12-5 — the indentation-tolerant raw-text probe made `claimsExecutor` true for
frontmatter that only MENTIONS `executor:` as prose inside a `description: |`/`>`
block scalar, so `astLostExecutor` hard-refused an in-process definition that
carried an unrelated tolerated YAML quirk (duplicate key, tab indent), and keyed
the refusal by its name — blocking every lower-precedence definition and builtin
of that name. The probe match is now excluded when its offset lies inside a
block scalar (walked via the real AST's BLOCK_LITERAL/BLOCK_FOLDED scalar
ranges, fail-closed if the walk throws). A genuine top-level — even
TAB-misindented or nested — `executor:` key is not inside a block scalar, so the
R10-1/R9-2/R7-1 refusals all still hold.

Verification: core and cli tsc 0 errors; ESLint and Prettier clean; cli executor
suite 61/61; core subagent-manager 202/202 and background-agent-resume 56/56;
core subagent/agent/runtime regression net 2517 passed (6 skipped). Mutation
proofs: re-sending the bundle on continuation fails R12-1 (turn 2 carries 2
blocks); moving the entry emit back above the guard fails R12-2 (`delivered`
becomes `['second']`); dropping `renderPromptAsPlainText` fails the R11-5 case;
rethrowing in `resolveResumeTarget` fails R12-3 (the row vanishes); deleting the
catch reset fails R12-4 (stale refusal persists); dropping the block-scalar
exclusion fails both R12-5 variants (they reject). Reverse-audit converged to
clean passes over the production and test diffs.

* fix(subagent): resolve the two carried Criticals via maintainer decision

Closes the two Criticals standing since round 3, per the maintainer's decision
on the round-12 review: both are resolved by an explicit, surfaced limitation
rather than an untestable/billing-risky implementation. Each is
mutation-verified (reverting the fix turns its test red).

R3-1/R3-22 (Windows spawn) — decision: POSIX-only for this release. On Windows
an npm-installed adapter resolves to a `.cmd` launcher that libuv's PATH search
never finds (bare name + `.exe` only) and Node >= 18.20.2 refuses to spawn
without a shell; the process-tree reaping is likewise POSIX-specific. The
executor previously failed at dispatch with a misleading `spawn <cmd> ENOENT`
(reporting an installed adapter as missing). A new
`assertExternalAgentSpawnPlatformSupported` guard now fails closed at the top of
`create()` with a clear, actionable "POSIX-only in this release" error, before
any spawn. A cross-spawn-style PATHEXT resolution + quoted `cmd.exe` arm is a
tracked follow-up (it changes the security-critical detached-spawn path and is
untestable without a Windows lane).

R3-6/R3-51 (mid-turn waiter) — decision: explicitly decline + surface the
limitation. ACP v1 has no mid-turn injection primitive (no `session/steer`), so
the executor deliberately does NOT implement `setExternalMessageWaiter` /
`setExternalMessageWaitPredicate` (documented on the class); input arriving
mid-prompt is delivered at the next turn boundary via the provider. To stop the
background loop presenting a queued steer as if delivered mid-turn, the
delegation result for an external executor now appends
`EXTERNAL_MID_TURN_INPUT_NOTICE` (alongside the existing usage notice) stating
queued input is delivered between turns, not mid-turn. True mid-turn steering
(cancel + re-prompt) re-bills the in-flight turn — a protocol/billing decision,
tracked as a follow-up.

Verification: cli + core tsc 0 errors; ESLint + Prettier clean; cli executor
suite 63/63 (incl. a win32-stubbed `create()` rejection that never reaches
spawn); core agent suite 288/288; core subagent/agent/runtime regression net
2518 passed (6 skipped). Mutation proofs: removing the win32 throw fails both
R3-1 tests; dropping the mid-turn notice fails the R3-6 result assertion.
Reverse-audit converged to clean passes over the production and test diffs.

---------

Co-authored-by: probe <probe@local>
2026-09-10 03:13:05 +00:00
易良
0039f184f8
fix(ci): space PR creation from release to avoid secondary rate limit (#11444)
* fix(ci): space PR creation from release to avoid secondary rate limit

* test(ci): pin sleep-before-create gap in all three release workflows
2026-09-09 12:59:56 +00:00
ChiGao
e09c461a92
fix(cli): restore OpenTUI banner, footer, and loading states; fix the OSS mirror flavor (#11437)
Some checks failed
Qwen Code CI / Integration Tests (no-AK, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (ubuntu-22.04) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (windows-2022) (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 1/2 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 2/2 (push) Blocked by required conditions
E2E Tests / Build for E2E (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Interactive - OpenTUI renderer (bun) (push) Blocked by required conditions
E2E Tests / channel-plugin E2E (nightly) (push) Blocked by required conditions
pnpm Worktree Smoke / Install (macos-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (ubuntu-latest) (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/1 (push) Blocked by required conditions
E2E Tests / cron-interactive E2E (nightly) (push) Blocked by required conditions
E2E Tests / web-shell Browser Regression (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
pnpm Worktree Smoke / Install (windows-latest) (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Update ECS Runner Qwen / Resolve version (push) Has been cancelled
Update ECS Runner Qwen / Update Qwen on ecs-update-hk-1 (push) Has been cancelled
Update ECS Runner Qwen / Update Qwen on ecs-update-hk-2 (push) Has been cancelled
Update ECS Runner Qwen / Update Qwen on ecs-update-hk-3 (push) Has been cancelled
Update ECS Runner Qwen / Update Qwen on ecs-update-hk-4 (push) Has been cancelled
Update ECS Runner Qwen / Update Qwen on ecs-update-hk-5 (push) Has been cancelled
Update ECS Runner Qwen / Report a stale fleet (push) Has been cancelled
* fix(cli): restore banner, footer, and loading states in the OpenTUI shell

The batched OpenTUI migration restructured the original single-file backend
into modules and dropped three user-visible pieces of the chrome along the
way: the startup banner, the bottom status line, and the responding
spinner. The OpenTUI renderer therefore booted into a screen that looked
nothing like the ink one it was meant to replace.

Rebuild all three as their own modules and mount them in the app shell,
following the ink layout: banner at the top, loading indicator above the
composer, status line at the bottom.

Two defects carried over from the original implementation are fixed rather
than copied. The context-usage label was missing its percent sign, and the
approval-mode colour check compared against a label that never matched, so
the auto-edit mode fell through to the default colour.

The context-file count is intentionally not restored: the shell no longer
tracks it and the current ink footer dropped it too.

* fix(cli): tick the OpenTUI spinner at the rate ink uses

The restored spinner ran at 120ms, carried over from the pre-batch
implementation, while the ink responding spinner it mirrors runs the same
frame set at 80ms and the existing OpenTUI compaction spinner does too. The
same animation therefore looked visibly slower under one renderer.

Name the interval and set it to the shared rate. The frame set itself is
unchanged.

* fix(release): sync the OSS mirror by what the tag shipped, not by a variable

The workflow that mirrors a release to Aliyun OSS decided whether to expect
the bun/OpenTUI preview archives from a repository variable. A repository
variable describes the default branch, while the checkout and the downloaded
assets come from the tag being synced, and the workflow can be re-dispatched
for any tag. Re-syncing a tag cut before the preview flavor existed therefore
asked that tag's own verify script for archives the release never shipped:
v0.22.3 fails because its script does not know the flag at all, and v0.23.0
fails on a missing checksum entry.

Derive the flag once from the archives actually downloaded and let all three
consumers read it, so the mirror follows the release instead of the branch.

Two related gaps in the same gate are closed here. The test covering it
asserted only the variable name, so inverting the comparison and silently
changing the flavor's default polarity stayed green; it now pins the
operator. And the variable appeared in no documentation, so an operator had
no way to learn that leaving it unset is what keeps the flavor off.

* fix(cli): source the OpenTUI chrome from main's shared helpers

The restored banner, footer, and responding indicator had grown private
copies of mappings main already owns, so the two renderers could drift.
Route the approval-mode label through formatApprovalModeName, extract the
context-usage format and version-label rules into ink-free helpers both
trees consume, and take the spinner frames and loading phrases from the
shared constants and cycler.

Also truncate the footer rows and the indicator phrase to the terminal
width so a long branch, path, or phrase cannot wrap the chrome mid-turn,
and read typed Config accessors in the banner instead of structural
casts and a manual package.json read.
2026-09-09 09:26:07 +00:00
qwen-code-dev-bot
a5bc6c5497
fix(ci): isolate serve route E2E from fork pressure (#11389) (#11391)
* fix(ci): isolate serve route E2E from fork pressure (#11389)

The Linux docker E2E leg has gone red four times since #11306 merged
the three Linux shards into one job with three Vitest forks (issues
#11331, #11343, #11384, #11389). In run 34221129472 the
cli/qwen-serve-routes.test.ts suite — a long-lived `qwen serve` daemon
answering local HTTP requests — took 297.8s and timed out on all three
attempts against 85.5s in the preceding green run, while the same
commit's sandbox:none leg passed. The suite has this exact signature
on record already: AcpSessionBridge initialize timeouts and 504s on
the sandbox:docker leg under shared-host load.

Run the latency-sensitive suite alone in a single fork after the
three-fork batch on both Linux legs, leaving every other suite's
parallelism unchanged. The sandbox:none bounded retry now covers the
batch and the isolated suite as one attempt; the docker leg still runs
each command exactly once. Workflow and retry witnesses updated, with
bash-executed coverage that a failure in either half retries once and
that two failed halves keep the step red.

* test(ci): pin isolated E2E suite order and filter targets (#11389)

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

* test(ci): complete exclude tree pins and tolerate benign script formatting (#11389)

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-09 02:08:08 +00:00
Shaojin Wen
70cf363395
feat(web-shell): Improve split-view session navigation (#11250)
* feat(web-shell): Improve split-view session navigation

* fix(ci): Run Web Shell browser checks on hosted runners

* fix(web-shell): Keep split navigation outside approval shortcuts

* fix(web-shell): Address split-view review regressions

* fix(web-shell): Stabilize split approval reports

* fix(web-shell): Preserve history anchors during slow rendering

---------

Co-authored-by: 易良 <1204183885@qq.com>
2026-09-08 17:33:19 +00:00
易良
b9f5d15a5b
fix(ci): isolate subprocess-heavy E2E from fork pressure (#11388)
* fix(ci): isolate serve route E2E from fork pressure

* refactor(ci): move E2E runner out of workflow YAML

* refactor(ci): reuse E2E runner arguments

* fix(ci): serialize SDK E2E subprocess suites
2026-09-08 15:45:07 +00:00
易良
d87ccc6579
fix(ci): reap release integration containers (#11330) 2026-09-08 09:48:05 +00:00
易良
078b924989
refactor(ci): extract release workflow scripts (#11165)
* refactor(ci): extract release workflow scripts

* test(ci): pin release-note labeling to the extracted step script

release.yml no longer inlines the auto-labeling body: the publish step now
just dispatches to run-release-step.sh, which owns the git rev-list
enumeration and the classify-release-notes.mjs call. The wiring test kept
asserting those four strings against the step's run block, so it failed on
the dispatcher path alone.

Read run-release-step.sh and assert the labeling body there, and assert the
dispatch from release.yml. The workflow-level guarantees (step name,
continue-on-error, GITHUB_TOKEN env, issues/pull-requests write permissions)
and the .github/release.yml exclusion entry are unchanged.

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

* test(ci): repoint release assertions at the extracted step script

The extraction moved the GitHub Release notes, standalone archive, and
release-failure autofix label logic out of .github/workflows/release.yml
into .github/scripts/run-release-step.sh, but three consumer suites still
pinned the moved text in the YAML, so `npm run test:scripts` was red and
the release workflow's fail-closed `quality` gate would refuse to publish.

Read the step script in ai-release-notes-workflow, install-script, and
qwen-autofix-workflow and assert the moved strings there with their new
lowercase locals (`notes_args`/`notes_file`), keeping on release.yml only
what genuinely stayed in it. Restore the "Safe to auto-apply approval"
rationale above both AUTOFIX_APPROVED_LABEL writes in the notify-failure
arm; it is the only recorded justification for the workflow granting
itself autonomous autofix approval.

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

* test(ci): derive the ECS wipe subject set from the runner, not the wipe

Selecting checkout jobs by "has a Restore workspace ownership step" made
the subject set a function of the property under test, so a job added to
the ecs-qwen-hk4-host pool with an actions/checkout and no wipe was
filtered out before any assertion ran. Key the exemption on the pool
marker instead, and pin that every pool-routed job carries the wipe
whether or not it checks out.

The hosted label cannot be the discriminator: every pool-routed runs-on
expression names 'ubuntu-latest' as its fallback branch.

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

* test(ci): re-scope the release-creation token pin to its own step

The workflow-wide toContain was satisfied by the second CI_BOT_PAT
occurrence on "Trigger ECS runner qwen update", so flipping the token on
"Create GitHub Release and Tag" to github.token left every lane green.
Restore the step scoping and the deleted rationale comment in release.yml.

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

* fix(ci): fail the release step runner closed on pipeline legs

run-release-step.sh set bare `set -e` while both sibling runners set
pipefail. In notify-failure a connection-level gh failure inside
`gh issue list ... | jq -c ...` leaves jq exiting 0 on empty input, so the
empty result took the create-a-new-issue branch and filed a duplicate
autofix/approved release-failure issue past all three reuse guards.

Pin the flag next to the existing sibling pin and add a behavioural row for
the notify-failure arm with an unreachable gh.

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

* test(ci): relocate the release timeout value-format contract

This PR condensed the nine-line release.yml lane comment to "values are
minutes" and relocated the format half nowhere: grep for the deleted claims
(expression error / free text / truthy / leading number) found no copy under
.github/, scripts/tests/ or docs/. Put the contract beside the assertions that
already pin the three tunable lanes' expressions, since release.yml is
size-pinned twice.

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

* docs(ci): scope the workflow_sha claim to the extracted helpers

The design doc said the trusted checkout "prevents an operator-selected
release ref from supplying code that receives release credentials". Two paths
at HEAD contradict that reading: resolve-version executes the ref's
scripts/get-release-version.js under the job token (run-release-step.sh:84),
and push-release-branch commits with core.hooksPath .husky (:116, :130) in a
step whose env carries CI_BOT_PAT (release.yml:659). Both predate this
extraction, so name them as residual paths rather than as prevented ones.

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

* fix(ci): format the release-version test to satisfy the Prettier gate

`Lint & Static` failed at the `Run Prettier` step: a continuation line in
the `assert-unreleased` format-gate test carried four extra spaces.

* fix(ci): exclude nested .git dirs from the docker build context

The extraction added a `.release-workflow/` checkout to `integration_docker`
between the sandbox build's reset and the build itself, and `.dockerignore`
listed `.git` only in its root-anchored form. A depth-1 sparse checkout of
the pinned ref is 1.1M of worktree over 51M of git objects, so every
uncached sandbox image baked those objects into a discarded builder layer on
the shared pool this job is routed to. `node_modules` and `dist` already
carry both forms.

* fix(release): close four gaps the script extraction left behind

R1-15: the push-time guard returned exit 2 for a malformed version, and
run-release-step.sh treats 2 as a transient probe failure — so a refusal
that can never succeed was retried three times over ~45s and logged as a
connectivity problem. Malformed versions now exit 4, which the retry loop
breaks on immediately and which is not the exit-3 version_refusal marker.

R1-16: the extraction moved the guard behind `.release-workflow/` so the
decision comes from the workflow-pinned SHA, but left a working
`--assert-unreleased` dispatch in the ref-supplied get-release-version.js.
A future step using that spelling would load the selected ref's guard.
Removed it, and re-pointed its five tests at the real entry point.

R1-13: assert-release-version.mjs ships alone under release.yml's
sparse-checkout set, so it cannot import release-helpers.js — the
duplicated isExpectedMissingGitHubRelease is deliberate. Documented that
at both sites and pinned the two bodies identical, so widening only the
guard's copy (which would read a rate-limited probe as "release absent")
goes red.

R1-6: restored the notify-failure arm's rationale, matching the condensed
comments the other five arms kept: the exact-title re-check that stops a
v0.18.1 failure reusing a v0.18.10 issue, the bot-author preference, and
the re-query that keeps autofix off a maintainer-owned issue.

* fix(release): re-pin the trusted runner before every step that runs it

R1-10: `Build Bundle and Prepare Package`, `Build Standalone Archives` and
`Verify Standalone Archives` executed `.release-workflow/.../run-release-step.sh`
from a tree that selected-ref npm code had already run over — `build-package`
for the first two, and `npm publish`'s own lifecycle scripts for the third,
inside the job that holds `id-token: 'write'`. At the merge base these were
inline `run:` bodies sourced from the workflow file, immune to anything
written into the working tree, so the exposure is introduced by the
extraction. Each now gets the reset-and-re-checkout pair the design doc
already promises for credential-bearing steps.

The guarding test hand-listed six protected step names, so it could not fail
for the three it omitted. It now sweeps every step whose `run` mentions
`.release-workflow/` (17 today) and separately pins that each re-checkout is
preceded by a reset.

R1-5: nothing tied the publish allowlist to the guard's `PUBLISHED_PACKAGES`
except a comment pointing at publish steps this PR deleted from release.yml.
A channel added to the loop but not the array would ship unprobed, so a retry
of a partial release reports "unreleased" and force-pushes over the tip the
shipped package anchors to. The two sets are now pinned against each other,
and the stale comment points at the real pin.

The three inserted step pairs grow release.yml by 294 bytes (well inside the
4096-byte allowance); the ratchet baseline and the PR description move with
it rather than letting the recorded number drift.

* test(release): pin the flags, arms and modes the extraction left unwitnessed

R1-19: the arm's only executable probe stubbed npm with a script that
recorded `$PWD` and discarded every argument, so deleting `--tag=${NPM_TAG}`
stayed green while `npm publish` would fall back to the `latest` dist-tag —
handing the 21:00 UTC nightly to every end-user install and to the ECS fleet
updater. The stub now records the argv and each publish is checked for
`--access public` and the dist-tag.

R2-3: `resolve-version` was the one arm no test executed or text-pinned;
flipping `--type=preview` to `--type=stable` survived all 2233 scripts tests,
while at runtime the stable path ignores `preview_version_override` and would
publish an operator's manual preview as a stable release. Two execution tests
now cover the preview mapping and the malformed-version rejection; both were
run against the real arm before pushing.

R1-18: release.yml invokes these scripts by bare path, so the executable bit
is load-bearing, but every test invokes them as `bash <script>`, which
ignores the mode — and the workflow triggers only on schedule/dispatch, so no
pull-request lane runs the bare-path form. A mode-normalizing commit would
keep the suite green and kill `set-flags` with exit 126. The recorded git
mode is now pinned for all three scripts.

* test(release): pin the trusted prefix, the fork gate and the real version formats

R1-3: every arm pin elsewhere is a substring match that a bare
`.github/scripts/run-release-step.sh` invocation would satisfy just as well,
so nothing pinned the mechanism the extraction exists for. All 17 step
invocations are now required to carry the `.release-workflow/` prefix and
forbidden from naming the release-ref copy.

R1-9: `PUBLISH_AUDIO_CAPTURE` replaced a deleted step-level
`if: github.repository == 'QwenLM/qwen-code'` gate and, unlike its
`PUBLISH_EXTERNAL_CONTEXT_MEM0` sibling, had neither its gate expression nor
its true branch pinned — a fork running this workflow would publish
@qwen-code/audio-capture. Both halves are pinned now.

R1-14: the format gate is the guard's only genuinely new behaviour and its
accept path was exercised solely with '1.2.3', while the two scheduled
releases produce nightly and preview strings. Both formats are now asserted,
so tightening the pattern fails here rather than at 21:00 UTC.

* fix(release): keep failure reporting alive when the trusted runner is not

R1-11: `notify_failure` gained a checkout of `github.workflow_sha` as its
first step, and its whole body now lives in a shell script. At base the job
had no checkout at all and the notifier was inline, so the job whose only
purpose is reporting every other job's failure acquired two ways to fail
silently: a degraded git backend takes out the checkout, and a syntax error
or dropped exec bit in run-release-step.sh takes out every arm including
notify-failure. In both cases `Create Issue on Failure` carried the default
`if: success()` and was skipped — no issue, no autofix label, no dispatch,
and the scheduled autofix fallback scans issues, so nothing recovered it.

Rather than moving 105 lines of notifier back into the workflow — which the
orchestration guard in this PR forbids at 12 run-lines per step — the primary
path keeps the extraction and stops being load-bearing: it runs under
`always()`, absorbs its own failure, and a five-line inline fallback files a
plain issue whenever it did not succeed. The fallback depends on neither the
checkout nor the script, does no reuse, labelling or dispatch, and accepts a
duplicate issue as the cost of never going silent.

The workflow's line ceiling moves 800 -> 830 for those lines, with the reason
recorded at the assertion; the per-step run cap that keeps logic out of the
YAML is unchanged and still holds. Ratchet baseline and the PR description
move with the file.

* style(release): apply the pinned Prettier to the fallback notifier

Prettier 3.6.1 normalizes the quoting on the new `if:` and drops a
trailing space; the ratchet baseline follows the two-byte change.

* docs(release): name exit 4 where the guard's exit codes are described

Adding the malformed-version code left two descriptions behind: the retry
loop had no statement of the contract at all, and the test comment beside it
still said exit 0 and exit 3 were the only decisive outcomes.

* fix(release): avoid duplicate fallback failure issues

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: yiliang114 <jinjing.zzj@gmail.com>
2026-09-08 07:09:09 +00:00
易良
3ecfaffdf8
perf(dev): add pnpm worktree bootstrap foundation (#10449)
* perf(dev): add pnpm worktree bootstrap foundation

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

* fix(dev): harden worktree bootstrap process handling

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

* perf(dev): prefer cache-only worktree installs

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

* fix(dev): preserve cached install cancellation

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

* fix(build): harden pnpm worktree bootstrap

Validate pnpm lock updates in releases and exercise real installs and builds across supported hosts. Preserve npm release compatibility and keep dependency-only setup from rewriting npm-layout notices.

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

* chore: record merge attribution

Record the required attribution for the Stage 1 merge without rewriting published history.

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

* fix(dev): keep worktree bootstrap clean

Use a bootstrap-scoped notice guard because nested npm lifecycle commands replace npm_lifecycle_event. This preserves explicit notice generation while preventing dependency setup from rewriting tracked output.

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

* ci: track pnpm smoke workflow size

Register the new workflow in the repository size ratchet as required by the main CI gate.

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

* fix(ci): drop stale importers from the new pnpm lockfile

The lockfile was generated before the WebShell cutover (#9811) removed
@qwen-code/webui from web-shell and the tailwind tooling plus
@qwen-code/webui from vscode-ide-companion, so frozen-lockfile
installs fail on all three smoke platforms with
ERR_PNPM_OUTDATED_LOCKFILE. Drop the five stale importer entries so
the lockfile matches the current package.json manifests; verified with
pnpm 11.24.0 install --frozen-lockfile --lockfile-only.

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

* fix(ci): refresh pnpm lockfile after merging main

The merge of origin/main added remend@^1.3.1 to packages/cli/package.json
without updating pnpm-lock.yaml, breaking the pnpm Worktree Smoke workflow
frozen-lockfile install. Regenerated with pnpm install.

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

* fix(scripts): resolve the path variable case-insensitively on Windows

A spread of process.env is an ordinary object, and native Windows shells
expose the path variable as `Path`, so `env.PATH` was undefined there and
findOnPath never located corepack — the offline-first Corepack bootstrap
silently degraded to npx on exactly the hosts it exists for.

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

* test(scripts): pin the worktree bootstrap guards against mutation

- Split the fallback log on /\r?\n/ like the sibling cmd.exe mocks, so the
  assertion holds on the Windows lane's CRLF output.
- Add a win32 variant bootstrapping with the native `Path` casing to pin
  the case-insensitive lookup.
- Assert the smoke workflow's fail-fast flag, the install-before-clean
  step order, and the no-build guard via a substring on the raw job text.
- Extend the pnpmfile rewrite fixture to devDependencies and
  optionalDependencies, which the committed lockfile already uses.
- Assert the notice-skip guard by effect (writeFile never called) and add
  the flag-absent companion test with I/O stubbed.

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

* chore(deps): sync pnpm-lock.yaml with qwen-live ACP backend deps

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

* fix(dev): stop patch-package from corrupting the pnpm store

The root postinstall runs patch-package, which rewrites files under
node_modules in place. With pnpm's default import method those files are
hard links into the content-addressable store, so the patch rewrites the
store entry too and its contents stop matching the sha512 it is filed
under. Every subsequent fresh worktree then fails its --offline stage
with ERR_PNPM_NO_OFFLINE_TARBALL, silently falls back to the registry
(exit 0), and the fallback's postinstall corrupts the store again, so the
offline path never hits. The advertised offline timing was only reachable
on a same-tree reinstall, which needs no relinking.

Set packageImportMethod to clone-or-copy: a copy-on-write clone where the
filesystem supports it, a plain copy elsewhere. Either way patch-package
edits only the worktree's own copy and the store entry stays intact, so
later worktrees resolve entirely from the store.

Verified on an isolated fixture store: under the default method the
node_modules file and its store entry share an inode, and an in-place
edit moves the store file's sha512 off its address, after which a fresh
offline install fails with ERR_PNPM_NO_OFFLINE_TARBALL; under
clone-or-copy the same edit leaves the store hash unchanged and the fresh
offline install reports reused 1, downloaded 0.

Also add pnpm-lock.yaml to .prettierignore. prettier reflows it into a
shape pnpm does not emit, so `npm run format` and `pnpm install` fight
over the file; .yamllint.yml already ignores it for the same reason.

Both facts are pinned by tests in scripts/tests/package-scripts.test.js.

Claude-Session: https://claude.ai/code/session_012797rgiteWJxLT9TLkKq8G

* fix(ci): refresh pnpm-lock.yaml for qwen-live prompts deps

Main added prompts/@types/prompts (and the ACP sdk) to
packages/qwen-live/package.json after this branch generated its
lockfile, so the pnpm Worktree Smoke frozen-lockfile install failed
with ERR_PNPM_OUTDATED_LOCKFILE. Regenerated with pnpm 11.24.0
(--lockfile-only); frozen-lockfile and supply-chain policy checks pass.

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

* fix(pnpm): close review findings on worktree bootstrap

- Declare @qwen-code/qwen-code-core in vscode-ide-companion so the pnpm
  linker materializes it; regenerate both lockfiles and assert the link in
  the smoke workflow (R5-1)
- Pin @types/node 20.19.1 in packages/core so the pnpm layout compiles
  against the same types npm hoists (D6-1)
- Accept and pin the corepack +sha512 integrity suffix on packageManager;
  strip it for the npx fallback spec (D6-2)
- Cover every workspace member in the pnpmfile rewrite set, mirror the npm
  channel list in pnpm-workspace.yaml, and cross-check both in tests
- Run setup-worktree.js installs with cwd pinned to the checkout, gate the
  smoke clean-check on git status --porcelain, stop post-merge runs from
  cancelling each other, and extend check-lockfile.js to pnpm-lock.yaml

* merge(main): realign pnpm workspace set and lockfiles

Main removed the webui and cua-driver packages; drop both from the pnpm
rewrite set and regenerate the pnpm lockfile against the merged manifests
(picks up playwright, react-markdown, and the other main-side dependency
changes) so the frozen bootstrap matches what the PR merge ref will run.

* style(ci): quote smoke workflow scalars per yamllint

* fix(dev): require Corepack for pnpm bootstrap

* fix(dev): close pnpm bootstrap review blockers

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: yiliang114 <jinjing.zzj@gmail.com>
2026-09-08 03:07:40 +00:00
易良
f3d9fa59a4
fix(ci): isolate E2E fork test directories (#11312) 2026-09-07 16:14:24 +00:00
jinye
55926e4617
feat(serve): allow concurrent standalone daemons with session fencing (#11207)
* feat(serve): allow concurrent standalone daemons with session fencing

* fix(serve): address standalone ownership review feedback

Preserve writer-blocked navigation and queued drafts, retain Live admission diagnostics, and cover concurrent daemon and filesystem ownership boundaries.

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

* fix(serve): handle all writer fences and legacy publishers

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

* test(ci): sync standalone concurrency allowlist assertion

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

---------

Co-authored-by: jinye <3007091+doudouOUC@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-07 15:52:05 +00:00
易良
a1cbe75cf1
ci: run Linux E2E with Vitest forks (#11290) (#11306)
* ci: benchmark Vitest forks against E2E shards

* ci: prepare E2E fork configuration for review
2026-09-07 14:31:46 +00:00
易良
63578c7eb3
fix(ci): reap leaked E2E sandbox containers (#11264)
* fix(ci): reap job-owned E2E containers

* fix(ci): tolerate containers exiting during cleanup
2026-09-07 13:00:42 +00:00
顾盼
5014a091ab
chore(release): bump node repl to 0.1.3 (#11299) 2026-09-07 09:57:30 +00:00
易良
5ec29581a5
ci: give every workspace the shared-pool test timeout, not just five (#10915)
* ci: give every workspace the shared-pool test timeout, not just five

`npm run test:ci --workspaces` runs 22 vitest projects and they all land on the
same shared ECS pool, where an identical suite takes about 5x longer depending
only on which host it draws (#10490). Five of those configs were given a raised
ceiling there, one at a time, as each package's flakiness became somebody's
problem: cli, core, acp-bridge, web-shell and vscode-ide-companion. Three more
carry a generous flat value (node-repl 60s, sdk-typescript E2E_TIMEOUT_MINUTES,
scripts/tests 90s).

The remaining fifteen were still on vitest's 5000 ms default: all eleven
channels packages, audio-capture, chrome-extension, webui and both
external-context integrations. That is 99 test files and roughly 3170 cases
whose only timeout on a contended host was 5s.

The workaround pattern was already visible in the tree — four tests had been
hand-patched past the default individually, each in its own commit:

    integrations/external-context/src/auto-recall.test.ts           12s
    integrations/external-context/src/provider-extension-local.ts   20s, 30s
    packages/channels/base/src/ChannelBase.test.ts                   8s

Every one of those is a case that needed more than 5s, found the hard way. This
raises the ceiling once, at the lane, instead of one test at a time.

Uses the `? 60_000 : undefined` form that acp-bridge and web-shell already use
rather than cli/core's `? 60_000 : 15_000`, so the change is confined to the
pool: off it these configs keep vitest's own default and a genuine hang on a
developer machine still fails in 5s, not 60s. 60_000 is the value the five
already-raised configs settled on.

The parity sweep in scripts/tests/unit-vitest-configs.test.ts now covers all 22
configs plus webui's function-form vite.config.ts, so a new workspace cannot
quietly join the lane on the 5s default. It asserts a floor rather than
equality, which lets scripts/tests (90s) and sdk-typescript (180s) keep their
own numbers, and a companion case pins that the off-pool branch stays
`undefined`. Verified both ways: the sweep reports 60000 for every config under
an `ecs-qwen-*` stub, `undefined` off it, and fails when the ceiling is removed
from any single config.

No test, fixture or production code changes; a test can only get more time
than before, and only on the pool.

Prerequisite for quarantining the seven raw wall-clock budgets that live in
these packages (#10912): channels/base and channels/dingtalk guard quadratic
stalls at 1000 ms whose measured regression cost is ~4.1s, so under the old 5s
ceiling there was no room to relax those bounds for a contended host at all.

* fix(ci): close the shared-pool timeout pin's own coverage holes

The review found four ways the pin could certify a green the lane does
not actually have. Close each one in the pin itself:

- packages/qwen-live defines test:ci and already runs on the pool lane,
  but was absent from the swept map; register it (its 60s ceiling is
  unconditional, so it stays out of the off-pool sample).
- Run every on-pool assertion under two runner names sharing only the
  documented ecs-qwen- prefix, so a gate narrowed past the prefix fails
  instead of passing on the stub's own name.
- Stub QWEN_SCRIPTS_TEST_TIMEOUT_MS and E2E_TIMEOUT_MINUTES alongside
  RUNNER_NAME: scripts/tests and sdk-typescript derive their ceiling
  from those knobs, so the pin must test the configs' logic, not the
  ambient environment. The scripts-suite knob test's unset arm now
  deletes the variable for real (stubEnv undefined) instead of relying
  on the ambient value being absent.
- Extend the off-pool sample to every gated ternary (all fourteen this
  PR gates plus acp-bridge/web-shell, which carried the shape already,
  plus the webui function form), so a flat 60s in any of them fails.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq

* fix(ci): pin the unset-RUNNER_NAME state and correct the pin's comment

Add an unset-state arm to the shared-pool timeout pin: the off-pool test
only stubbed a foreign RUNNER_NAME, so a ternary treating a missing
variable as pool (`?? true`, `=== undefined || startsWith(...)`) passed
every test while developer machines — which run with RUNNER_NAME unset —
silently moved to the 60s ceiling. The new arm deletes the variable via
`vi.stubEnv(..., undefined)` and samples every gated ternary plus the
webui function form. Mutation check: channels/base mutated to `?? true`
fails only the new arm; restored, the suite is green.

Reword the comment above configModules: the pin does not replace the
hand-patched per-test timeouts (auto-recall 12s, provider-extension-local
20s/30s, ChannelBase 8s). A per-test timeout outranks the config-level
field and stays load-bearing off the pool, so the pin guards the
config-level ceiling only.

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

* test(ci): pin the shared-pool guard's own coverage instead of sampling it

Three holes in the guard added for the #10490 flake class, all in the guard
rather than in any config:

cli and core were kept out of the off-pool sample on the stated grounds that
they pin a lower value, but nothing asserted that value — flattening either
ternary to the pool number passed the whole file, and a real hang off CI would
have surfaced at 60s instead of 15s. Both off-pool arms now assert 15s.

Those two arms also carried verbatim copies of the same 16-entry sample, so a
newly gated workspace had to be registered in three places, and missing one
left it with a pool floor but no off-pool assertion. One shared list and one
helper now back both arms.

configModules was hand-maintained with nothing cross-checking it against the
workspace set, while `npm run test:ci --workspaces` runs whatever the root glob
resolves — so a new workspace with a test:ci script could join the lane on
vitest's 5s default with the guard green. The map is now checked against the
repo's own workspace resolver, one-directionally.

* refactor(ci): share the test:ci workspace selector between both suites

The shared-pool timeout cross-check re-implemented the test:ci workspace
selector that release-workflow.test.js already carried as
getTestCiWorkspaces: same getWorkspacePackageJsonPaths root glob, same
scripts?.['test:ci'] filter. Two copies of the selection could drift
and let the two suites pin different workspace sets.

Extract the selection once into scripts/workspaces.js as
getTestCiWorkspacePaths(root) and consume it from both tests; the
release-workflow site keeps only a pair adapter attaching the parsed
package.json its assertions read. Pure extraction with no behavior
change: both selectors resolved the same 23 workspaces before and
after.

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

* refactor(ci): rename selector to getTestCiWorkspacePackageJsonPaths

The selector returns workspace package.json paths, so carry the same
PackageJson qualifier as its sibling getWorkspacePackageJsonPaths
instead of making every caller privately know the entries end in
/package.json. Updates both consumers added in this PR.

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

* test(scripts): pin the test:ci workspace selector directly

Every consumer derives its workspace set from the selector, so a
filter mutation that drops a subset stays invisible to all of them.
Add an exact-set fixture pin next to the getWorkspacePackageJsonPaths
tests, with the with-ci workspace outside packages/ to mirror the
integrations/* layout. Verified red against both an over-inclusion
mutant (filter -> true) and a partial-drop mutant.

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

* test(scripts): make the selector pin and off-pool arms witness-bearing

Addresses the two low-severity coverage findings from the sandboxed
verify report (comment 5548150043), both measured open at this head:

- Finding A: the exact-set pin mirrored only the flat integrations/*
  layout, so a filter mutation dropping the nested packages/channels/*
  subtree stayed invisible to all three selector consumers (mutant S3
  survived 105/105). Mirror the nested layout in the fixture too:
  explicit packages/channels/* entries in the root workspaces list
  (packages/* does not match nested directories), one member with a
  test:ci script and one without. S3 now fails the pin, naming it;
  S1/S2/S4 stay caught.
- Finding B: OFF_POOL_DEFAULT and OFF_POOL_PINNED are hand-maintained
  and nothing cross-checked them, so dropping a name silently removed
  its off-pool assertion and a flattened ternary still passed the
  on-pool floor (mutants G5/G6 survived). Add a partition-
  exhaustiveness pin: every configModules entry must sit in exactly
  one off-pool arm and every arm entry must exist in the map. Both
  mutants now fail it.

No production logic changed; pins and fixtures only.

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-07 09:02:39 +00:00
易良
dd5982aa23
ci: enrich deferred-findings tracking issues with PR context and assignment (#11080)
* ci: enrich deferred-findings tracking issues with PR context and assignment

The autofix loop's deferred-findings upsert files one tracking issue per
PR for verified review findings whose fixes lie outside that PR's
footprint. The issue it created carried a constant title, a bare
boilerplate body, and no assignee, so a maintainer looking at the issue
could not tell what it was about or who should follow up — over the past
week that produced ~100 issues that read as scattered noise.

The create path now fetches the PR once and makes the issue
self-describing: the title carries the PR title, the body names the PR
context (number, title, author), cc's the author, and every rc bullet
deep-links to its original review comment. The issue is assigned to the
PR author at creation; external contributors are not assignable, so the
create retries once without the assignment rather than losing the
findings, and the cc in the body is what actually reaches them. All of
it is best-effort: a failed PR fetch degrades to the previous bare
title and no assignee — metadata must never lose findings.

The title is also the lookup's fallback anchor when a maintainer edit
drops the body marker. It now accepts both the bare form and the
enriched "base: <PR title>" form, guarded by the colon so PR #5's base
never prefix-matches PR #50's issue. The rc link suffix is safe on
identity because rc dedup is id-anchored; rv/ic bullets deliberately get
no link — their cross-round identity IS the rendered line, and a suffix
would render every already-persisted rv/ic item as new, a one-time
duplicate wave over the existing tracking issues.

The PR title is API-derived content published under the bot identity,
so it receives the same mention/comment-opener neutralization as the
reason rendering (canonical spelling, which moves the script's escape
census pin from 1 to 2), and its cap slice happens in jq so a CJK title
cannot be byte-cut under a C locale.

Tests: the recording gh stub learns the pulls endpoint and an
assignment-failure injection; new cases cover the degraded create, bot
authors, the assign fallback, enriched-title adoption, the number-prefix
collision, and title neutralization. The upsert test spawns ~70
subprocesses, so it gets an explicit 30s bound like its spawn-heavy
neighbors instead of flaking on exec-scanning hosts.

* fix(ci): create the deferred-findings issue once, assign separately

Address review round R1 on the deferred-findings tracking issue.

R1-1 (Critical): the assignee-less retry re-POSTed the non-idempotent
issue create. GitHub silently drops assignees on POST
/repos/{owner}/{repo}/issues for users without push access, so for the
external contributors the retry was named for, the first create returns
201 and the retry is dead code. The failures that DO reach it are the
ambiguous transport ones (connection reset, gateway 502, a read timeout
after the server already committed), where the re-POST mints a second
tracking issue carrying the same marker: the next round's newest-first
lookup adopts the newer one and the first is orphaned forever, publicly
duplicating every finding while the round logs clean success. That is
against this file's own rule that creating a duplicate is worse than
deferring persistence one round. Create once, unconditionally, then
assign best-effort with a separate idempotent call whose failure only
warns — the body's cc mention is what actually reaches an external
contributor.

R1-7 collapses by construction: the create call is now spelled once
instead of three times, and the assignability condition is evaluated once
(ASSIGNABLE, set alongside CC) instead of twice.

R1-2 (Critical): dropped the re-added per-test 30s timeout on this file's
heaviest case. scripts/tests/vitest.config.ts configures testTimeout
90_000 precisely because this case (~14s idle, and heavier in this diff)
exhausted 30s on contended release runners, and 93e1597b7e (#10870)
removed this exact cap two days ago. Each spawnSync already carries its
own 30s child timeout, which bounds the only real hang risk.

R1-8: pinned the PR-title neutralization, which had no test on any stage —
deleting the entity escape shipped green while a fully
contributor-controlled title published a live @-mention under the bot
identity (GitHub decodes &#64; before its mention filter). One case now
pins the entity escape, the [\r\n\t] flatten and the .[0:80] slice.

R1-9: pinned the ic side of the deep-link exemption. Only rv witnessed the
"no suffix for review/issue_comment" invariant, so widening the condition
to issue_comment shipped green while re-rendering every already-persisted
ic item as new — the one-time duplicate wave the script's comment warns
against.

Verified: npx vitest run --config ./scripts/tests/vitest.config.ts
scripts/tests/qwen-autofix-workflow.test.js -> 225 passed | 4 skipped.
Each new assertion reds under its own mutation: retry shape restored,
title entity gsub / flatten / slice deleted, suffix widened to ic.

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

* fix(ci): warn when the deferred-findings PR-context fetch fails

The pulls-endpoint call was the only gh call in upsert-deferred-issue.sh
whose captured stderr nobody read: `|| true` swallowed the exit status and
the `gh_err_reset` before the create call wiped the reason, so a systematic
fetch failure — a fine-grained PAT rotated without pull-requests:read, a
rate limit, a persistent 404 — silently reverted every new tracking issue
to the bare title / no assignee / no cc while the success line still
printed. That is against this file's own header contract (every failure
path warns and exits 0) and the GH_ERR design note (these warnings are the
feature's only signal): every sibling call routes its reason into a
`gh_reason`-bearing warning.

The gate is the call status, not a body field — `jq -e '.number'` would
warn on every healthy round whose PR JSON carries no `.number`. One
non-blocking warning, read before that reset so the create-failure warning
below still reports its own reason; the bare-title degradation and the
never-fail-a-round contract are unchanged.

Pinned in the existing prFetchFailed case (warning text, reason routed
through gh_reason() so the `::` payload arrives neutralized, no raw
`::error::` on stdout) plus a no-false-warning assertion on the healthy
create path. Mutations observed red, then restored: delete the warning,
read raw ${GH_ERR}, and swap in the `.number` gate.

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

* fix(ci): keep the enriched tracking-issue title plain-text and pin the create-path guards

Address review round R2 on the deferred-findings tracking issue.

R2-1: the one neutralized PR_TITLE was published to two surfaces with
different renderers. The issue TITLE is stored and rendered as plain
text — no markdown pass, no mention filter — so the entity/@/comment-
opener stages protect nothing there and only corrupt the string this
change exists to make readable (a ZWSP inside `@types` on a routine
dependency-bump title, visible `\-\-` backslashes). Derive two variants
from the one fetch: PR_TITLE_RAW (flatten + cap only) for CREATE_TITLE,
and the fully neutralized PR_TITLE for the markdown-rendered body copy.
The gsub("<!--") census stays at 2 — the raw variant adds no site.

R2-2: the PR_AUTHOR login-charset guard had no witness — deleting it
shipped green while a malformed `.user.login` flowed into both the
deliberate `cc @…` mention in the public body and the `assignees[]`
argument. New badLogin case.

R2-5: the `-n "${NUM}" &&` half of the assign guard had no witness —
dropping it let the create-failure path POST repos/…/issues//assignees
and print a could-not-assign warning immediately before the LOST
warning. Pinned on the existing writeFail case.

R2-3: the enriched-title format was written at creation and re-spelled
220 lines away at the marker-less adoption lookup, each side pinned by
its own hand-typed literal — a one-sided restyle (": " → " - ") left
the suite green while production forked a second tracking issue. Added
a round-trip case that derives the adoption fixture from the recorded
create call, plus a comment at the create site naming the lookup as the
separator's consumer.

Mutation probes observed red, then restored: title reverted to the
escaped variant; body copy swapped to the raw variant; body-chain
flatten deleted; charset guard deleted; NUM guard half dropped;
separator restyled on the create side (including the maintainer
scenario — every hand-typed create-side literal updated, round-trip
still reds) and on the lookup side.

Verified: npx vitest run --config ./scripts/tests/vitest.config.ts
scripts/tests/qwen-autofix-workflow.test.js -> 229 passed.

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

* fix(ci): name the unusable PR-context fetch and pin the guards' remaining shapes

Three review findings on this PR:

- R3-1 (.github/scripts/upsert-deferred-issue.sh): the degradation warning was
  gated on the gh call status alone. This fetch passes no --jq, and gh copies a
  non-JSON body raw with serverError set only above status 299, so a transparent
  proxy answering `200 text/html` exits 0 while all three derivations come back
  empty through their `|| true` - the bare title / no cc / no assignee
  degradation behind a clean success line, which is exactly what the warning
  exists to name. Extend the gate to "the call failed OR both derived strings
  are empty" and name that second case in the message, instead of letting
  gh_reason() read "no stderr captured" for a call that succeeded. Keying on
  both strings being empty cannot false-positive on a real PR object (a
  whitespace-only title still flattens to a non-empty " ", a deleted author
  leaves the title), where a `.number`-presence gate would warn on every
  healthy round.
- R3-2 (scripts/tests): nothing pinned the fetch's ABSENCE on the append path,
  so hoisting the 4-line fetch unit above the create/append branch shipped green
  while costing an authenticated GET /pulls/N against the bot PAT's rate limit
  on every steady-state append round - the path that runs most often - instead
  of once per tracking issue's lifetime. Add the negative half to the existing
  `appended` case, which already pins the append-only shape.
- R2-2 (scripts/tests): `badLogin` exercised only the space shape of the four
  malformed-login shapes its own comment declares the charset guard covers, so
  widening `{1,39}` to `{1,}` or admitting `@` into the class both shipped green
  while a 40-character or `@`-bearing login flowed into the deliberately
  un-defused public `cc @...` mention and into an argv element. Add one fixture
  per remaining shape.

Mutation-verified against the whole file: dropping the gate's second half,
dropping the exit-0 reason wording, hoisting the fetch unit above the branch,
and each of the two charset widenings reds the suite (1 failed each); intact
tree is 225 passed / 4 skipped. shellcheck note count goes 10 -> 9, since
$(gh_reason) moved out of the masked echo into a plain assignment.

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

* fix(ci): keep the contributor-controlled PR title out of the markdown body

R1-5: the enriched tracking-issue body carried a second copy of the PR
title, on the one sink that renders markdown. A PR title is fully
contributor-controlled and no enumerated escape chain closes that
surface: a markdown link and an image beacon pass the mention / entity /
comment-opener stages byte-identical and render as a live clickable URL
under the bot identity, and an unclosed <details> makes a real HTML5
parser nest the findings <ul> inside it, so the whole human-facing
surface collapses behind an unlabeled fold while the round still prints
its clean success line. Because the dedupe corpus reads the RAW body, the
hidden items then count as already tracked and are never re-published.

Drop the body copy rather than add another escape stage: PR #N auto-links
on its own and the enriched issue title already says what the PR was
about, so the copy bought nothing. This retires the title's four-stage
neutralization chain entirely (one jq comment-opener escape site remains,
the line-builder one, so that census drops 2 -> 1 deliberately), and the
two title pins now assert that no spelling of the title reaches the body.

Also pin the degradation gate's second half (R3-1 fix-induced): the
title-present / author-rejected state is the only input that distinguishes
its && from an ||, and nothing asserted the warning's ABSENCE there, so
flipping the operator shipped green while announcing a bare title on a
round that in fact enriched it.

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

* fix(ci): point the deferred-findings ready-for-agent hint at the per-item issue

The tracking issue this script creates is assigned to the PR author right
after creation, and the scheduled ready-for-agent scan filters
`no:assignee` (AUTOFIX_ISSUE_EXCLUDES in qwen-autofix.yml), so an issue
carrying an assignee never re-enters that scan. The body still invited
readers to "apply the ready-for-agent flow" without saying to which issue,
which reads as an invitation to label the tracking issue itself - the one
place where a label-event run cancelled by the per-issue concurrency group
has no scheduled scan left to retry it.

Attach the flow to the per-item issue a human files from this list instead:
that issue is unassigned, so it keeps the scan backstop. Pinned per call in
the upsert case; mutation-verified red when the old parenthetical is
restored.

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

* fix(ci): pin the never-retried deferred-findings create and correct the assignment comments

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-09-07 05:45:22 +00:00
Shaojin Wen
21e1133514
feat(ci): surface the resolved model and CLI version in autofix round reports (#11124)
* feat(ci): surface the resolved model and CLI version in autofix round reports

The round-report footers could only name the REQUESTED model — the repo
variable, or 'default' when unset — so diagnosing a round (which model
produced it, on which CLI build) meant downloading the run artifact and
digging through agent.log. The CLI already reports both in its stream-json
init event; run-agent.mjs now records them to agent-model on every settle
path — a crashed or timed-out round is exactly when the footer needs to
name the model that died — and the three report footers (push/no-op,
handoff, issue-lane e2e report) prefer those values over the configured
one, falling back to it when the file never landed. WORKDIR is
agent-writable, so both read sites allowlist the values (the version's
allowlist is the review footer's FOOTER_VERSION_RE shape) and cap their
length before a bot-authored comment renders them; a forged file degrades
to inert mojibake, never markup or a control marker. The repair step
deletes the sentinel with its siblings so run 2 cannot inherit run 1's.
The eval-marker grammar is untouched — six jq scan regexes anchor on it,
and the footer rides the same comment, so per-round attribution stays
greppable.

* fix(ci): harden the agent-model sentinel reads and gate them with executed tests

Addresses the round-1 review on #11124. The sentinel reads ran under
-eo pipefail in PAT-bearing steps but assumed a readable regular file:
a planted directory passed the old size test and killed the step at its
preamble (losing the push and the round report), a planted FIFO wedged
it until the job timeout, and a planted symlink pointed the read at an
arbitrary host file whose head rode into a public comment. All three
sites now take a regular file only, never a symlink, read through
bounded head -c prefixes so sed cannot be made to buffer a forged huge
line, and end every pipeline in || true so any read failure degrades to
the configured model instead of aborting the step. The repair step's
sentinel deletion list gains the same tolerance: rm -f exits non-zero on
a planted directory, which previously aborted the step AFTER
attempted=true was written, silently costing the round its repair.

Tests: the read block is now replayed as REAL bash under GitHub's own
flags against benign, hostile, binary, directory, symlink and FIFO
fixtures — gating the preference ordering (a default assignment moved
below it reverts every footer to the requested model) and the fail-safe
shapes together; the writer's two-line invariant (newline flattening,
length caps) is exercised through a newline-bearing oversized init
event; and the repair deletion and artifact-dump lines gain the pins the
review found missing.

* test(ci): gate all three footer-block copies and both writer invariants

Round-2 review on #11124 measured two surviving mutants. The executed
replay extracted only the canonical script copy, so reordering the
default assignment in either workflow copy — a plausible slip when
hand-editing one of three duplicates in a 6.9k-line file — stayed green
while that copy's footer silently reverted to the requested model; the
replay now also asserts each workflow copy's CODE lines equal the
canonical block's, modulo the ten-space indent and the shorter comment,
in the same order. The flattening fixture parked the model's newline at
index 250, past the writer's 200-char cap, so dropping the flatten
while keeping the cap produced identical output and survived; the test
now runs two fixtures — a newline inside both caps to kill split-drop
mutants on both fields, and values exceeding both caps to kill
slice-drop mutants on both fields.

Measured: both workflow-copy reorders and all four writer mutants
(split/slice × model/version) now fail the suite; the intact tree stays
green (230 passed, 4 skipped).

* fix(ci): align the agent-model writer caps with the published reader caps

The sandboxed verification on #11124 measured the gap this leaves: the
runner accepted 200 chars of model and 80 of version while every read
site publishes at most 100/40, so a legitimate value between the two
bounds was written in full, silently clipped on read, and posted as
accurate attribution — a 154-char model published as 100, exit 0, no
warning anywhere. The writer now caps at exactly the published bounds,
and a contract test pins the two pairs equal, plus the head -c windows
wide enough to cover the largest sentinel the writer can emit, so
neither side can drift alone. The caps fixture moves to the new bounds
in the same commit.

Measured: writer-cap, reader-cap, slice-drop and window-shrink mutants
all fail the suite (4/4 killed); intact tree green (231 passed,
4 skipped).

* test(ci): bound the agent-model head windows in bytes, per read

Round-3 review on #11124: the window guard computed the writer's
maximum sentinel as caps + 2 = 142 bytes, but the caps count UTF-16
code units while writeFileSync encodes UTF-8 and head -c bounds bytes —
a BMP code unit occupies up to 3 bytes (surrogate pairs fold to 2 per
unit, lone surrogates encode as the 3-byte replacement char), so the
real maximum is 422 bytes and the assertion was ~3x looser than the
invariant its own comment claimed. Shrinking the model window from 400
to anything in [142, 300] kept the suite green while head -c cut line 1
mid-value and the footer published a truncated model prefix — the exact
failure this test exists to prevent. Measured: that mutant passed
before this change and fails after (300 < 301), and 400 -> 301 stays
green on the inclusive bound. Each window is now bounded against the
line it actually reads — the model read needs line 1 plus its LF, the
version read needs the whole two-line file for sed -n '2p' to see
line 2 — and the bound stays split per read because applying the
422-byte whole-file figure to the model window would fail it (400) for
a line it never reads.

* fix(ci): open the agent-model sentinel non-following and non-blocking

Round-4 review on #11124 (Critical): the sentinel write was a plain
O_WRONLY|O_CREAT|O_TRUNC open of an agent-writable path, executed in
finish() AFTER every watchdog has been disarmed. A FIFO planted at the
path — creatable from inside the sandbox, which bind-mounts the host
tmpdir rw as the same uid — blocks the open forever with no timer left
to kill it: measured, the runner never returned, burning the address
step's 130-minute cap and, on a wedged repair pass, enough of the job
budget to cancel the always() reporters into a silent round. A planted
symlink was worse than a hang: O_TRUNC followed it and replaced a host
file's content while the round exited 0 and reported success.

The write now opens O_WRONLY|O_CREAT|O_TRUNC|(O_NOFOLLOW ?? 0)|
O_NONBLOCK — af-053's rule for agent-writable paths, the shape
run-ledger.ts's noFollow writes use: the symlink becomes ELOOP, the
readerless FIFO ENXIO, a directory EISDIR, all landing in the existing
best-effort catch, so the sentinel stays optional and no write failure
escapes into the close handler. O_NOFOLLOW is undefined on Windows and
folds to 0; the sentinel contract (two newline-terminated lines within
the 100/40 caps) is untouched and its pins stay green.

Tests: FIFO and symlink plant arms beside the writer tests. The FIFO
arm bounds its own spawn (45 s SIGTERM) instead of the shared unbounded
helper — measured, a flags-removed runner blocks the vitest worker
thread synchronously, the 90 s suite testTimeout cannot fire on it, and
the file hangs past 10 minutes instead of going red; the bounded spawn
turns the same mutant deterministically red at ~45 s. Mutation battery:
all flags dropped / O_NOFOLLOW dropped / O_NONBLOCK dropped — 3/3
killed; intact tree green (240 passed / 4 skipped).
2026-09-07 05:12:54 +00:00
易良
5b470befa0
ci: make the Prettier lane a real gate (#11117)
* chore: format the 35 files CI had been silently rewriting

`scripts/lint.js --prettier` and the release workflow's `Format Project`
step both run `prettier --write`, which reformats in place and exits 0.
Neither is followed by a dirty-tree check, so the rewrites are discarded
when the job ends and these files have stayed unformatted on `main`.

This is the output of `prettier --write` over exactly those files, so the
next commit can turn the check into a real gate without failing on a
backlog. No behaviour change: every hunk is whitespace, quoting or
wrapping.

Verified with prettier 3.6.1, the version pinned in package-lock.json.
`prettier --check .` over the whole repo reports these 35 and nothing
else.

Refs #11109

* fix(ci): make the Prettier lane a real gate

`runPrettier()` ran `prettier --write .`. Prettier exits 0 after
reformatting, and no caller checks the tree afterwards, so the lane
passed on unformatted code and CI threw the rewrites away — the 35 files
in the previous commit are what it had been silently fixing and
discarding.

ESLint does not cover the gap: eslint.config.js loads
eslint-config-prettier, which disables formatting rules rather than
enforcing them. With this change the only enforcement point outside the
husky pre-commit hook actually enforces something.

`npm run lint:all` now reports unformatted files instead of fixing them;
`npm run format` is the fix. Prettier's own failure message says so.

Refs #11109

* fix(release): drop Format Project, it writes and discards

The step ran `npm run format` (`prettier --write .`) and nothing in
release.yml inspected the tree afterwards — no `git diff --exit-code`, no
`git status --porcelain` anywhere in the file. The rewrites died with the
job, so the only failure it could produce was Prettier crashing on an
unparsable file, which the Run Lint step reports anyway.

It cost 13m58s on a quiet host and 20m45s on a contended one, against a
30-minute cap that `quality_static` hit twice today (runs 33957952281 and
33963757913, both cancelled mid-lint with nothing failing). The remaining
steps are `npm ci` (1m44s) and `lint:ci` (~8m20s), so the job now has real
headroom instead of finishing 24 seconds over the wall.

Formatting is now gated where it belongs: the previous commit makes the
PR-CI Prettier lane `--check`.

Refs #11109

* fix(ci): keep prettier --check out of .qwen/ and the pre-paint theme script

Two things the `--write` → `--check` flip broke that `--write` had hidden.

`.qwen/` is gitignored as `.qwen/*` and holds machine-written state —
pending-skills/, auto-skill-*/, learned-skill-*/,
computer-use/installed.json. Prettier does not read .gitignore, so it was
walking in and silently reformatting whatever a contributor had there;
under `--check` that turns into a failed `npm run lint:all` on files git
does not even track. CI never saw it (fresh checkout, tracked files only),
which is exactly why it went unnoticed. Adding `.qwen/` to
.prettierignore also drops the one tracked file under it —
skills/find-simplifications/SKILL.md, force-added past .gitignore — from
the previous formatting commit, since a directory the gate should not
police should not be reformatted for it either.

The inline theme script in web-shell's index.html is restored to its
original single line behind `<!-- prettier-ignore -->`. It is a
render-blocking script in <head> that applies the stored theme before
first paint; the expanded form ships ~600 more bytes on the critical path
and buys no readability the comment above it does not already give. A
change about CI hygiene should not be reshaping that.

Refs #11109

* fix(ci): keep prettier --check out of vendored packages/mobile-mcp

eslint.config.js has ignored `packages/mobile-mcp/**` since it landed —
"vendored mobile-next/mobile-mcp; has own eslint config" — and
.prettierignore already carries its sibling `packages/cua-driver/`, listed
one line above with the same rationale. mobile-mcp was simply missed.

`--write` hid that: it reformatted the vendored package's own eslint
config to our house style every run and threw the result away. The
formatting commit in this PR would have made that permanent, widening the
diff against upstream on the next sync for a package this repo has already
decided not to lint. That file is reverted here and the directory is
ignored.

Same class as the .qwen/ fix in the previous commit: the gate reaching
somewhere the repo had already decided it should not.

Refs #11109

* fix(ci): mirror eslint's remaining global ignores in .prettierignore

`--check` makes .prettierignore load-bearing in a way `--write` never was:
under `--write` a file prettier should not touch was reformatted and the
result discarded, so nobody noticed; under `--check` it fails the lane.

These three are the rest of eslint.config.js's global ignores, added for
the reasons it already states. `scripts/codemod/fixtures/` is the one that
matters: those files are "intentionally non-idiomatic ink input/output",
so the obvious response to a `--check` failure there — reformat to house
style — would corrupt the fixtures the codemod tests assert against. The
trap is not live today (all three currently satisfy prettier), which is
precisely why it is worth closing before someone meets it.

Refs #11109

* chore: format converter.test.ts, which drifted onto main since this branch

This branch merged main 32 commits after its base and `prettier --check .`
found one new offender. It is the same gate failing the same way: nothing
enforces formatting, so `--write` reformatted this file on every CI run
since it landed and threw the result away.

Worth noting as evidence rather than noise — the backlog this PR fixes was
not a one-off from before someone got careless; it accrues continuously,
and it accrued again while the PR was open.

Refs #11109

* Revert "fix(release): drop Format Project, it writes and discards"

This reverts the release.yml half of this PR. #11195 removes that step on
its own, in one file and four lines, and two PRs racing on the same
deletion helps nobody.

The reasoning and the evidence are unchanged and now live on #11195: the
dry-run release dispatched from this branch with the step removed
(34022919438) went 18/18 green in 65m43s, quality_static finished in
7m00s against main's 8m57s, and Run Lint was 4m52s vs 4m43s — nothing
downstream ever read what the step produced.

Dropping release.yml from this PR also widens who can review it. It was
the only file here owned by @pomelo-nwu and @wenshao alone; what remains
under CODEOWNERS is /packages/core/, whose owner list is six people. The
Prettier gate no longer waits on the two maintainers who must sign off on
release workflow changes.

Refs #11109, #11195

* fix(ci): make the gate resolve the file set npm run format resolves

Two review findings, one root cause: the gate was checking a different set
of files than the tree it is supposed to police.

**The `.qwen/` ignore rested on a false premise (R1-1, @chiga0).** Prettier
3.6's `--ignore-path` already defaults to `[.gitignore, .prettierignore]`,
so every machine-written path that entry's comment named —
`pending-skills/`, `auto-skill-*/`, `learned-skill-*/` — was outside the
gate before it via `.gitignore:32`. What the entry actually excluded is the
corpus `.gitignore` deliberately re-includes: `.qwen/commands/**`,
`skills/**`, `agents/**`, `team-memory/**`, `review-context.json` — 57
tracked, hand-authored files. The gate then passed because the surface had
been narrowed, not because the tree was formatted, and contributors editing
those docs could ship them unformatted with both enforcement points silent.
The entry is dropped and the first commit's formatting of
`skills/find-simplifications/SKILL.md`, reverted alongside it, is re-landed.

**The gate and the remedy used different CLIs (R1-2).** `runPrettier()` ran
the classic `prettier --check .` while `npm run format` runs
`prettier --experimental-cli --write .`. The classic CLI reads only the
repo-root `.gitignore`; the experimental one also honours nested ones, and
this repo has 23 of those. Verified on a scratch tree: given
`pkg/.gitignore` containing `lib/`, classic reports `pkg/lib/nested.js` and
experimental skips it. So the lane could go red on a locally built package's
output that `npm run format` provably cannot clear. The gate now uses the
same CLI as the remedy it names.

Refs #11109

* fix(ci): let the pre-commit hook cover what the gate now checks, and pin it

**The hook was narrower than the gate (R1-5).** `lint-staged` formatted only
`*.{js,jsx,ts,tsx}` and `*.{json,md}`, but the gate checks every extension
Prettier has a parser for. Of the 35 files the first commit formatted, 29
(83%) are in classes the hook never touched — 13 `.yml`, 12 `.mjs`, 3
`.css`, 1 `.html`. Left alone, the backlog this PR just cleared would
re-accumulate, and now as a red CI step on someone else's PR rather than as
silent drift. `.mjs`, `.cjs`, `.yml`, `.yaml`, `.css` and `.html` join the
Prettier-only group; ESLint's globs are unchanged, since the gap is about
formatting coverage, not lint coverage.

**Nothing pinned the flag (R1-4).** `scripts/tests/lint.test.js` exercised
only the linter-directory helpers, and `ci-platform-lanes.test.js` asserts
that the `Run Prettier` step exists and is profile-gated, not what it runs.
A refactor, or a well-meant "make lint auto-fix again", could restore the
silent pass with every suite green. Two assertions now pin it: that
`runPrettier()` checks rather than writes, and that it uses the same
Prettier CLI as `npm run format`, so the two cannot drift apart again the
way R1-2 found them.

Refs #11109

* docs: correct the instructions and comments the --check flip falsified

**Three tracked instructions to automation agents now say the opposite of
what the code does (R1-9), and one is fed to a CI lane today** —
`.github/workflows/qwen-triage.yml` re-pins `verify-pr` from the base commit
and hands it to the verifying agent.

- `verify-pr/SKILL.md` told agents never to run `scripts/lint.js` with no
  arguments because it would rewrite the working tree under their A/B
  harness. That is no longer why. The prohibition is kept — it is still
  right — but re-grounded in what the no-arg form actually does now
  (`setupLinters()` wipes the linter temp dir and re-downloads three pinned
  binaries), and `--prettier` joins the enumerated non-mutating checks it
  was the only flag missing from.
- `find-simplifications/SKILL.md` and its `references/land.md` told the
  simplification agent that a formatting diff "carries zero signal" because
  CI runs `--write`. An agent believing that would skip `npm run format`
  after a deletion changed line shape — on the very extensions the hook does
  not cover — and go red on formatting it was told could not matter.
- `update-ecs-runner-qwen-workflow.test.js`'s comment justified parsing two
  matrix layouts by "CI runs `prettier --write .` before this suite". CI no
  longer rewrites anything; the two layouts are still worth parsing, so the
  comment is re-grounded in the checked-in form versus older branches.

**The index.html rationale overstated its own number (R1-8, @chiga0).** It
claimed the expanded script ships "~600 more bytes"; measured against this
repo's Prettier 3.6.1 and its `tabWidth: 2`, it is +258 source and +8
gzipped here, +287/+20 by the reviewer's method — either way roughly half
what was claimed, and "~600" is what the same expansion costs at
`--tab-width 4`. Since the prose ships verbatim inside the built
`index.html`, the fix is to drop the contested figure and keep the comment
short. The `<!-- prettier-ignore -->` guard itself stays: it is load-bearing.

Refs #11109

* chore: format classify-profile.test.mjs, drifted again after commit 1

Third instance in this PR's lifetime, and the sharpest: commit `b1b94a8762`
formatted this exact file as one of the 35, and `main` reintroduced
unformatted content in it within a day. The other two were
`converter.test.ts` at the first merge and this file at the second.

The gate is the fix; until it lands, every merge from `main` re-opens the
same hole.

Refs #11109
2026-09-07 00:44:01 +00:00
jinye
f5ce3ea8fd
fix(ci): give web-shell E2E Smoke the ECS timeout allowance its siblings have (#11212)
Four jobs route to the self-hosted pool through the same `ubuntu_runner`
expression. Three of them scale `timeout-minutes` for `ecs-qwen` — test 120/60,
lint_and_static 90/45, integration_no_ak 60/30 — and `lint_and_static` carries
a comment explaining why: the pool's contention, with hosted runners kept on the
tighter ceiling so a genuine hang there does not burn the ECS allowance.

`web_shell_e2e_smoke` was the fourth, and the only one still priced flat at 20.
It lands on the same hosts under the same contention and got none of that
allowance, so it was beheaded twice on a single commit that changes nothing it
tests: on hk3-9 `npm ci` alone ran 19m46s of the 20 and never finished, and on
hk4-23 install took 10m33s against 4m53s on hk5-2, leaving the browser smoke
8m15s before the job died against 2m29s warm.

Both phases running 2-3x is the pool rather than the dependencies. A cold npm
cache would explain a slow install but cannot explain the smoke phase, which is
a vite dev server plus a headless browser and touches no cache at all. The
healthy path is 8-10 minutes, so a flat 20 only ever tolerated a 2x slowdown —
exactly what the contended hosts deliver, and the per-host medians already
measured in #10879 (hk3 34.6 min, hk4 27.5, hk5 12.3) say which attempts were
going to die before they started.

40 gives the ECS path the same 2x its siblings use and covers the measured
worst case with room for a 3x; hosted keeps 20 for the reason lint's copy gives.

The lane was not covered by the guard that exists for exactly this regression.
`ci-platform-lanes.test.js` already evaluates the real timeout expression for
both routings so that, in its own words, "a regression to an unconditional
ceiling fails here instead of reading as a passing constant" — but it pinned
only the three siblings. Add this one on both routings.

Three mutants applied, all killed by exactly the intended test: reverting to the
flat 20, reducing the ECS allowance to 30 (under the measured worst case), and
raising it to a flat 120, which the hosted-ceiling test also catches.
2026-09-06 23:25:41 +00:00
Tianyuan
92a8a8d179
fix(skills): register frontmatter hooks on the /<skill-name> path (#11068)
* fix(skills): register frontmatter hooks on the /<skill-name> path

A skill's frontmatter `hooks:` were registered only by `SkillTool`, the
model-invocation path. The user-invocation path — `SkillCommandLoader` and
`BundledSkillLoader`, reached by typing `/<skill-name>` — injected the skill
body and granted its `allowedTools`, but never registered its hooks.

A `PreToolUse` gate is the one mechanism that enforces a skill's rule
regardless of what the model decides to do, so users reach for it exactly
where a model mistake is expensive. Dropping it on the slash-command path
made that gate fail open, silently: nothing in the UI or the logs indicated
that a declared hook had been skipped.

Extract the side-effect application into `applySkillSideEffects` in
`skill-utils.ts`, alongside the existing `applySkillAllowedTools` and
`canApplySkillSideEffects`, and route all three call sites through it. Both
underlying registrations already dedup, and project skills stay trust-gated
on every path. Bundling the two side effects behind one helper also stops a
future invocation path from applying half of them.

Documents the `hooks:` frontmatter in the Skills guide, which had no user
documentation for it, making the deterministic path hard to discover.

Tests: unit regressions on both loaders (the two positive cases fail without
this fix), plus an end-to-end parity test driving the real interactive CLI
over a PTY, asserting the gate blocks the shell call on both paths.

Fixes #11067

* fix(skills): address review — sandbox reachability, CI coverage, resume path

R1-1: the parity test bound the fake server to loopback and hand-stubbed
NO_PROXY, so under the docker/podman sandbox legs the containerized CLI could
not reach it and both cases failed. Use `fakeServerHostOptions()` and
`applyContainerSandboxNoProxy()` like every sibling fake-server test; both are
no-ops outside a container sandbox.

R1-2: the test sat outside every suite that gates a PR, so a regression would
only surface post-merge. Add it to `test:integration:no-ak:sandbox:none` (it
needs no credentials) and to the pinned list in the CI wiring guard test.

R1-4: session hooks are in-memory only, so `--continue` / `--resume` restored a
skill's body without its side effects — and the dedup guard answers "already
loaded in context", so nothing prompted the re-invocation that would have
re-registered them. The gate silently stopped firing after a resume: the same
fail-open shape this PR fixes. `restoreLoadedSkillsFromHistory` now re-applies
side effects per restored skill, through `applySkillSideEffects` so the
folder-trust gate still applies. Documentation says so.

R1-5: restore the trace the refactor dropped. Sessions that disable hooks
(`disableAllHooks`, safe mode, bare mode, ACP `skipHooks`) build no hook
system, so a skill's gate is discarded — previously with a debug line naming
`hasHookSystem: false`, now silently.

R1-6: cover the guard branch. It is reachable in hooks-disabled sessions, and
deleting it would throw on `undefined` and crash every skill invocation there,
with nothing going red.

R1-3: `[hook](hooks)` 404s on GitHub — the target is `hooks.md`, and no
docs-site generator resolves extensionless links.

Each fix was verified to red when the fix is removed.

* fix(skills): don't re-arm disabled skills on resume; correct the docs claim

Both problems were introduced by the resume repair added in the previous
round, and both have the shape this PR exists to close: an operator believes
a gate is armed when it is not.

R2-1: `restoreLoadedSkillsFromHistory` applied side effects for every matched
skill with no enabledness check. `applySkillSideEffects` gates only folder
trust, and the candidate map comes from `getCachedSkills()`, which does not
consult `skills.disabled` — so invoke-skill → disable → `--continue`
reinstated the skill's `allowedTools` auto-approval rules and re-registered
its hooks. Both live paths refuse a disabled skill before applying anything
(`executeDisabledSkill`; the loader's disabled branch), so restore was the
only way back in. Gate the side-effect call on `isSkillEnabled`; bookkeeping
of `loadedSkillNames`/`loadedSkillContents` stays unconditional, since the
body is in the restored context either way and the dedup guard must know.

R1-5: the docs bullet claimed resume re-registers hooks on both invocation
paths. It does not. The restore matcher requires a `ToolNames.SKILL`
functionCall/functionResponse pair, which only the model path writes; the
slash command submits the body as an ordinary prompt and leaves no such
record, so a `/<skill-name>` skill's hooks are gone after resume while its
instructions stay resident. State the truth per path, say how to re-arm the
gate, and note the disabled-skill case. Making the parity claim true needs a
restorable trace for the slash path — out of scope here.

Tests: a probe asserting zero hook and zero allow-rule calls for a disabled
skill on restore, verified to red when the enabledness gate is removed.

* fix(skills): drop the resume repair, returning this PR to its original scope

The resume repair added in round 1 has produced one Critical and three
Suggestions across two review rounds, all in the same two files, and none of
them are the bug this PR set out to fix. Both maintainers and the review bot
converged on the same advice: the cluster belongs in its own change.

Removing it entirely, rather than continuing to patch it, is also the honest
call. The integration leg written for R3-3 showed the repair does not work in
a real resumed session: the gate fired 0 times after `--continue` and the
shell command actually ran, while the unit tests stayed green because they
mock the hook system wholesale. Shipping a re-arm that does not re-arm would
reproduce the exact fail-open this PR exists to close.

`restoreLoadedSkillsFromHistory` is now byte-identical to its pre-PR form.
The docs bullet takes round 1's other offered option and states the truth for
the reverted behavior: session hooks are in-memory only and are not restored
on resume, on either path — re-run the Skill to re-arm its gate.

What remains is the change reviewed as sound: one shared side-effect helper,
both loader paths routed through it, and the tests and documentation for it.
The resume gap is pre-existing, now documented, and left to a follow-up.

Removed: the resume re-arm and its `isSkillEnabled` / `pendingConditionalSkillNames`
gates, the mismatch-branch trace, three resume unit tests, and the resume
integration leg.

* test(skills): close the three suggestions left open from rounds 1-2

R1-6 (fix-induced): the trace added for the silent-drop finding was `debug`,
which `debugLogger` drops entirely without `QWEN_DEBUG_LOG_FILE` — quiet was
asked for, invisible was not. Raise it to `warn`: control only reaches this
branch for a skill that actually declares hooks (`applySkillHooks` returns
early on `!skill.hooks`), so it fires exactly when a promised gate is being
dropped and cannot become a steady-state warning. The docs sold the
guarantee without naming the exception, so the Notes list now says hooks are
not registered in sessions that disable them (`disableAllHooks`, safe mode,
bare mode, an ACP client's `skipHooks`), where the body and `allowedTools`
still apply but the gate does not.

R2-3: the no-session-id test pinned only the hooks half, so hoisting that
guard above `applySkillAllowedTools` shipped untested. It now asserts the
grant still lands, matching its no-hook-system sibling.

R2-2: the parity test sequenced its legs with unconditional `setTimeout`
sleeps that neither shortened on a fast runner nor extended on a slow one.
Every wait now polls the condition it is actually waiting on — the command
echoing, the model call the submitted body produces, the gate firing, the
exit prompt. Incidentally 2.4x faster locally (15.6s -> 6.6s), which is the
fixed budget that was being burned.

Both new assertions are load-bearing: reverting the trace to `debug` reds the
no-hook-system case, and hoisting the session-id guard above the allowedTools
grant reds the no-session-id case.

* fix(skills): correct the trust claim, pin the hookless early return, fix the recipe

All four from @wenshao's round-3 verification report.

The Notes list claimed folder trust is re-checked at fire time, so revoking
it mid-session silences a project Skill's hooks without a restart. Measured
on the real CLI, neither direction takes effect: `Config.trustedFolder` is
`private readonly`, assigned once in the constructor with no setter, so the
fire-time check returns a constant for the life of a Config — and both of the
CLI's own trust-change paths set `needsRestart`. The sentence was copied
faithfully from a pre-existing code comment, but this PR is what promotes it
into user-facing docs where a reader will act on it, which is exactly the
shape of overpromise this PR exists to remove. It now says trust changes take
effect on restart.

The surviving mutant: deleting `if (!skill.hooks) return;` from
`applySkillHooks` left the whole core suite green, while that early return is
what lets the branch below it be a `warn` rather than steady-state noise —
without it every hookless Skill invoked in a hooks-disabled session warns.
Pinned by a case that reds when it is removed.

Two fixes to the doc recipe: it is the first copy-pasteable hook example, and
run verbatim with the script left at 0644 the gate fails open silently, so it
now says to `chmod +x`. The example also used `matcher: Shell`, which works
but is the compatibility alias; hooks.md tells new configs to prefer runtime
ids, so it uses `run_shell_command`.

Also fixes a soundness bug the new assertion exposed: `debugLoggerSpies` is
module-scoped and was never cleared between cases, so log assertions were
reading calls made by earlier tests. Cleared in `beforeEach`, which makes the
previous round's two log assertions meaningful as well.

* fix(skills): correct the trust claim, guard an empty hooks block, wait for command registration

Round 4 review (#11068): two Criticals and two Suggestions, all four verified
locally with intact/flip arms rather than by reading the diff.

- docs: the trust bullet rewritten in 80c2fcb asserted unconditionally that a
  mid-session trust change takes effect only on restart. `isTrustedFolder()`
  reads the IDE context store first and falls back to the readonly
  `trustedFolder` field only when the store is empty, so the value is live
  under an IDE connection. A probe on a real Config confirms it: with
  `trustedFolder: true`, an `ideContextStore.set({workspaceState:
  {isTrusted:false}})` flips `isTrustedFolder()` to false mid-session, and
  clearing the store restores true. State both branches instead, and align the
  `applySkillHooks` / `applySkillAllowedTools` JSDoc with the same wording so
  the two documents stop disagreeing about one gate.

- docs: bare mode was listed with the sessions where only hooks are dropped.
  `listSkillsAtLevel` returns [] for every level in bare mode and the tool
  registry returns before the Skill tool is registered, so there is no body and
  no allowedTools there either. Safe mode differs — discovery is limited to
  ['bundled'] rather than emptied — so it stays in the first group.

- `applySkillHooks`: `{}` is truthy, and `parseSkillContent` assigns an empty
  object for `hooks: {}` and for a block whose event names are all unknown. Such
  a skill fell through the `!skill.hooks` guard and, with the warn level this
  branch now uses, emitted a per-invocation warning about dropping a gate it
  never declared. Widen the guard and pin the empty-object shape with a test;
  dropping the new operand turns that test red and nothing else.

- parity test: the poll that replaced the fixed sleep waited for the typed text
  to echo, which lands before the skill command registry does. Submitting into
  that gap gets `Unknown command: /gated-skill`, undetectable downstream because
  the error text contains the command. Poll for the completion menu rendering
  the skill's own description — a signal only a registered command produces —
  and assert the unknown-command error never appears.

Verified with the skill command loader artificially slowed by 1500ms, same
bundle, only the wait predicate differing: the echo poll fails 3/3 on the
user-invoked leg with `Unknown command: /gated-skill`, the registration poll
passes 3/3 and absorbs the delay (4.3s vs 3.0s). Unslowed, the fix arm is 3/3
green with zero unknown-command occurrences. `test:integration:no-ak:sandbox:none`
23 files / 187 tests, core tools+hooks+skills 1333 tests, CLI skill loaders 63
tests, typecheck, eslint and prettier all clean.

* fix(skills): close the six deferred findings from rounds 2-4

Every deferral recorded across rounds 2, 3 and 4, verified with intact/flip
arms and probes on the real classes rather than by reading the diff.

- docs recipe: `$QWEN_SKILL_ROOT` was unquoted. The command string is handed
  to a shell (`bash -c`), so a project path containing a space splits into two
  words: measured `bash: /tmp/My: No such file or directory`, exit 127, which
  is not 2 and therefore fails open — the same shape as #11067, in the recipe
  that teaches the fix. Quote it, and say why.

- docs: an omitted `matcher:` on a tool event is stored as '' and compiled to
  `^$`, which matches no tool name. A probe on a real SessionHooksManager
  confirms it: two hooks registered, only the `*` one matches
  `run_shell_command`. The hook registers and never fires, silently. Say so,
  and point at `*`.

- docs: hook commands run through the platform shell — bash on macOS and
  Linux, Git Bash on Windows when `MSYSTEM`/`TERM` say so, otherwise cmd.exe
  or PowerShell, where `$VAR` is not expanded and a `.sh` is not executable.
  `shell: bash` forces bash but resolves to whatever is on PATH, so the note
  stops short of promising it works on a stock Windows box.

- `BundledSkillLoader`: the load-time `skills.disabled` filter cannot help a
  command that was already built, so a skill disabled afterwards still had its
  side effects applied on invocation. `SkillCommandLoader` has refused this
  since before this PR; mirror it. Pre-existing for `allowedTools`, but this
  PR widened the same call to hooks.

- `packages/core/src/index.ts`: stop exporting `applySkillHooks`. It is the
  one entry point whose caller must apply the folder-trust gate itself, it had
  no consumer outside its own module, and exporting it invites the bypass that
  caused #11067. `applySkillSideEffects` stays exported and applies the gate.

- test: the untrusted-folder warn fired on `allowedTools?.length || hooks`,
  but the only case used a skill carrying both, so dropping the hooks operand
  left all tests green. Pin a hooks-only project skill.

Both new tests are discriminating: removing the bundled enabledness check reds
only the new bundled case (1 failed / 28 passed), and dropping the `|| hooks`
operand reds only the new skill-utils case (1 failed / 23 passed).
`test:integration:no-ak:sandbox:none` 23 files / 187 tests, core
tools+hooks+skills 4588 tests, CLI services 551 tests, eslint and prettier
clean. The Windows shell behaviour is read from `getShellConfiguration`, not
executed — no Windows host was available.
2026-09-06 17:11:57 +00:00
易良
703678136a
perf(cli): import core modules directly instead of the package root (#10957)
* perf(cli): let tests resolve core modules individually, and stop two files importing the whole package

Importing from the core package root pulls in its entire export graph — a bit
over six hundred modules — however little of it a file actually uses. In a
release run the cli workspace spent 2223s collecting modules against 1372s
running tests, and a file that imports the package root costs about 11.5s
before its first assertion where one importing a single module costs about 2s.

cli's tsconfig already maps a wildcard subpath onto core's sources, so esbuild
resolves per-module imports when it bundles. Vitest does not read tsconfig
paths, and the alias list that stands in for them named only four subpaths, so
those imports did not resolve under test at all. This adds the wildcard there.

Expressing the alias list as an ordered array is what allows a pattern entry.
The package root has to become an exact match in the process: as a string it
would also match everything beneath it and rewrite each subpath into a path
under index.ts.

Two files move to per-module imports as a first check that the mapping holds
end to end. Both were picked because nothing that depends on them replaces the
core package with a mock factory — where a test does that, the mock stops
intercepting once the code under test imports the module directly, so those
call sites need their mocks moved in the same change and are left alone here.

* fix(cli): restore the named core subpaths the previous commit dropped

The previous commit was assembled from a working copy that predated main by
several weeks, so it silently reverted this file to that older state. Four
named core subpaths added since — envVarResolver, noFollowOpen,
subSessionConstants and toolWriteOrigin — disappeared with it, and the new
wildcard then claimed those specifiers and pointed them at files that do not
exist. 257 test files failed to load as a result.

All eight named subpaths are restored and kept ahead of the wildcard, with a
comment saying why that order matters and what a contributor adding a ninth
has to do. None of the eight can be derived from its specifier, so none of
them can be folded into the pattern.

The two migrated source files are rebuilt on their current contents for the
same reason; one of them had also been reverted by a line.

* perf(cli): import core modules directly where no test mocks the package

Importing from the core package root evaluates its whole export graph — a bit
over six hundred modules — however little of it a file uses. On the release
lane the cli workspace spends more time collecting modules than running tests,
and on the main lane it now takes 84 minutes on its own, most of it collection.

These 130 files ask for named modules instead. They were chosen by checking,
for every test whose module graph reaches them, whether that test replaces the
core package with a mock: a test that swaps the package wholesale stops
intercepting once the code under test imports a module directly, and a test
that spreads the real package and overrides a few names only matters if one of
those names is what the file imports. Files with either kind of coupling are
left for a later change that moves the mocks at the same time.

Only import statements move; every other line is byte-identical.

* fix(cli): point ProviderModelConfig at the module that declares it, and wrap long imports

Two problems with the previous commit, both found by CI.

The symbol map resolved a re-exported name to the module that re-exports it
rather than the one that declares it, so `ProviderModelConfig` was asked of
`models/types` when it is declared in `providers/types`, and the build failed
to typecheck. A checker now confirms, for every generated specifier, that the
named module really does export that symbol — following its own re-exports —
and it reports one bad pair out of 492.

The formatting pass that was supposed to run over these files had silently
done nothing: invoked from the repository root against paths outside it,
Prettier skips the files and still reports success, so long import statements
went out unwrapped. Rerunning it properly reflows 47 files.

Two files are left with an over-long line Prettier would wrap, because that
line is over-long on the base commit too and the lint gate does not flag it;
reformatting it here would be unrelated noise. Every other line outside an
import statement stays byte-identical.

* perf(cli): import core modules directly in another 114 files

The same mechanical change as the previous commit, over the files a corrected
reading of the test suite showed were always safe to move.

The earlier pass classified a test as replacing the core package if the text
of such a call appeared anywhere in it, including inside a comment. One file
only mentions the pattern in a doc comment explaining why it deliberately
avoids it, and being counted as a blocker there ruled out 217 modules that
nothing actually blocks. Ignoring comments when detecting the call raises the
number of files movable without touching a single test from 141 to 260.

Every generated specifier is checked against the exports its named module
really has, following that module's own re-exports — 865 pairs here, none
wrong. Outside import statements every line is byte-identical.

* test(cli): move three barrel mocks onto the modules they actually stub

Where a test replaces the whole core package with a factory, the code under
test cannot move to per-module imports on its own: the mock would stop
intercepting and the real implementation would load instead, quietly changing
what the test exercises while leaving it green. The mock has to move in the
same commit.

These three are the cases where that is unambiguous — every name the factory
stubs is declared in one module, and the code under test imports exactly those
names. Each pair moves together onto that module.

The pattern generalises: about sixty tests each hold back one or two modules
this way, and roughly ninety more modules are held by several tests at once
and need them changed together. Establishing the shape on the clean cases
first keeps the ambiguous ones honest.

* Revert the migrated modules that any failing suite depends on

Retargeting this PR at main got the unit suite to run for the first time, and
it failed: 16 files, 127 tests, none of them failing on the jsdom PR that
shares the same base. So they are this stack's doing.

Every one is the same shape — a stub the suite installs no longer intercepts
once the code under test names a module instead of the package root. The
static analysis that picked these files models three ways of installing such a
stub and misses at least two more: a spy planted on a namespace import of the
package, and whatever six of the sixteen suites do, which it cannot parse at
all. Sharpening the heuristic further is not the answer; it was already wrong
in a way no amount of local reading would have caught.

So this restores every migrated module that any failing suite reaches, 146 of
them, and keeps the 110 that nothing failing depends on. That is blunt — some
of the 146 are certainly fine — but it is the version that can be shown to
pass, and picking the survivors apart is work for a run that is green to begin
with.

* fix(core): let consumers outside the cli package resolve a core module by path

The integration gate failed to compile against the migrated files:

  error TS2307: Cannot find module '@qwen-code/qwen-code-core/utils/debugLogger.js'
  error TS2307: Cannot find module '@qwen-code/qwen-code-core/utils/editor.js'

Only packages/cli maps these specifiers, through a wildcard in its own
tsconfig. The integration suite lists the eight named subpaths and no
wildcard, and the package's exports map has entries for those same eight plus
the dist and src trees — so anything resolving the normal way, this suite and
any consumer of the published package alike, cannot name a core module.

Both gaps close here: the wildcard is added to the integration suite's path
mappings, and a catch-all maps a bare module path onto the build output. The
catch-all exposes nothing new; `./dist/*` already reaches the same files.

This is the part of the change with consequences beyond the test run. The
shipped CLI is a single bundle and never resolves these specifiers at runtime,
but the package is published, and until now a migrated import was only
resolvable from inside the one workspace that happens to map it.

* fix(cli): repair the mock pairs the revert split, and keep dev on source

Three findings from review, all of them consequences of earlier steps here.

Two mock pairs were left half-migrated. In one, the mock and the code under
test both moved to the module, but the suite's own import of the same two
functions still read the package root — so the suite held the real functions
while the code held the stubs. In the other, the revert restored the code to
the package root and left the mock pointing at a module nothing imports any
more, which stubs nothing at all. The first is completed, the second put back.

The exports catch-all also changed what `npm run dev` runs. Its loader
intercepts the exact package root and nothing else, so subpath imports fall
through — and where they used to fail to resolve, they now quietly reach
compiled output from whenever the tree was last built. The loader now redirects
them to the source tree when the source file is there, leaving the named
subpath exports, whose file names do not mirror their specifiers, to resolve
as before.

* Cover the exports catch-all, and stop it hiding from the architecture rule

Three review suggestions, all about the `./*` entry this branch added to core's
exports and the things that quietly depend on it.

Nothing exercised that entry. In the repo, cli's subpath imports resolve
through tsconfig paths or vitest aliases; in the published package neither
exists and Node resolves them against `exports`. So removing the entry, or
renaming the dist root, would leave every suite green and break `qwen` on its
first core subpath import. A new check runs Node's own resolver against the
built package — deleting the entry turns it red, which is the property that
makes it worth having.

The entry also widened what the utils-layer rule has to police. It resolved
self-references by exact key only, so a deep specifier like
`.../config/storage.js` matched nothing, returned early, and reported nothing —
while resolving perfectly at runtime. It now follows Node's own order: exact
keys first, then the longest literal prefix among patterns. Fixtures cover the
wildcard idiom, exact-key precedence, and a sibling utils import that must
still be allowed.

Finally the integration suite's path mappings. The guidance comment above them
said a bare wildcard falls through to dist, which stopped being true when the
wildcard was added — nodenext substitutes `.js` for `.ts`, and the program
resolves core subpaths through it to source today. Left alone, a maintainer
following that comment would delete the wildcard as a violation and silently
re-route live imports to dist declarations. The comment now describes what the
block does, and says why the six alias-style entries cannot be folded into the
pattern. Two entries that had drifted out of sync with core's exports map,
toolWriteOrigin and envVarResolver, are restored.

* fix(cli): point the session-picker branch mock at the module it imports

Review finding R1-4b. StandaloneSessionPicker now takes getGitBranch from
@qwen-code/qwen-code-core/utils/gitUtils.js, but the colocated suite kept
mocking the package root, so the override no longer intercepted anything the
picker resolves and the factory's importActual still pulled core's whole
index into the suite's module graph. The mock now names the same specifier
the component imports, and a small pin test goes red if the pair splits
again.

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

* fix(dev): preserve source-backed core entry paths

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

* fix(scripts): close core subpath verification gaps

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

* fix(scripts): gate dep-only core subpath exports in the check

The exports check collected specifiers only from packages/cli/src, but
acp-bridge and sdk-typescript are runtime dependencies of the cli whose
compiled dist keeps core subpath specifiers verbatim. Entries named only
by those packages (./goalWire, ./transcriptRecords, ./subSessionConstants)
were therefore ungated: deleting one kept every gate green while
`node packages/cli` died with ERR_MODULE_NOT_FOUND on the serve/replay
path. Extend the source scan to packages/acp-bridge/src and
packages/sdk-typescript/src so all 95 collected specifiers resolve
through the exports map. Verified: removing ./goalWire or
./transcriptRecords from packages/core/package.json now makes the check
exit 1 (entries restored after the mutation probe).

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

* docs(harness): note terminal-capture loader is manually exercised

No CI test executes the terminal-capture harness loader's subpath
redirect; it runs manually only via `npm run test:terminal-bench`.
Record that at the redirect site and point at
scripts/check-core-subpath-exports.mjs as the CI gate for the real
resolution path, so the `named` map stays consciously in sync with the
named entries in packages/core/package.json.

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

* fix(scripts): reject core subpath targets outside the published dist

The exports check validated resolved targets against the repo tree, but
core's exports map also carries "./src/*": "./src/*" while package.json
publishes only dist, vendor and scripts/postinstall.js. A specifier
routed through that entry resolved to a real in-repo packages/core/src
file, passed the bare existsSync and exited 0 even though the published
artifact ships nothing — the installed CLI would die at startup with
ERR_MODULE_NOT_FOUND while the gate stayed green. Require the resolved
target to lie inside packages/core/dist/, where every legitimate
runtime specifier lands.

Add scripts/tests/check-core-subpath-exports.test.js: a fixture-tree
suite (per the scripts/tests convention) that runs a copy of the real
script in a temp workspace. It pins the dep-only scan from a18ffebb8
(goalWire named by no cli source resolves; removing its exports entry
exits 1 — the mutation witness requested in review, made hermetic) and
pins this guard (a ./src/*-routed specifier is reported as not
published; removing the guard turns that test red).

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

* fix(scripts): allow published core package.json in subpath gate, cover probe stages

The round-4 dist-containment guard rejected
@qwen-code/qwen-code-core/package.json, which core's exports map
deliberately publishes ("./package.json": "./package.json") and npm ships
regardless of "files". Allow that one target; ./src/* targets stay
rejected (existing test remains red without the guard).

Also extend the fixture suite to the two failure stages it did not
witness: the named-export probe stage (target exists but lacks the probed
export) and the resolve-failure branch (exports map without the ./*
catch-all). Mutant-verified: removing the probe import/export-name check
or the resolve-catch failed++ turns the matching new test red and nothing
else.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(terminal-capture): add conversationsRuntimeMarker to harness loader map

The loader's named map listed 8 of the 9 named entries in core's exports
map: conversationsRuntimeMarker (source at utils/conversations-runtime-marker.ts,
a path the specifier does not mirror) was missing. The specifier is
reachable in the harness graph today via shared-env-keys.ts, so resolution
fell through to the exports map — ERR_MODULE_NOT_FOUND mid-capture on a
fresh clone, or a stale dist silently mixed into a source-backed capture
otherwise. Add the entry, matching packages/core/package.json and the cli
vitest alias.

Also add a sync guard (scripts/tests) asserting every named key of core's
exports map has a loader entry pointing at an existing core source file,
so the next omitted entry goes red in CI.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(integration-tests): map conversationsRuntimeMarker to core source in tsconfig paths

The ninth named exports entry was missing from the paths block, so
`@qwen-code/qwen-code-core/conversationsRuntimeMarker` (imported by
packages/cli/src/config/shared-env-keys.ts) fell through the wildcard to
a nonexistent substitution and resolved against packages/core/dist — or
failed with TS2307 on an unbuilt tree — the exact stale-dist mode this
block's comment warns about. Add a sync gate asserting every named
exports key whose target stem differs from the key has a paths entry
pointing at the matching source file.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtntktgsk8

* test(scripts): assert loader map values and reverse key-set against core exports

The harness loader sync gate only checked key coverage (exports key ->
loader entry) and file existence, so two drifts passed it green:
a retargeted exports entry left the loader serving the old module (the
named map short-circuits ahead of the stem probe), and a stale
loader-only entry short-circuits captures on a module graph the shipped
package can no longer resolve. Assert each loader entry equals the
exports import target mapped into source space, and every loader entry
has a matching exports key. Mutating either direction now fails the
suite.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtntktgsk8

* test(scripts): make the exact-key-precedence fixture verdict-sensitive

The rule verdicts by directory layer and never checks file existence, so
the goalWire fixture reported one violation whether the exact exports
key or the ./* wildcard resolved it — removing the exact-key branch of
resolveExportTarget left the test green. Switch the fixture to
transcriptRecords, whose exact resolution lands inside utils/ (allowed)
while the wildcard resolution lands outside (violation), so dropping the
exact-key branch now flips the assertion from 0 to 1.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtntktgsk8

* docs(cli): correct the subpath-alias and mock-wiring comments

packages/cli/vitest.config.ts named the cli tsconfig `paths` block as the
trigger for adding a named core subpath alias, but only three of the nine
entries (noFollowOpen, subSessionConstants, transcriptRecords) appear there;
the source of truth is the named keys of the `exports` map in
packages/core/package.json. The comment now says so, and records that this
map — unlike the skill-review-harness loader's, which
scripts/tests/text-capture-core-loader-sync.test.js checks against core's
exports — has no gate.

StandaloneSessionPicker.test.tsx's wiring test claimed to pin the wrapper's
import, but StandaloneSessionPicker.tsx is not in that suite's module graph:
the tests render SessionPicker, which takes `currentBranch` as a prop, so the
stub's only consumer is the test file's own import. Renamed the test and
corrected both comments to state what it pins and what it does not cover.

Comment and test-name only; no behavior change.

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

* ci: pin the new core subpath exports step in the lint lane payload

`Check core subpath exports resolve` was added to the lint_and_static job
without being added to the pin that asserts that job's full-gated payload by
name and order, so the guard failed with 19 received against 18 expected.

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
2026-09-06 11:21:50 +00:00
易良
133a1a39de
ci: skip product tests for ECS updater maintenance (#10548)
* ci: classify ECS updater maintenance as CI-only

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

* fix(ci): harden CI-only classifier trust

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

* ci: record ci.yml growth in the size baseline

The ci-only-profile wiring added real workflow content; combined with
prior main growth this pushed ci.yml 4853 bytes past its recorded
98043, over the 4096 allowance. Bump the ratchet to the current
102896 so the check reflects the reviewed growth.

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

* test(ci): gate the classifier trust test to the Linux lane

The fail-closed trust test drives the workflow step through `bash -c`
with a Unix ':'-joined PATH and an extensionless shebang `gh` stub,
which the Windows lane cannot express. Gate it the same way this
file's other bash-driven cases already do, keeping the Linux lane as
the authoritative coverage (scripts/tests/vitest.config.ts).

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

* ci: log the reason behind the classifier trust verdict

The permission lookup discarded all diagnostics (2>/dev/null || true)
and the log printed only the verdict, so an API outage, an empty
permission, and a real below-write permission all produced the
identical "PR classifier trusted: false" line. Record which branch
made the decision and print it alongside the verdict; the
can_trust_pr_classifier output and its fail-closed semantics are
unchanged. Bumps the size baseline for the added lines.

The trust test's helper now captures the step's stdout, and a new
Linux-gated case asserts the denial reasons stay distinguishable.

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

* test(ci): pin the remaining classifier trust reason branches

The R1-3 reason logging added six per-branch reason strings, but the
'not a pull_request event' and 'PR author missing from the event
payload' branches were unreachable from the tests: runClassifierTrust
hardcoded GITHUB_EVENT_NAME and PR_AUTHOR, so dropping or swapping
either assignment shipped with every test green. Give the helper
optional eventName / prAuthor overrides and extend the reason test to
assert both branches stay denied and distinguishable; the merge queue
exercises the first one on every merge_group run.

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

* fix(ci): run PR classifier from trusted base

Compute the CI profile once from an immutable base-SHA checkout, then pass the validated output to product jobs without executing the PR head's classifier. All missing, failed, or unknown classifications continue to fall back to full CI.

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

* fix(ci): prepare trusted classifier checkout

Clean shared-runner residue before checking out the trusted base classifier and update the workflow test fixture to mirror its isolated path.

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

* fix(ci): sweep leftover trusted-ci-classifier before checkout

actions/checkout keeps an existing checkout directory when its origin
URL matches and runs `git checkout --detach` inside it without a
core.hooksPath mitigation, so a leftover trusted-ci-classifier from an
earlier classify_pr run on the shared ecs-qwen pool would execute
whatever hooks residue planted there — code execution through the
classifier checkout the sweep already defends. Add the path to the
pre-checkout sweep loop in every ci.yml copy (the byte-identity pin
keeps them in lockstep) and in qwen-code-pr-review.yml, per the
documented third-residue-name contract, and pin the removal
behaviorally.

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

* test(ci): pin the shared-pool ownership restore step's existence

The ordering assertion alone survives removing 'Restore workspace
ownership': restoreIdx becomes -1 and cleanIdx > -1 still holds, while
root-owned leftovers from a containerised job defeat the sweep and the
checkout again — the EACCES incident class the step exists for. The
step is new in classify_pr on this branch, so pin it explicitly on
every shared-pool job.

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

* fix(ci): pin the profile producer id and log the degraded fallback

Two silent degradations in the new profile wiring:

- The classify_pr job output resolves against step id ci_profile, but
  nothing pinned the producer step's id or its GITHUB_OUTPUT write: a
  rename empties the output and every consumer silently runs the full
  profile. Pin both on the producer step.
- When classify_pr fails (continue-on-error) the consumers coerce the
  empty output to full and log the exact same line as a legitimate full
  classification. Emit a ::warning breadcrumb when the producer output
  is empty, on both consumer copies, and pin it.

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

* test(ci): pin the profile consumers' allowlist and output write

The consumer loop pinned every load-bearing line of both `Use trusted
CI profile` steps except the allowlist case line and the GITHUB_OUTPUT
write. If a later edit drops the echo in either consumer,
steps.ci_profile.outputs.ci_profile becomes empty and every downstream
profile gate mis-compares. Extend the pinning test to cover both
consumer copies of those two lines (mutation-probed on all four
consumer lines).

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

* docs(ci): explain the third residue name in the review sweep comment

The sweep loop grew to three names (adding trusted-ci-classifier), but
the comment above it still said "those two known names". Update the
count and add the explanatory paragraph for the third name, mirroring
the ci.yml copy: the residue comes from ci.yml's classify_pr base-SHA
checkout on the shared pool, and actions/checkout would reuse it and
run git (hooks included) inside it.

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

* fix(ci): run only dependency-free helper tests in github_ci_only lane (#10548)

The github_ci_only helper step runs before any dependency install — the
setup-node and `npm ci` steps are gated on the full profile — but it ran
the entire HELPER_TESTS list, 8 of whose suites import the `yaml` npm
package. A PR classifying into this lane (e.g. a fork PR touching only
.github/workflows/update-ecs-runner-qwen.yml, which this PR admits to the
fast lane) failed closed with ERR_MODULE_NOT_FOUND on a fresh runner.

Split the list: the fast lane now runs HELPER_TESTS_DEP_FREE (suites whose
import closure is node: builtins only); HELPER_TESTS keeps the full list
for the full-profile helper step, and the merge queue's full run
re-checks what the fast lane skips. A pinning test in ci-platform-lanes
keeps the yaml suites out of the fast lane.

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

* fix(ci): drop yaml-dependent helper suite from dep-free test lane

web-shell-visuals-publish.test.mjs statically imports the `yaml` npm
package, but HELPER_TESTS_DEP_FREE runs before any dependency install
(npm ci is gated to the full profile), so the suite failed with
ERR_MODULE_NOT_FOUND on fresh runners (review feedback from qqqys).
Remove it from the dep-free lane only; the full-profile helper lane
still runs it.

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

* fix(ci): keep yaml-dependent visuals suite out of github_ci_only lane

web-shell-visuals-publish.test.mjs statically imports `yaml`, so the
dependency-free github_ci_only lane can never execute it. Listing it in
GITHUB_CI_ONLY_FILES meant a PR touching only that suite got every
required check green without the changed file running once, because the
full-profile helper step is gated on ci_profile == 'full'. Drop the
entry so such PRs keep the full profile, and pin the classification
with a test assertion (verified red with the entry, green without).

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

* fix(ci): grant classify_pr pull-requests read scope and pin the fast-lane exclusion

F1: classify_pr had no job-level permissions block, so its token carried
only the workflow-level {checks: write, contents: read, statuses: write}
grant. The classifier wrapper calls repos/{}/pulls/{}/files and
repos/{}/pulls/{}, which 403 without pull-requests scope and fall back to
full CI. Grant contents: read + pull-requests: read on the job and pin the
block with an assertion in the no-ak integration test.

F7: the fast-lane pinning test enumerated 8 of the 9 yaml-importing suites;
add web-shell-visuals-publish.test.mjs (the suite the F6 commit excluded
from HELPER_TESTS_DEP_FREE) and fix the comment count, so re-adding it to
the fast lane fails the test.

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

* ci: give the github_ci_only fast lane a single dependency-free owner

lint_and_static's 'GitHub CI helper checks' step ran the full HELPER_TESTS list under the github_ci_only profile, where every dependency install in that job is gated on ci_profile == 'full' - so the 9 suites that statically import `yaml` died with ERR_MODULE_NOT_FOUND on a fresh hosted runner. This branch newly routes ECS-updater and actionlint PRs into that profile, which made the fast lane deterministically red on exactly the flow it exists to serve.

Repoint that step at HELPER_TESTS_DEP_FREE and drop the duplicate copy in the `test` job: ci-platform-lanes.test.js reserves that step name for lint_and_static, so the copy also broke the shared-prelude byte-identity guard and the out-of-test pin. Select the fast lane by its gate rather than by the list it consumes, so a regression back to env.HELPER_TESTS cannot drop out of the filter.

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

* ci: keep the ECS updater workflow guard reachable from its own fast lane

Routing .github/workflows/update-ecs-runner-qwen.yml into GITHUB_CI_ONLY_FILES left its guard unreachable on exactly the PRs that change it. The 21-case vitest suite under scripts/tests/ only runs under the full profile — test:scripts is gated on ci_profile == 'full', and test_macos / test_windows fire only on merge_group|schedule|workflow_dispatch — while the dependency-free node:test replacement was a strict 7-case subset. Renaming the matrix job name template, which breaks the reporter script startswith(poolPrefix) filter, or dropping the issues:write permission from report_failure, merged with every check green.

Port the remaining 14 cases into the dep-free suite behind the same win32/jq capability gate the bash replays already used, and delete the vitest copy so the two cannot drift. The suite imports node: builtins only, so it stays inside the HELPER_TESTS_DEP_FREE contract. Verified locally: 21/21 pass, 0 skipped; renaming the matrix job name template reddens "filters the run jobs by the prefix the matrix job actually uses", and dropping issues:write reddens "reports a failed fleet update only when a pool actually failed".

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

* style: apply prettier to the ported ECS updater guard suite

The ported assertions were hand-converted from vitest and drifted from the repo Prettier style, which the full-profile Run Prettier step would have flagged on this PR.

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-06 11:19:07 +00:00
易良
49e3ef6269
perf(cli): let tests resolve core modules individually (#10917)
* perf(cli): let tests resolve core modules individually, and stop two files importing the whole package

Importing from the core package root pulls in its entire export graph — a bit
over six hundred modules — however little of it a file actually uses. In a
release run the cli workspace spent 2223s collecting modules against 1372s
running tests, and a file that imports the package root costs about 11.5s
before its first assertion where one importing a single module costs about 2s.

cli's tsconfig already maps a wildcard subpath onto core's sources, so esbuild
resolves per-module imports when it bundles. Vitest does not read tsconfig
paths, and the alias list that stands in for them named only four subpaths, so
those imports did not resolve under test at all. This adds the wildcard there.

Expressing the alias list as an ordered array is what allows a pattern entry.
The package root has to become an exact match in the process: as a string it
would also match everything beneath it and rewrite each subpath into a path
under index.ts.

Two files move to per-module imports as a first check that the mapping holds
end to end. Both were picked because nothing that depends on them replaces the
core package with a mock factory — where a test does that, the mock stops
intercepting once the code under test imports the module directly, so those
call sites need their mocks moved in the same change and are left alone here.

* fix(cli): restore the named core subpaths the previous commit dropped

The previous commit was assembled from a working copy that predated main by
several weeks, so it silently reverted this file to that older state. Four
named core subpaths added since — envVarResolver, noFollowOpen,
subSessionConstants and toolWriteOrigin — disappeared with it, and the new
wildcard then claimed those specifiers and pointed them at files that do not
exist. 257 test files failed to load as a result.

All eight named subpaths are restored and kept ahead of the wildcard, with a
comment saying why that order matters and what a contributor adding a ninth
has to do. None of the eight can be derived from its specifier, so none of
them can be folded into the pattern.

The two migrated source files are rebuilt on their current contents for the
same reason; one of them had also been reverted by a line.

* fix(cli): name the migrated core imports so plain Node resolves them

The tipHistory / RemoteInputWatcher migration used .js-suffixed subpath
specifiers that match no entry in packages/core/package.json exports, so
the built-but-unbundled CLI (npm start / build-and-start, whose tsc dist
keeps specifiers) crashed at module load with ERR_PACKAGE_PATH_NOT_EXPORTED
while typecheck (tsconfig paths), unit tests (vitest wildcard alias) and the
bundle (esbuild paths) all bypassed exports and stayed green. Switch the two
files to named subpaths (storage, atomicFileWrite, debugLogger), following
the convention of the eight existing entries, and add the matching exports
entries and vitest aliases.

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

* test(scripts): guard core subpath exports resolution under plain Node

Every core subpath specifier statically imported from packages/cli/src must
resolve through packages/core/package.json exports in a real child node
process — no vitest aliases, no tsconfig paths. Without the matching exports
entries the built-but-unbundled CLI dies with ERR_PACKAGE_PATH_NOT_EXPORTED
while every gate that bypasses exports stays green; this guard goes red the
moment an entry is removed.

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

* fix(cli): name every core subpath import in cli tsconfig paths

The named subpaths cli sources import from @qwen-code/qwen-code-core had
no named `paths` entries, so the wildcard composed nonexistent files and
esbuild (bundle) plus the dev loader chain fell back to the exports map,
loading packages/core/dist copies while every package-root import loads
packages/core/src — two instances of barrel-exported, stateful modules
(debugLogger, storage, atomicFileWrite, envVarResolver, toolWriteOrigin,
memoryScopes) in one process. Add the six missing named entries beside the
existing ones so bundle and dev resolve all subpaths into the core src
tree, consistent with the vitest alias list.

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

* test(scripts): pin subpath exports targets and guard bundle resolution

Complete the resolution guard and harden its probe:

- Cover every core subpath statically imported from packages/cli/src
  (adds toolWriteOrigin and memoryScopes) plus the subpaths npm start
  reaches through @qwen-code/acp-bridge (subSessionConstants, goalWire,
  transcriptRecords), instead of the previous five specifiers.
- Pin each specifier to its expected dist target: assert the resolved
  URL equals the pinned path and the target file exists, so a typo'd or
  redirected exports target fails the guard (import.meta.resolve alone
  accepts both). This makes a built core dist a prerequisite, which
  vitest-global-setup already fail-fasts on.
- Add a bundle-resolution guard: esbuild-bundle every cli/src core
  subpath under packages/cli/tsconfig.json and assert no input comes
  from packages/core/dist, pinning the tsconfig paths entries.

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

* fix: cover goalWire in the core-subpath guard and make it OS-independent

- Add goalWire to the bundle arm and named tsconfig paths entries in
  packages/cli and packages/acp-bridge: acp-bridge's transcript-replay
  imports @qwen-code/qwen-code-core/goalWire, and without a named entry
  the wildcard resolved to a nonexistent ../core/src/goalWire and fell
  back to the exports map, bundling packages/core/dist/src/goals/
  goal-wire.js next to src-resolved core modules — the module-identity
  split #10908's Known risks name. Verified with an esbuild metafile
  probe: dist input before the paths entry, src input after.
- Normalize esbuild metafile input keys to forward slashes before the
  dist-leak filter and src-target assertions so the guard behaves the
  same on Windows runners, where esbuild emits backslash separators.
- Correct the header: this lane's vitest config does not wire
  scripts/vitest-global-setup.js (it is a globalSetup only in the
  packages/core and packages/cli configs, and its DIST_PREREQUISITES
  has no key covering this lane), so a missing dist surfaces as the
  existence assertion naming the absent file.

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

* test: guard acp-bridge-routed core subpaths in the bundle arm

The bundle arm seeded goalWire into the cli-routed buildSync case, which
passes an explicit packages/cli/tsconfig.json — but the shipped bundle
resolves that import under packages/acp-bridge/tsconfig.json (mainBuild in
esbuild.config.js carries no tsconfig option, so esbuild discovers the
nearest tsconfig per importing file, and goalWire is imported only from
packages/acp-bridge/src/transcript-replay.ts). The acp-bridge goalWire
paths entry was therefore guarded by no arm, and transcriptRecords /
subSessionConstants were probed by none at all: removing the acp-bridge
goalWire entry left the test green while a production-shaped build pulled
packages/core/dist inputs.

Add an acp-bridge-routed buildSync case — no tsconfig option, resolveDir
packages/acp-bridge/src, seeding goalWire, transcriptRecords,
subSessionConstants and noFollowOpen — asserting no input lands under
packages/core/dist and each expected core src target is present. With the
new arm, removing the acp-bridge goalWire entry goes red (4 dist leaks)
where previously nothing did.

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

* fix(cli): guard the conversationsRuntimeMarker subpath route

R6-1: @qwen-code/qwen-code-core/conversationsRuntimeMarker is
statically imported from packages/cli/src (config/shared-env-keys.ts,
serve/run-qwen-serve.ts) but carried no named entry in
packages/cli/tsconfig.json paths and was seeded into neither guard
map. The wildcard composed a nonexistent ../core/src target and fell
back to the exports map, so the cli-routed bundle loaded a
packages/core/dist copy next to the core src copy while every guard
arm stayed green. Add the named paths entry beside the ones this PR
already adds and seed both guard maps with the specifier.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(sdk): guard the sdk-routed core subpath resolution

R6-2: the cli bundle reaches sdk sources through the cli tsconfig
@qwen-code/sdk/* mapping (ui/utils/export/export-transcript-document.ts
imports @qwen-code/sdk/daemon/transcript, which re-exports from
daemon/ui/chat-record-transcript.ts), and chat-record-transcript.ts
imports @qwen-code/qwen-code-core/transcriptRecords. With no paths in
packages/sdk-typescript/tsconfig.json, mainBuild's per-importing-file
tsconfig discovery found no mapping and fell back to the exports map,
bundling a packages/core/dist copy while the guard suite stayed green.
Add the named entry to the sdk tsconfig, mirroring acp-bridge, and a
third guard arm probing the sdk route.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(sdk): reference core from the sdk composite reference project

The paths entry added for the sdk-routed guard resolves
@qwen-code/qwen-code-core/transcriptRecords to the core source file,
which in the composite tsc --build graph belongs to the core project.
Without a project reference the cli build failed with TS6059/TS6307;
declare the core reference, mirroring packages/acp-bridge/tsconfig.json.
Verified with npm run build in packages/cli and npm run typecheck in
packages/sdk-typescript.

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(sdk): keep the declaration build on the exports map

The paths entry added to tsconfig.json for the bundle's esbuild
discovery is inherited by tsconfig.build.json; in that plain
declaration build it pulled core sources into the program, re-rooted
the inferred rootDir above the package, and nested every emitted .d.ts
under dist/sdk-typescript/src/, dropping dist/daemon/index.d.ts that
web-shell imports (TS7016). Reset paths in tsconfig.build.json so the
declaration build resolves core through its exports map. Verified with
npm run build in packages/sdk-typescript: dist/daemon/index.d.ts
restored and the daemon browser bundle byte-identical (236252 bytes,
its pre-existing warning threshold breach is unchanged).

Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
Patrol-Run: qwen-pr-closeout/jmtngpuscjl

* fix(scripts): route core subpaths to src in the dev loader

The dev loader intercepted only the exact package root, so a named core
subpath fell through to the package's exports map and loaded
packages/core/dist while every package-root import loaded
packages/core/src. One dev process then held two instances of the same
module: Config binds the debug session on the src copy
(setDebugLogSession(this)), so RemoteInputWatcher's dist-copy
REMOTE_INPUT logger read an empty session and every debugLogger(...) call
there silently no-oped with QWEN_DEBUG_LOG_FILE enabled. Storage split
its static state the same way.

Derive the interception map from the core exports map so every named
subpath short-circuits to its packages/core/src file and stays covered as
subpaths are added. The exports entries (built-but-unbundled CLI) and the
tsconfig paths entries (bundle lane) are untouched.

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com>
2026-09-06 10:20:05 +00:00
qwen-code-dev-bot
101b003f93
test(ci): make verify-capture fallback assertion rasterization-safe (#10758)
* fix(ci): stabilize verify-capture fallback-grey test on fontless hosts (#10757)

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

* test(ci): require near-neutral pixels in the verify-capture fallback scan (#10758)

A brightness-only count was also satisfied by the hardcoded #9cdcfe title
fill and by a tinted FG_DEFAULT, so the defect this case guards — a deleted
bounds guard shipping fill="undefined", which librsvg paints black — could
escape unnoticed as soon as a capture is captioned. Both endpoints of the
blend axis are neutral, so requiring a channel spread <= 12 keeps the count
specific to the fallback grey at any coverage, with tolerance enough for
subpixel antialiasing that the exact-colour scan it replaced did not have.

Probes on a fontless host (fc-list = 0): guard deleted, guard deleted with
--title, and FG_DEFAULT='#ff8080' each fail this case now, and all three
pass with the clause neutralised; --title alone still passes.

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

* test(ci): keep a mid-grey fallback out of the verify-capture count (#10758)

The brightness floor admitted an FG_DEFAULT of exactly 0x80, so a mid-grey
fallback still satisfied the count: measured on a host without fontconfig,
a '#808080' mutant cleared it in 6 of 12 renders. Make the floor strict,
which kills that mutant in 12 of 12 while the real '#d4d4d4' control still
counts 35-70 pixels against a threshold of 1.

Restate the predicate comment to what the tolerance actually excludes — a
channel spread up to 12 is admitted, so only a grossly tinted fallback is
ruled out, not every tint.

* test(ci): tighten the verify-capture fallback spread bound (#10758)

The 12-unit channel-spread tolerance was justified as absorbing subpixel
antialiasing, but that mechanism never reaches this rasteriser: both ends
of the blend axis are neutral greys, so #d4d4d4 over #1e1e1e yields
r=g=b at every coverage and a glyph pixel's spread is 0 by arithmetic.
Measured here over 8 renders and 440 bright pixels, max spread was 0 and
the <=4 and <=12 counts were identical; the fontconfig control in this
round's review report shows rgba=rgb and antialias=false both render
byte-identically, so the fringing the slack was reserved for cannot occur.

The slack cost a mutant. FG_DEFAULT='#d4d4c8' has spread exactly 12 and
passed the old bound; at <=4 it fails. The mid-grey '#808080' and the
deleted-bounds-guard mutants still fail, so no detection is given up.

Restate the predicate comment to the measured reason instead of the
fringing that cannot happen. Net line change is zero.

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: 易良 <1204183885@qq.com>
2026-09-06 08:46:27 +00:00
Shaojin Wen
827c8aa492
fix(ci): salvage superseded review runs and hold the loop's report-time base refresh (#10123)
* fix(ci): salvage superseded review runs and hold the loop's report-time base refresh

A synchronize used to cancel-in-progress the in-flight review run no matter how close to done it was: on PR #9729 a push cancelled run 32726618419 4h06m in, minutes from posting, and cancelled review runs wasted ~7h41m of runner time across that one PR. A declarative cancel cannot weigh the run's progress, so the supersede decision moves into the run: cancel-in-progress now fires on closed only, a synchronize run queues pending in the PR-scoped group, and the in-flight run's new supersede watcher either ends the attempt early (pre-threshold: the queued replacement reviews the new head from scratch, within a poll interval) or, past the salvage threshold (compose artifact present, or elapsed >= 50% of the size-aware budget, tunable via QWEN_REVIEW_SALVAGE_ELAPSED_PERCENT), finishes and posts against the head it reviewed. The gh-wrapper write guard gains a salvage escape pinned to the reviewed head; the CLI's existing presubmit head-drift gate annotates and caps the verdict, and a new step posts a historical-head note naming the anchor the next incremental review starts from. The delay job now also skips a queued run whose event head went stale while it waited.

The other half covers the one loop-owned head move outside the #8888/#8899 dispatch hold: the autofix report step's stale-base update-branch. It now probes for a live review-pr first (same probe pair as the scan gate, fail-open on probe errors) and defers the refresh with the retry branch's sentinel semantics instead of superseding the review; the deferred round joins the consecutive-failure exemptions. Rationale recorded as qwen-autofix.md#af-148, and the af-099 delay-window entry is updated for the new supersede semantics.

Both workflows' .size-baseline entries are bumped in this PR per the growth ratchet: the review workflow gains the watcher/salvage machinery (+15 KB) and the autofix report step gains the probe pair (+6 KB) — real feature growth, with the long-form rationale kept in the design record.

Fixes #10110

* test(shepherd): classify the deferred stale-base headline as transient

The fleet-shepherd contract test requires every autofix HEADLINE to be named terminal or transient; the report step's new deferred-stale-base headline (#10110) was unclassified. It is transient — the loop retries on the next scan — and the shepherd's REASON regex already does not match it, so only the classification lists needed the new needle.

* fix(ci): scope supersede-salvage per attempt and reap the watcher safely

Round 1 review findings on the salvage machinery. The EXIT trap's watcher
reap killed an already-reaped subshell on the salvage-cede path: under
errexit the failed kill aborted the trap before the SALVAGE_DIR cleanup and
turned the deliberate clean exit into exit 1 — the kill now tolerates a
reaped watcher. The salvage threshold parse accepts leading-zero values
that the watcher arithmetic reads as octal (`08` kills the first
salvage_eligible call, `050` silently applies 40%); the value is coerced
decimal after the digit guard.

Salvage eligibility is now per attempt, matching the threshold's own
rationale: the retry loop restarts the review from zero, so attempt 1's
compose sighting is unlatched and its elapsed fraction no longer counts
against attempt 2 — the loop resets the latch and records an attempt-start
timestamp the watcher reads (self-bound stays per run). The supersede file
is also re-checked at the top of the loop: a cede landing during the retry
backoff (pkill matched nothing — qwen not running) used to be noticed only
after a full re-review of the dead head. Docs-only runs always cede on a
moved head: their only posting vehicle skips any moved head and the
salvage-note step excludes them, so an armed KEEP could never post.

The watcher's KEEP/CEDE dispatch is now replayed with stub gh/pkill/sleep
(marker carries the reviewed head, supersede file the live one, docs-only
and fresh-attempt cedes pinned), the parse and the trap are replayed too,
and runScenario gains the backoff-cede and latch-reset scenarios. Six
mutation probes confirm each guard is witnessed; all eight changed tests
fail against the pre-round workflow. The .size-baseline entry is bumped
per the growth ratchet.

* fix(ci): verify the live head before ceding and validate salvage signals

The supersede/salvage cedes trusted agent-writable marker files by
existence alone, letting a forged file suppress a whole review with the
job green, and the composed-artifact latch trusted a bare existence
check on an agent-derivable workspace path. Re-read the live head at
every cede decision, latch only on a parseable composed verdict, delete
the artifact in the per-attempt reset, and shape-check moved-to before
the $GITHUB_OUTPUT write. Each guard is pinned by a replayed-bash
witness that turns red when the guard is removed.

* fix(ci): make salvage signal IO FIFO-safe and cede decisions outcome-aware

Round-3 review fixes for the supersede-salvage mechanism (#10110):
every signal write goes aside-then-rename (write_signal) and every
read gates on [ -f ], so planted FIFOs at the agent-derivable signal
paths can no longer block the watcher, the finished step, or the
posting path; the compose latch refuses non-regular artifacts; the
cede message validates the supersede target to the watcher's 40-hex
shape before interpolation; the salvage outputs block re-checks the
live head like the cede sites; the post-attempt supersede check
carries the OUTCOME guard its sibling has; and the per-attempt reset
also runs before the retry backoff. The replay harness pins
AUTO_REVIEW off so an exporting environment cannot arm the watcher
inside a replay. Each guard has an executed replay witness, all
mutation-probed red.

* fix(ci): cede genuinely superseded runs and restart the retry watcher

- Cede clean at the terminal fail when the live head really moved
  (a poll-gap move left no signal file) or when the PR timeline proves
  a move-then-revert landed during the run — a superseded run must not
  go red while its replacement is already queued.
- Restart the one-shot watcher when a retry outlives it and clear the
  dead attempt's marker/moved-to/superseded signals, so the fresh
  attempt is watched and meets fresh state.
- Measure salvage eligibility against the attempt's own budget share,
  not the whole run's, so a late retry can still reach KEEP.
- New executed replays witness each guard; runScenario now returns the
  exit status so the load-bearing clean cede exits are pinned.

* fix(ci): close the salvage cede races and hostile-state aborts

* fix(ci): gate supersede cedes to automatic runs and dedup same-head reviews (#10123)

* fix(ci): bound salvage-signal reads and authenticate the delay-step dedup (#10123)

Address the round-8/9 review Criticals:

- The delay-step head-level dedup called `gh api --arg`, a flag gh does
  not have, so the lookup died at flag parse and the dedup was dead
  code. Drop the flag and interpolate the constant bot login into the
  --jq filter.
- The dedup parsed the FIRST `"sha":"…"` hit out of model-authored
  review bodies, a forgeable anchor. Parse only the LAST ledger marker
  per body (the CLI's parseLedger convention) and authenticate the sha
  against the review's server-recorded commit_id before skipping; every
  lookup trouble still fails open.
- The revert-cede accepted the watcher's kill record alone, but the
  record's dir is enumerable under RUNNER_TEMP — non-export stops
  derivation, not discovery by the same-uid agent. Accept the record
  only corroborated by a server-side back-push onto the expected head;
  the timeline pair stays the fallback for a lost record.
- The salvage-signal reads gated on [ -f ] and then reopened agent-
  derivable paths with unbounded cats: a FIFO rename-swapped into the
  check-then-open window wedged the watcher, the posting path, or the
  cede forever, and a huge plant could be slurped whole. Each read is
  now one `timeout 5 head -c N` open; the posting path fails closed.
- The replay harnesses supply a bound-enforcing timeout(1) stub (macOS
  lanes ship none), and the gh stub rejects unknown flags like real gh
  so the --arg fiction cannot be re-emulated.

New replays witness each fix (forged kill record, forged/first markers,
commit_id mismatch, rename-swapped FIFOs, huge plants); mutation probes
confirmed every witness turns red when its guard is removed. The
workflow's .size-baseline ratchet is bumped for the real growth.

* fix(ci): cap the compose-latch read, poison-proof cleanups, and authenticate the cede/skip witnesses (#10123)

* fix(ci): drop the forgeable delay-step dedup and scope the report hold to lifecycle runs (#10123)

* fix(ci): pin cede/salvage live-head reads to the real gh (#10123)

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

* fix(ci): pin supersede timestamp conversions to the real date, seal replay harness envs (#10123)

* fix(ci): pin salvage clock/utility resolution and bound planted salvage signals (#10123)

* fix(ci): pin the supersede watcher's utilities and bound future attempt-start plants (#10123)

Address the round-21 Critical findings on the review workflow:

- The supersede watcher resolved bare sleep/pkill/id through the
  agent-writable $proxy_bin PATH prepend; capture them in
  configure_qwen_network before the prepend like gh/git/date/rm/tee
  and read the captures with :- fallbacks (R21-2).
- The attempt-start guard bounded only the low side, so a 10-digit
  future epoch flipped KEEP to CEDE through a negative elapsed; add
  the pinned-clock upper bound (R21-3).
- runScenario pinned the lane-inherited real-utility captures and
  AUTO_REVIEW but not QWEN_CI_REVIEW_SALVAGE_OK_FILE, so on this
  workflow's own review lane the replayed retry reset rm-rfed the
  parent run's live salvage marker; pin it empty ahead of extraEnv,
  together with the sleep pin the armWatcher replays need (R21-1).

Witnesses: a lane-simulation canary for the marker, proxy-bin plant
arms for the watcher's poll and kill, and a future arm in the
attempt-start bounds test; each goes red with its guard removed.

* fix(ci): refuse bot-authored force-push events in the supersede revert cede (#10123)

* fix(ci): finish the review utility pin sweep and scope the cede kill (#10123)

Three gaps in the review lane's supersede hardening, all reachable by an
agent that can write the $proxy_bin PATH prepend:

- The pin sweep stopped six commands short. timeout, head, node, mktemp
  and mv still resolved through the prepend at thirteen added sites, and
  write_signal called rm bare 26 lines below the comment stating the
  rule. These fork from the step shell or the watcher subshell, not from
  the qwen invocation, so the invocation-scoped $GITHUB_* decoys never
  covered them and a plant inherited the real command files and
  GH_TOKEN. Capture all five before the prepend and pin every site.

- The CEDE kill identified its target by this PR's review URL, which is
  not this run's identity: an explicit run gets a unique per-run
  concurrency group and reviews the same URL concurrently, so a ceding
  automatic run killed it too. Its attempt then died with 143 — neither
  timeout nor success — so it went red over a push it had nothing to do
  with. Scope the sweep to this step shell's own children; GNU timeout
  signals its whole monitored group, so this run's tree still dies.

- START_TS, the baseline of both timeline bounds, read the unpinned
  date, so a plant answering +%s with a past epoch dropped both bounds
  and let the run's own triggering force-push corroborate a forged kill
  record — a green cede with nothing posted for a head that never moved.

Every guard is witnessed: replayed proxy-bin arms for each pinned
utility, a real-signal CEDE replay that spawns a decoy for this run and
one for a concurrent run of the same PR, and a capture-block property
test the env-pinned replays cannot see. All eleven mutation probes were
measured red without their guard.

* fix(ci): kill the superseded review attempt's process group, not just its timeout (#10123)

The supersede watcher's TERM->KILL escalation swept -P $$, which only ever
matched the timeout wrapper: SIGKILL is not forwarded and the wrapper's own
--kill-after timer dies with it, so a TERM-resistant member of the attempt
tree survived holding the tee pipe's write end. run_review_once then never
returned, the post-outcome cede checks were never reached, and the run went
red at the job timeout instead of ceding clean while the queued replacement
waited in the concurrency group.

Escalate against the process group that timeout leads instead, resolving its
pid through a pre-prepend pgrep capture before the TERM sweep retires it. The
sweep stays scoped to this step shell's own child matching this run's review
URL, so a concurrent explicit run of the same PR is still untouched.

* fix(ci): make the salvage replay suites truthful on macOS and pin the loop's last bare clocks

Takeover commit after 27 autofix rounds. Running the four suites on
macOS 26.6 (the merge_group/schedule-gated test_macos lane no round could
reach) turned up nine reds and eight silently skipped cases in
qwen-pr-review-workflow.test.js:

- The replay harness pinned QWEN_CI_REAL_TEE to /bin/tee; macOS ships
  tee in /usr/bin, so every replayed cede exited 127 under errexit.
  Resolve rm/tee through realUtilityPath like the other pins.
- hasMkfifo probed `mkfifo --help`, which BSD mkfifo rejects, so all
  FIFO wedge tests were skipped on the one platform the probe exists
  for although /usr/bin/mkfifo is present. Probe presence (ENOENT only);
  the eight cases now run and pass on macOS.
- The ~1.5 GB over-cap plants came from `head -c /dev/zero | tr`, ~65 s
  each on macOS (68 s per case in the suite). Generate them with yes(1)
  instead — same real non-hex bytes, ~2 s. A sparse file would not do:
  bash drops NUL bytes from a command substitution, so the unbounded-cat
  mutant these cases exist to catch would ship green.

Also, from the open Copilot threads:

- The retry loop's two budget computations still read bare `date` after
  the agent-writable $proxy_bin prepend; read the pinned
  QWEN_CI_REAL_DATE like START_TS does.
- The pre-loop attempt-start write_signal was the one salvage write not
  `|| true`, contradicting the reset's best-effort contract.
- The autofix design-doc test pinned the pre-existing af-149 anchor
  instead of the hold's own af-155 entry.
- .size-baseline entries set to the exact committed sizes (main-side
  growth of qwen-autofix.yml had drifted the ratchet by 2 KB).

* fix(ci): neutralize inherited review-lane captures in the replay harnesses and bound the salvage percent before arithmetic

R28-1: every replay harness spreads process.env into the replayed shell,
and the review lane exports the production QWEN_CI_REAL_* captures into
the reviewed agent's environment. Only GH/DATE/SLEEP were neutralized, so
on that lane the bounded-read witnesses resolved `timeout 5 head -c` to
the real utilities: runWatcher's two rename-swap FIFO wedge tests passed
without a FIFO ever being swapped in (28 ms / 11 ms instead of ~5 s), and
the runCedeRead / runSalvageOutputs swap witnesses went red. A sweep now
empties every inherited QWEN_CI_REAL_* key ahead of each harness's own
pins (runScenario, runWatcher, runCedeRead, runSalvageOutputs, and the
resolve suite's gh-wrapper harness) — a sweep, not a name list, so the
next capture production adds cannot reopen the door. Mutation-proven on
Linux under the exported-capture shape: the fixed tree keeps all seven
wedge tests green and exercised, the pre-fix tree reproduces the vacuous
pass and the two reds, and removing the `timeout 5` bound from the
workflow turns six of the seven red on both lanes.

Sandboxed verification F1: the salvage-percent parse clamped AFTER
`$((10#…))`, so a repo variable >= 2^63 wrapped silently — 2^64 to
exactly 0, "salvage always", and the run never ceded. Strip leading
zeros, then bound the digit count before any arithmetic; the replayed
parse test pins the wrap values, `0050`, `000` and `1000`.

F2: the retry loop's remaining bare `date` (deadline epoch, stop token)
and the backoff `sleep` now read the pinned captures like START_TS.

Round-28 deferred, taken because they mirror existing gates: the
post-loop `salvaged=` emission carries the same AUTO_REVIEW gate as the
five cede sites (an explicit run arms no watcher, so a marker there is a
forgery and must not become a historical-head note; replay gains the
explicit-run arm), and the af-099 design-record text describes the
supersede semantics instead of the removed synchronize cancel.

* fix(ci): keep the deadline-epoch line bare — packages/cli pins it as the units contract

packages/cli/src/commands/review/lib/deadline.test.ts ("the CI wiring
contract") pins the exact line
`QWEN_REVIEW_DEADLINE_EPOCH="$(( $(date +%s) + attempt_timeout ))"`
so the epoch stays whole seconds; e76afd48d4's clock pin on that line
turned the contract red in CI (Test (ubuntu), 1 failed / 28135 passed).
Restore the bare `date` there with a comment naming the contract; the
stop-token and backoff-sleep pins stay, and no other test pins them.

* docs(ci): record why the revert witness excludes only the agent's credential, and pin the queue half of the supersede model

R29-1/R29-2 asked supersede_reverted_during_run to exclude the PR
author from both witness loops. Declined, and the reasoning now sits at
the actor filter: the witness exists to keep the reviewed agent from
forging a move with the credential it inherits; the author needs no
forgery — one real head move per run before the salvage threshold
already cedes that run green by design, so an author-acted away-and-back
pair corroborating planted signals adds no capability, while excluding
the author would turn the author's own legitimate in-run revert into a
red failure.

Two round-29 deferrals taken in the resolve suite: the planted-FIFO
wedge asserts mkfifo really created a FIFO (a silent plant failure let
the case pass for the wrong reason), and `synchronize`/`closed` are
pinned as pull_request_target triggers — every CEDE branch relies on a
push queuing the replacement lifecycle run.

* fix(ci): make the supersede watcher's TERM->KILL wind-down immune to its reapers

R30-1: every reaper — the EXIT trap on a cede or fail(), the retry
branch, the post-loop reap — TERMs the one-shot watcher and waits, and a
TERM landing inside the 15s wind-down ended the watcher before its group
KILL, the only signal a TERM-resistant tree member answers; that member
then outlived the step on the shared runner holding the job's
credentials.

The watcher now sets `trap '' TERM` at the top of its CEDE branch,
before the first signal write: the polling phase stays TERM-killable so
a normal step end reaps it at once, and once the decision is made the
sleep and the kill inherit the ignore, so every reaper's kill+wait spans
the bounded wind-down and the KILL lands first. The EXIT trap gains the
wait the other two reapers already had, covering cede_superseded's exit 0
and fail() alike. Persisting attempt_pgid to a SALVAGE_DIR signal and
re-firing the KILL at each reap site was not taken: that file is
agent-writable, and a planted pgid would aim the group KILL at any
process group of the runner user. R25-1's group-KILL contract, the
unconditional relaunch and the 15s wind-down (<< the 60s backoff) are
unchanged.

Witness (replayed watcher, real pkill/pgrep): the realCedeKill decoys
plus a backgrounded watcher with a 3s real wind-down, TERM+wait fired the
moment the supersede file appears — reap >= 2s, own attempt and tree
member dead, concurrent run alive. Dropping the TERM ignore turns it red
(reap 0s, tree member alive) on macOS and Linux. The trap pin covers the
added wait; a shape pin keeps the ignore inside the CEDE branch.

* fix(ci): refresh on the capped round instead of deferring, and hand the review skill the salvage contract

R32-1 (regression): the report-step hold deferred the stale-base refresh
to "the next scan" even on the last permitted round, where the scan's
round gate parks the PR before any report step could run — the refresh
never happened and the headline promised a retry the cap forbids. The
deferral is now taken only while a next round exists
(`MARK_ROUND -lt MAX_ROUNDS`, the step's own cap); at the cap the hold
yields to the update-branch PUT, the pre-#10110 shape, and af-155 says
so. Witness: the rollup-probe-through-PUT block replayed with a stub gh
and the real jq — below the cap a live review defers, at the cap it
refreshes; dropping the guard turns the at-cap arm red.

R32-2 (new surface): the salvage escape admitted the historical-head
post, but the skill's presubmit rule (references/posting.md,
anchorsAtRisk=true) commanded abandon-and-restart in exactly the
drifted state a salvage creates — compose latches the artifact before
presubmit runs — so the modal salvage restarted instead of posting. The
review step now exports QWEN_REVIEW_SALVAGE_POST=1 inside the
AUTO_REVIEW-gated arming block (an explicit run never carries it), and
posting.md gains the one exception: with that signal AND a marker whose
content equals headDrift.reviewedSha, submit as in the
anchorsAtRisk=false branch and consume no restart. The historical-head
note step reads back the latest review by its own credential and claims
the anchor only when its commit_id equals the reviewed head, so a
restart that completed at the new head never gets an old-head note.
Cross-pinned by name: the review suite pins the gated export (exactly
one site) and replays the note step both ways; SKILL.test.ts pins the
exception sentence. The guard comment now names anchorsAtRisk.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-06 05:51:06 +00:00
qwen-code-dev-bot
aab0258857
fix(test): put the scripts suite's quiet-host budgets on its own knob (#11098) (#11106)
Release run 33957952281 lost Quality Checks (Scripts) to two budgets that
were measured on a quiet machine and met a contended one:

- install-script.test.js capped itself at 30s with vi.setConfig, overriding
  the 90s floor scripts/tests/vitest.config.ts sets for exactly this reason
  (#10870). A standalone-packaging case that costs 3s idle timed out at
  exactly 30000ms. The file's two per-test 30s arguments and its 15s/60s
  describe ceilings were the same quiet-host figure, so they go too.
- qwen-autofix-workflow.test.js bounded its deferred-findings subprocess at
  30s. That case costs 0.9s idle; killed mid-run, the stub `gh` recording
  truncates, so the timeout reported itself as a content mismatch on the
  first recorded call. The file's four hung-runner bounds now ride
  QWEN_SCRIPTS_TEST_TIMEOUT_MS. Its 3s/2s bounds pair with a stubbed
  QWEN_TIMEOUT_MS and are fast-fail guards, so they stay.

Both signatures were reproduced locally by shrinking each budget, then
reverted. A witness in unit-vitest-configs.test.ts fails if a file in the
suite caps the floor again; mutation-probed both ways.

The same run also lost Quality Checks (Static) to its 30-minute job timeout,
and the re-run on current main lost Static and Build the same way. That is
release-workflow capacity rather than test code, so it is escalated on the
issue instead of changed here.

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-09-06 01:16:27 +00:00
易良
7bd863daaf
fix(ci): stop the scripts-suite timeout knob from failing open on an empty value (#10910)
`scripts/tests/vitest.config.ts` read its ceiling as
`Number(process.env['QWEN_SCRIPTS_TEST_TIMEOUT_MS'] ?? 90_000)`. `??` only
catches `undefined`, so an empty value yields `Number('')` === 0, and vitest
reads 0 as "no timeout at all" — the knob meant to raise the ceiling would
instead remove it, and a hung test would run until the job cap. `NaN` from a
typo does the same.

`''` is not a hypothetical spelling. It is exactly what this repo's
`${{ cond && 'x' || '' }}` idiom renders when the condition is false, which is
how the sibling `QWEN_SKIP_LATENCY_BUDGETS` knob one line away in ci.yml is
wired. Nothing sets this variable in a workflow today, so the fault is latent
rather than live.

Measured, on this config:

    undefined -> 90000    '' -> 0    'abc' -> NaN    '5000' -> 5000

`||` instead of `??` sends every non-positive spelling to the default. The
cost is that 0 can no longer be passed to mean "no timeout"; that is a footgun
rather than a feature, and no caller uses it.

The companion pin could not have caught this. It stubbed `''` and then called
`vi.unstubAllEnvs()` on the next line, so both of its arms measured the unset
path and the one value that would fail was discarded. It now asserts `''`,
`'abc'` and `'0'` alongside the two original arms; `vi.stubEnv(k, undefined)`
deletes the variable, so the unset arm no longer needs the if/else. Verified
both ways: the rewritten pin fails on the old expression
(`expected +0 to be 90000`) and passes on the new one.

Also drops six per-test `}, 30000)` ceilings in qwen-autofix-workflow.test.js.
They predate the suite ceiling and now shadow it, pinning exactly the
bash-spawning cases that the 90s default exists to protect back down to the old
flat 30s. None of them asserts a duration property — each is a "give this
subprocess-spawning test room" budget, and the last one says so in its own
comment, which is updated to point at the suite ceiling. The three
`timeout: 30_000` options that remain are `spawnSync` bounds, deliberately
separate because spawnSync blocks the event loop where vitest's async timeout
cannot fire.

The ceiling removals were first proposed by qwen-code-dev-bot in #10858, which
conflicts with the knob that landed in #10870 and is superseded here.

Follow-up to #10870; the empty-value fault was raised there by doudouOUC (S1)
and chiga0 (R3-1) and confirmed post-merge by wenshao.
2026-09-06 01:00:42 +00:00
易良
689453ef96
fix(release): block the transport-timeout pass-through on any exception header (#10902)
* fix(release): block the transport-timeout pass-through on any exception header

The workspace-tests step may exit 0 over a Vitest worker-RPC timeout, but only
with proof the run reached its end and nothing else broke. The last leg of that
proof anchored on a bare `Error:`, and Node prints an unhandled exception under
its own class header — `TypeError:`, `AssertionError:`, `SyntaxError:` — so any
crash that is not literally `Error:` cleared all four legs and shipped as a
green release, behind an annotation claiming no other error was reported.

Reproduced against the step's own script with npm stubbed: a log carrying the
transport timeout, a `TypeError: Cannot read properties of null` with a stack,
and a passing tally exits 0 on main and 1 with this change. Widening the header
match to `[A-Za-z_$]*Error:` also covers the whole-file/per-run gap the same leg
had — a passing tally from one workspace no longer covers a later crash, since
that crash's header is now seen.

Two red probes pin both shapes. The legitimate pass-through (timeout plus a
passing tally, nothing else) is unchanged, as are the signal-death, `FAIL`-line
and `Error: write after end` paths.

Found by qwen-code-dev-bot reviewing #10805, which merged ~10 seconds before
that review landed, so the finding never gated it.

Claude-Session: https://claude.ai/code/session_01AWWgJEqafyAT1Mc75T8N7h

* fix(release): match coded and digit-bearing exception headers too

The previous widening to `[A-Za-z_$]*Error:` still enumerated header shapes and
left corners open, as the review probe showed:

- Node prints coded internal errors as `Name [ERR_CODE]: message`
  (`AssertionError [ERR_ASSERTION]:`, `Error [ERR_MODULE_NOT_FOUND]:`), with the
  bracketed code between the class name and the colon, so the anchored pattern
  never matched the line.
- The character class omitted digits, and this repo throws a digit-bearing
  class (`LargeNonUtf8TextError`).

Both now match, and `Exception` suffixes are accepted alongside `Error`.
Re-probed against the step's own script with npm stubbed: `AssertionError
[ERR_ASSERTION]:`, `TypeError [ERR_INVALID_ARG_TYPE]:`, `Error
[ERR_MODULE_NOT_FOUND]:`, `LargeNonUtf8TextError:` and the plain `TypeError:`
all flip to exit 1, while a clean transport-timeout log stays exit 0 so the
`transport timeout, run completed` case keeps its pass-through.

This narrows the class rather than closing it: a producer-defined name without
an `Error`/`Exception` suffix (`PoolTimeout:`) still clears the guard, verified
still exit 0. Closing it properly means deciding on an authoritative signal —
vitest's machine-readable report, or re-running the shard and requiring green —
instead of a wider header regex. Recorded here rather than left implicit.

* test(release): pin each constituent of the exception-header matcher

Mutation-checked the committed suite and confirmed the review's finding: three
parts of the widened matcher had no probe, so deleting any one left everything
green while reopening the hole.

  (Error|Exception) -> (Error)      DOMException:            exit 1 -> 0
  [A-Za-z0-9_$] -> [A-Za-z_$]       LargeNonUtf8TextError:   exit 1 -> 0
  [A-Za-z0-9_$] -> [A-Za-z0-9_]     Foo$Error:               exit 1 -> 0

Each new row goes red under its own mutant and green at HEAD, so they pin the
constituent rather than decorate the table. `LargeNonUtf8TextError` is this
repo's own class (`packages/core/src/utils/read-text-range.ts`), not a
hypothetical.

* fix(release): gate the transport-timeout pass-through on a green shard re-run

The pass-through certified the run by enumerating exception-header shapes in
the log, but the header is producer-chosen: a crash under a class with no
Error/Exception suffix (PoolTimeout:) beside a passing tally cleared every
guard and shipped green (R1-1). No enumeration closes that class, so the log
is no longer allowed to grant the pass — the conditions only fast-fail on
clear breakage evidence, and the pass now requires re-executing the same
shard once and demanding a green exit. A real break fails the re-run; a
transient worker-RPC death passes it. The probe rows flip accordingly: the
suffix-less header now yields both verdicts depending on the re-run.

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

* fix(release): certify the transport pass-through by Vitest's own error count

The pass-through certified a run by reading crash headers out of the log, and
no reading of headers can be complete: the class name is producer-chosen
(`PoolTimeout:`, a bare throw, a coded internal), which is the standing R1-1.
Re-running the shard would settle it, but the job's 45-minute budget is set by
contention and a shard that lost its transport has already spent up to 36 of
it, so a re-run does not fit in the one situation that produces these timeouts.

Vitest already publishes the number the guard was trying to infer: it prints
`Errors  N errors` whenever unhandled errors occurred. Summing that across the
log and comparing it with how many carried `[vitest-worker]: Timeout calling`
certifies the run without naming a single class. It closes the suffix-less and
bare-throw shapes no enumeration reaches, and it stops ordinary `Error:` lines
that a test merely printed from refusing the pass — the log this guard was
written for carries three of them.

Two pre-existing defects left the guard unreachable in production, and the
count is meaningless without them, so both are fixed here:

- The step ran under GitHub's default `bash -e {0}`, so `npm … | tee` yielded
  tee's status, the `||` handler never fired, and a shard with a genuinely
  failing test exited 0 (release run 33806806226, job 100824085040).
  `shell: bash` restores `-o pipefail`.
- The log is ANSI-coloured, which puts escapes between every label and its
  value, so three of the four conditions never matched real release bytes.
  `NO_COLOR` matches what ci.yml already sets on three steps.

18 probe rows drive the step extracted verbatim from the workflow; under
mutation each condition, the whole-file sum, both anchors and the plural in
the summary pattern dies to its own row.

* fix(release): print both compared counts when the pass-through is refused

The refusal annotation enumerated four causes, but the count comparison
adds a fifth that none of them described: `errors` and `timeouts` simply
disagreeing, with no crash in the log — the same transport message on two
lines is enough. A shard refused for that reason was told one of four
things happened when none did, and was not given the two numbers whose
mismatch was the whole story. The pass-side annotation already
interpolates `${errors}`, so name the fifth cause and print both figures.

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

* test(release): pin the step declarations and anchored patterns the guard needs

Three things the guard depends on had no test opposing their removal:

- `shell: 'bash'` is the step's only source of `-o pipefail` (the workflow
  has no `defaults:` block), so it is what makes the guard live rather than
  dead code. The probe harness passes `-o pipefail` itself, so deleting the
  line as "redundant" left every row green while GitHub fell back to
  `bash -e {0}` and a failing shard exited 0 into the release.
- `NO_COLOR: 'true'` is load-bearing for every anchored pattern: Vitest
  colours from the mere presence of CI, and escapes between a label and its
  value match nothing. The stub npm prints plain-text fixtures, so nothing
  noticed.
- The `^[[:space:]]*` and `$` anchors on the `Errors` sum had no
  discriminating row: the loosened pattern sums identically on every
  existing fixture. A summary-shaped line a test printed at column 0 is what
  separates them, and it fails closed — the false-red this PR removes.

Also adds the row for the count-mismatch refusal, asserting the annotation
carries both figures. Each mutant was run: dropping `shell`, dropping
`NO_COLOR`, unanchoring the awk sum, and dropping the interpolation each
turn exactly one of these assertions red.

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-06 00:44:00 +00:00
易良
948872b148
fix(web-shell): close the four deferred #9812 review follow-ups (#11107)
Each of the four suggestions deferred out of #9812 was left with nothing
pinning it, so the behaviour each one protects could regress silently.

Pin the historical sessionStorage key. `clientLifecycle.test.ts` only ever
round-tripped through `SESSION_CLIENT_ID_STORAGE_PREFIX`, so renaming that
constant moved the read and the write together and left every test green,
while a tab that persisted its id under the WebUI-era key lost it across the
migration. Three assertions now spell the key literally, including its
percent-encoded session suffix.

Make the ChatRecord export script testable and cover both paths. It had no
exports and ran `main()` at import, which is why the legacy-JSONL rejection —
a behaviour flip #9812 introduced — had no test. The input gate is now
`selectChatRecords`, the render is `renderHtmlFromObjects(objects, api)` with
the export API passed in, and `main()` runs only as the process entry point.
The CLI behaves as before. The new suite asserts the rejection message
verbatim and that the renderer is never reached, so removing the throw fails
rather than falling through to the neighbouring error.

Restore the substring-trap fixture the retirement deleted, under a live path.
`Shellfish.tsx` guards the keyword being the head of a stem, which is a
different loosening from the `web-shell` compound case already covered.

Replace the deleted `InputForm` in the public hook's docblock with
`ChatEditor`, which declares the three props, naming `ChatPane` and `App` as
the in-tree hosts.

Verification brief for the two vitest-backed items is committed alongside;
item 3 was run here, mutation included, and the mutant survives without the
new fixture.

Refs #11076, #9812.

Co-authored-by: yiliang114 <jinjing.zzj@gmail.com>
2026-09-05 23:40:57 +00:00
qwen-code-dev-bot
0c945a6136
fix(release): price the static quality lane from the reserved host (#11127)
* fix(release): price the static quality lane from the reserved host (#11121)

The Quality Checks (Static) lane runs prettier and then eslint on the
pinned hk4 host, whose load — not the tree — sets how long they take.
Measured on that pool: 17s + 141s on a quiet runner (run 33741188587),
928s + 645s eight hours before the run that needed 1348s for prettier
alone (run 33941624603), and then the flat 30-minute ceiling landing
with eslint still going. Run 33963757913 lost attempts 1, 3 and 5 that
way, on three different runners, each with nothing failing anywhere;
the aggregate quality job then failed closed on the cancelled lane and
opened #11121.

Give the lane the same retunable bound workspace_tests already uses,
defaulting to 60 minutes: enough for the measured worst case with room
for the pool to keep moving, and adjustable by an operator without a PR
when it does. Both pins in the release workflow suite — the new one and
the existing shared-pool bounds map — fail if the ceiling goes back to
a literal.

* docs(release): correct the evidence recorded in the static lane comment

"Nothing failing anywhere" overstated attempt 1: the build lane was
cancelled at its own 45-minute ceiling there too. Say what was actually
observed — no lint diagnostic and no failing test in any of the three
attempts, and a cancelled lane failing the quality aggregate.
2026-09-05 21:07:52 +00:00
qwen-code-dev-bot
e133150ed1
fix(release): let an operator retune the quality lane timeouts (#11104) (#11110)
* fix(release): let an operator retune the quality lane timeouts (#11104)

Release run 33963757913 lost v0.23.1-preview.0 with nothing wrong in the
tree: quality_static was killed at 30m13s in Run Lint and quality_build at
45m13s in Pack Build Outputs, every earlier step green, both thirteen
seconds past their own timeout-minutes. Four heavy lanes had started within
a second of each other on the reserved hk4 host and shared its CPUs —
`npm run format` alone ran 20m45s. A timeout kill reports as 'cancelled',
the quality aggregate fails closed on any component that did not succeed,
and notify_failure filed the issue against a clean tree.

These were the next lanes to go, not a surprise. The last successful release
(33941624603) cleared quality_static at 29.1m against its 30m bound and
quality_build at 43.7m against 45m — 54 and 78 seconds of headroom. Runtime
tracks fleet load rather than the work, which is why the workspace shard
bound is already a variable; the operator has since raised it, and those
shards ran 61.8-64.5m past a 45m fallback. The two lanes that just died had
no knob to raise.

Same remedy: both bounds read a repository variable and keep today's numbers
as the fallback, so behaviour is unchanged until an operator sets one. The
aggregate still fails closed on a timeout — a release that never finished
validating should notify — so this makes the bound movable, not the failure
quieter.

Defaults are deliberately unchanged, so stopping the recurrence means
setting QWEN_RELEASE_STATIC_TIMEOUT_MINUTES and
QWEN_RELEASE_BUILD_TIMEOUT_MINUTES. The observed maxima suggest 45 and 70,
but the right numbers are a fleet-load call for whoever operates the host.

* fix(release): report the quality lane timeout budget in effect (#11104)

Review follow-ups to the two quality lane knobs. No bound and no default
changes.

A knob whose value the run never states is indistinguishable from a knob
nobody set: a misspelled variable name renders '' and the lane dies at its
fallback again, and a variable set under Settings -> Environments does the
same because neither lane has an `environment:` key. Each tunable lane now
reports the budget it resolved and whether the variable reached the job at
all, in the `::notice::` idiom this file already uses. The reported budget
is the lane's own `timeout-minutes` expression rather than a copy of it, so
the trace cannot drift from what the runner enforces; the knob test pins
that equality and goes red if the step, the notice, or the set= signal is
removed.

The value is free text under a name ending in MINUTES, so the accepted
format is now named beside the knob rather than left to the variable name:
a bare positive integer, not `70m`, `1h` or a quoted `'70'`. Both measured
consequences are recorded there too — GitHub refuses to create the job and
the run blames 'a workflow file issue' instead of the variable, and `0` is
not "no limit" because the string '0' is truthy in a GitHub `||`, so the
knob wins and GitHub then ignores the zero bound it was handed. The
fail-closed consequence is recorded because it is the reason no clamp was
added: a value GitHub cannot turn into a number drops that one lane, the
quality aggregate reads it as 'failure', and a bad value can refuse a
release but never ship one unvalidated. Clamping only these two knobs would
also have left three same-kind knobs in one file on two different
contracts, since QWEN_RELEASE_WORKSPACE_TIMEOUT_MINUTES already uses the
raw form and is set to 75 in the live repository.

sdk-java.yml justified its own 45 by citing "the 45 tier release.yml's
quality_build uses". That number is mutable now, so the citation names the
variable and its default instead. Citation only: daemon-e2e derives 45 from
its own npm-ci measurements and runs on ecs-qwen, not release.yml's
ecs-qwen-hk4-host, so nothing there was retuned or knob-ified.
2026-09-05 18:15:28 +00:00
易良
c516740a27
chore(ci): remove retired WebUI dependency guard (#11095)
* test(ci): include WebUI guard in lane contract

* fix(release): extend quality check timeouts

* chore(ci): remove retired WebUI dependency guard
2026-09-05 11:29:54 +00:00
易良
077c57d200
refactor!: retire @qwen-code/webui (#9812)
* feat(transcript): add cross-host document export pipeline

Establish a shared transcript model and document-mode projection so Web
Shell, VS Code, and HTML export can consume the same stable conversation
semantics without changing interactive rendering.

- Preserve daemon and ACP segment identity across replay and normalization
- Add export-safe previews and a versioned transcript document builder
- Add document-mode Web Shell rendering with bounded Mermaid processing
- Lock direct-daemon and ACP behavior with contract fixtures
- Cover render and export equivalence in integration tests

* feat(transcript): wire real VS Code and HTML export consumers

Move transcript prevalidation onto real product paths while preserving
the legacy VS Code timeline as the default fallback.

- Add version-bound document HTML with strict CSP and schema checks
- Add opt-in ACP projection with stable source identity
- Reuse product export code across hosts and the integration runner
- Remove test-only production probes and consolidate shared validation

Note: Browser, host-action, VSIX, and packaging gates remain pending.

* build(deps): sync transcript workspace dependencies

Keep the npm lockfile aligned with the VS Code and HTML export package
manifests added by the transcript consumer migration.

- Lock the VS Code Web Shell workspace dependency
- Lock Web Templates SDK, Web Shell, and React build dependencies

* feat(vscode-ide-companion): reuse WebShell transcript UI behind experimental flag

Bridge ACP session/update notifications into the shared SDK daemon transcript reducer and render the result with the WebShell transcript component, gated on qwen-code.experimental.webShellTranscript (default off).

The WebShell renderer and its heavy transitive dependencies (echarts, mermaid, shiki, codemirror, katex) are lazily loaded via esbuild code splitting, so the default configuration keeps the ~700KB webview bundle unchanged.

* fix(vscode-ide-companion): grant wasm-unsafe-eval for shiki WASM when WebShell transcript enabled

* fix(transcript): harden export and identity paths

Resolve review findings across document export and the VS Code ACP
timeline while preserving default interactive and readonly semantics.

- Preserve stable text and non-text identity across live and replay
- Harden export projection, budgets, URL handling, CSP, and nonces
- Make document rendering complete, inert, and browser-validated
- Keep VS Code transcript scope, theme, copy, and flags reactive
- Restore fixture, schema, hash, and compatibility contract locks

Note: The overall gate remains failed pending VSIX, host-action, and
packaged-artifact evidence.

* feat(vscode-ide-companion): adopt WebShell transcript as default timeline

Drop the experimental flag and the legacy MessageList renderer. The companion timeline now always renders through the shared WebShell transcript component, fed by ACP session/update notifications via the SDK daemon transcript reducer (lazy loaded through esbuild code splitting).

The flag-gated wiring is removed: the qwen-code.experimental.webShellTranscript setting, the conditional CSP/body attribute in WebViewContent, and the legacy MessageList path in App.tsx (~850 lines). The webview CSP now grants wasm-unsafe-eval unconditionally for Shiki's Oniguruma WASM.

* fix(vscode-ide-companion): reset WebShell transcript state on session switch

The experimental useAcpTranscript hook only consumed transcriptUpdate
messages, so its reducer state survived session boundaries. When the
extension switched sessions it kept the webview mounted and replayed the
newly-selected session through ACP, causing the previous session's blocks
to merge with the new replay (e.g. user text "alpha" from session A leaked
into session B as "alphabeta").

Reset both the reducer state and the rendered blocks on the same
boundaries the legacy message flow uses: qwenSessionSwitched (sent before
the ACP replay of the selected session) and conversationCleared (new
session). Adds a regression test that replays two sessions with a switch
between them.

* fix(vscode-ide-companion): harden WebShell transcript session boundaries

- reset the transcript on `conversationLoaded` too, closing the same
  cross-session leak the previous commit fixed for `qwenSessionSwitched`
  and `conversationCleared` (agent reconnect posts only this boundary)
- track the active session id and drop late `transcriptUpdate` frames
  whose `sessionId` no longer matches, so a previous session's trailing
  frames cannot contaminate the next session's timeline
- seed the transcript from cached messages carried by
  `qwenSessionSwitched` so offline restores and load-failure fallbacks
  render their history instead of a blank timeline
- dispatch `assistant.done` on `streamEnd`/`sessionLoadComplete` so the
  final assistant/thought block of a turn (or history replay) does not
  stay `streaming: true` forever

* fix(transcript): harden identity and document export

Close the latest review findings across transcript identity, VS Code
rehydration, and the HTML export security boundary.

- Terminate discrete ACP segments and bind automatic turn provenance
- Reset VS Code transcript scopes across replay and reconnect lifecycles
- Align Markdown sanitization and envelope budgets with document rendering
- Run browser gates with Chromium and complete third-party notices

* fix(vscode-ide-companion): adopt live ACP session id after load-failure fallback

* fix(vscode-ide-companion): echo user prompt into WebShell transcript

* fix(vscode-ide-companion): keep WebShell transcript expanded and clear of the composer

* fix(vscode-ide-companion): surface local error and interrupt notices in the transcript area

* fix(vscode-ide-companion): restore file-link opening from the WebShell transcript

* fix(vscode-ide-companion): restore contributed copy commands for the WebShell transcript

* fix(vscode-ide-companion): add localOnly marker to TextMessage state type

* fix(vscode-ide-companion): restore /insight progress card and report link in the transcript UI

* fix(vscode-ide-companion): finalize in-flight tools on timeout and pin session-switch seeding guard

Map streamEnd reasons timeout/session_expired onto the reducer's error reason so abandoned mid-tool turns no longer spin forever (ceuI). Add qwenSessionSwitched cases with no messages field and an empty cache array; the no-messages case fails when the seeding guard is forced true, pinning its false side (ceuN).

* fix(vscode-ide-companion): remove unreachable editMessage backend and dead submit options

The user-message edit/rewind UI was dropped in the WebShell-transcript migration, leaving editTargetTurnIndex/onSubmitted options in useMessageSubmit and the full editMessage/rewind flow in SessionMessageHandler unreachable. Remove the dead options, the editMessage dispatch case, the rewind/snapshot flow with its recovery branches, and their tests (R1-8 direction b).

* fix(vscode-ide-companion): drop write-only loadingMessage bookkeeping

The waiting-message renderer was removed with the WebShell transcript migration and the user prompt is echoed into the timeline at send time (bd09e19d86), so the loadingMessage string was write-only dead state. Keep the isWaitingForResponse flag (submit gating / cancel) and pin its API surface (R1-19 direction b).

* fix(vscode-ide-companion): align waiting-flag pin test with the argument-less setter

* fix(transcript): simplify adapters and preserve segment boundaries

Reduce the shared transcript review surface while keeping the VS Code and
HTML Export consumers and their security gates intact.

- Merge VS Code feature state into the ACP transcript hook
- Collect source identity once and strip it at the compatibility boundary
- Remove test-only export adapters, gate reports, and duplicate helpers
- Keep shell output separated when stable producer segments change
- Mark third-party notices as generated review content

* fix(transcript): address cross-host review regressions

* fix(vscode-ide-companion): echo attached images into the transcript timeline

The prompt carries pasted/attached images as ACP resource_link blocks,
which the transcript reducer cannot render (no inline data), so user
images vanished from the timeline while the attach path stayed alive.
Read each saved prompt image back from disk and echo it alongside the
text echo as an inline user_message_chunk image part (the daemon-echo
content shape), which the shared reducer folds into the user block and
the WebShell renderer already displays. Unreadable images are skipped
without breaking the send.

* fix(vscode-ide-companion): track live VS Code theme for the transcript

webShellTheme was snapshotted once at mount via useMemo with an empty
dependency array, so switching the VS Code color theme left the
timeline on the stale theme (VS Code updates data-vscode-theme-kind on
<body> in place without reloading the webview). Hold the theme in state
and refresh it with a MutationObserver on the body theme attributes.

* fix(vscode-ide-companion): copy every transcript block kind and map ambiguous row keys

- Copy All Messages now includes tool, shell, user_shell, and status
  blocks via getBlockCopyText, matching the pre-PR copyAllMessages
  handler which included formatted tool calls (review 5001842059 S-1).
- findBlockByRowKey prefers an exact id match and otherwise the longest
  matching block id, so one block id that dash-prefixes a sibling (e.g.
  `a` vs `a-1`) can no longer capture the sibling's row key (S-4).

* fix(vscode-ide-companion): drop whitespace-only cached transcript rows

cachedMessageToNotification rejected empty strings but admitted
whitespace-only content, which the reducer turns into an empty block
when seeding history from cached rows. Reject content that trims to
nothing (review 5001842059 S-2).

* fix(vscode-ide-companion): ship missing third-party notices in NOTICES.txt

Extend generate-notices.js so the regenerated NOTICES.txt carries the
attribution texts it previously only pointed at or dropped:

- Append license files from a package's licenses/ directory (echarts'
  Apache LICENSE references licenses/LICENSE-d3 for its embedded
  d3-derived files; the BSD-3-Clause text is now shipped).
- Append a package's NOTICE file when present (Apache-2.0 §4(d)),
  covering echarts' Apache Software Foundation attribution.
- Accept string-form package.json repository values (full URLs and
  GitHub shorthand) instead of emitting "(No repository found)".
- Fall back to the standard MIT text (copyright holder from package.json
  metadata) for MIT-declared packages that ship no license file.

* fix(vscode-ide-companion): show a recoverable error state when the transcript chunk fails to load

* test(vscode-ide-companion): gate the transcript blocks wiring into the WebShell renderer

* test(vscode-ide-companion): gate the transcriptUpdate forwarding from agent to webview

* docs(vscode): plan complete Web Shell cutover

* docs(webui): plan legacy package retirement

* docs(webui): link cutover prerequisite

* feat(vscode-ide-companion): reuse WebShell transcript UI behind experimental flag

Bridge ACP session/update notifications into the shared SDK daemon transcript reducer and render the result with the WebShell transcript component, gated on qwen-code.experimental.webShellTranscript (default off).

The WebShell renderer and its heavy transitive dependencies (echarts, mermaid, shiki, codemirror, katex) are lazily loaded via esbuild code splitting, so the default configuration keeps the ~700KB webview bundle unchanged.

* fix(vscode-ide-companion): grant wasm-unsafe-eval for shiki WASM when WebShell transcript enabled

* feat(vscode-ide-companion): adopt WebShell transcript as default timeline

Drop the experimental flag and the legacy MessageList renderer. The companion timeline now always renders through the shared WebShell transcript component, fed by ACP session/update notifications via the SDK daemon transcript reducer (lazy loaded through esbuild code splitting).

The flag-gated wiring is removed: the qwen-code.experimental.webShellTranscript setting, the conditional CSP/body attribute in WebViewContent, and the legacy MessageList path in App.tsx (~850 lines). The webview CSP now grants wasm-unsafe-eval unconditionally for Shiki's Oniguruma WASM.

* fix(vscode-ide-companion): reset WebShell transcript state on session switch

The experimental useAcpTranscript hook only consumed transcriptUpdate
messages, so its reducer state survived session boundaries. When the
extension switched sessions it kept the webview mounted and replayed the
newly-selected session through ACP, causing the previous session's blocks
to merge with the new replay (e.g. user text "alpha" from session A leaked
into session B as "alphabeta").

Reset both the reducer state and the rendered blocks on the same
boundaries the legacy message flow uses: qwenSessionSwitched (sent before
the ACP replay of the selected session) and conversationCleared (new
session). Adds a regression test that replays two sessions with a switch
between them.

* fix(vscode-ide-companion): harden WebShell transcript session boundaries

- reset the transcript on `conversationLoaded` too, closing the same
  cross-session leak the previous commit fixed for `qwenSessionSwitched`
  and `conversationCleared` (agent reconnect posts only this boundary)
- track the active session id and drop late `transcriptUpdate` frames
  whose `sessionId` no longer matches, so a previous session's trailing
  frames cannot contaminate the next session's timeline
- seed the transcript from cached messages carried by
  `qwenSessionSwitched` so offline restores and load-failure fallbacks
  render their history instead of a blank timeline
- dispatch `assistant.done` on `streamEnd`/`sessionLoadComplete` so the
  final assistant/thought block of a turn (or history replay) does not
  stay `streaming: true` forever

* fix(vscode-ide-companion): adopt live ACP session id after load-failure fallback

* fix(vscode-ide-companion): echo user prompt into WebShell transcript

* fix(vscode-ide-companion): keep WebShell transcript expanded and clear of the composer

* fix(vscode-ide-companion): surface local error and interrupt notices in the transcript area

* fix(vscode-ide-companion): restore file-link opening from the WebShell transcript

* fix(vscode-ide-companion): restore contributed copy commands for the WebShell transcript

* fix(vscode-ide-companion): add localOnly marker to TextMessage state type

* fix(vscode-ide-companion): restore /insight progress card and report link in the transcript UI

* fix(vscode-ide-companion): finalize in-flight tools on timeout and pin session-switch seeding guard

Map streamEnd reasons timeout/session_expired onto the reducer's error reason so abandoned mid-tool turns no longer spin forever (ceuI). Add qwenSessionSwitched cases with no messages field and an empty cache array; the no-messages case fails when the seeding guard is forced true, pinning its false side (ceuN).

* fix(vscode-ide-companion): remove unreachable editMessage backend and dead submit options

The user-message edit/rewind UI was dropped in the WebShell-transcript migration, leaving editTargetTurnIndex/onSubmitted options in useMessageSubmit and the full editMessage/rewind flow in SessionMessageHandler unreachable. Remove the dead options, the editMessage dispatch case, the rewind/snapshot flow with its recovery branches, and their tests (R1-8 direction b).

* fix(vscode-ide-companion): drop write-only loadingMessage bookkeeping

The waiting-message renderer was removed with the WebShell transcript migration and the user prompt is echoed into the timeline at send time (bd09e19d86), so the loadingMessage string was write-only dead state. Keep the isWaitingForResponse flag (submit gating / cancel) and pin its API surface (R1-19 direction b).

* fix(vscode-ide-companion): align waiting-flag pin test with the argument-less setter

* fix(vscode-ide-companion): echo attached images into the transcript timeline

The prompt carries pasted/attached images as ACP resource_link blocks,
which the transcript reducer cannot render (no inline data), so user
images vanished from the timeline while the attach path stayed alive.
Read each saved prompt image back from disk and echo it alongside the
text echo as an inline user_message_chunk image part (the daemon-echo
content shape), which the shared reducer folds into the user block and
the WebShell renderer already displays. Unreadable images are skipped
without breaking the send.

* fix(vscode-ide-companion): track live VS Code theme for the transcript

webShellTheme was snapshotted once at mount via useMemo with an empty
dependency array, so switching the VS Code color theme left the
timeline on the stale theme (VS Code updates data-vscode-theme-kind on
<body> in place without reloading the webview). Hold the theme in state
and refresh it with a MutationObserver on the body theme attributes.

* fix(vscode-ide-companion): copy every transcript block kind and map ambiguous row keys

- Copy All Messages now includes tool, shell, user_shell, and status
  blocks via getBlockCopyText, matching the pre-PR copyAllMessages
  handler which included formatted tool calls (review 5001842059 S-1).
- findBlockByRowKey prefers an exact id match and otherwise the longest
  matching block id, so one block id that dash-prefixes a sibling (e.g.
  `a` vs `a-1`) can no longer capture the sibling's row key (S-4).

* fix(vscode-ide-companion): drop whitespace-only cached transcript rows

cachedMessageToNotification rejected empty strings but admitted
whitespace-only content, which the reducer turns into an empty block
when seeding history from cached rows. Reject content that trims to
nothing (review 5001842059 S-2).

* fix(vscode-ide-companion): ship missing third-party notices in NOTICES.txt

Extend generate-notices.js so the regenerated NOTICES.txt carries the
attribution texts it previously only pointed at or dropped:

- Append license files from a package's licenses/ directory (echarts'
  Apache LICENSE references licenses/LICENSE-d3 for its embedded
  d3-derived files; the BSD-3-Clause text is now shipped).
- Append a package's NOTICE file when present (Apache-2.0 §4(d)),
  covering echarts' Apache Software Foundation attribution.
- Accept string-form package.json repository values (full URLs and
  GitHub shorthand) instead of emitting "(No repository found)".
- Fall back to the standard MIT text (copyright holder from package.json
  metadata) for MIT-declared packages that ship no license file.

* fix(vscode-ide-companion): show a recoverable error state when the transcript chunk fails to load

* test(vscode-ide-companion): gate the transcript blocks wiring into the WebShell renderer

* test(vscode-ide-companion): gate the transcriptUpdate forwarding from agent to webview

* docs(vscode): plan complete Web Shell cutover

* fix(transcript): harden export sanitization and user identity

Close the latest review findings around document resource safety and
recorded-user replay consistency.

- Redact local home paths structurally without corrupting remote URLs
- Sanitize nested Markdown images inside otherwise safe links
- Advance merged segment provenance without duplicating separators
- Keep recorded-user stable IDs anchored to durable record identity

* refactor(web-shell): own daemon React bindings

* fix(webui): preserve package entry filenames

* refactor(vscode): complete WebShell UI cutover

* chore(vscode): refresh third-party notices

* fix(vscode): fill embedded chat viewport

* test(web-shell): disambiguate workspace visual locator

* docs: clarify webui retirement prerequisites

* fix(transcript): resolve export and CI blockers

* fix(vscode): match embedded chat layout to host

* fix(vscode): compact embedded chat styling

* test(ci): cover dual Playwright installs

* fix(vscode): align embedded chat density with VS Code

* fix(vscode): complete embedded composer integration

* refactor: retire legacy webui package

* chore: refresh lockfile after webui removal

* fix(vscode): restore user message editing after cutover

* refactor: narrow webui retirement to export and removal

* fix(vscode): complete WebShell feature parity

* test(vscode-ide-companion): repair host-wiring tests for the WebShell cutover

* refactor(vscode-ide-companion): replace webui build scanner with an ESLint boundary rule

The bespoke recursive source scanner reimplemented a dependency-boundary
check on every extension build. A scoped no-restricted-imports rule
enforces the same boundary on every lint run with less custom code; the
manifest dependency entry was already removed by the cutover.

* fix(web-shell): keep ChatEditor commands prop referentially stable (#9811)

The `additionalSlashCommands = []` destructure default allocated a fresh
array on every App render, invalidating the `commands` useMemo and breaking
ChatEditor memoization on every transcript-only re-render. Default to a
module-level constant instead, matching the existing EMPTY_* convention.

Also align the /skills completion expectation with the autoSubmit field the
completion source intentionally emits for leaf skill items.

* fix(vscode): distinguish the VS Code channel and localize its chrome

The companion now drives Web Shell against a shared `qwen serve` daemon,
so the CLI, the browser Web Shell, and this extension all create sessions
in the same workspace catalog. Web Shell recorded `'default'` for every
surface, leaving VS Code conversations indistinguishable from terminal and
browser ones — the panel's history listed sessions the user never opened
here, and nothing attributed a session back to the editor.

Give Web Shell a `sessionSourceType` prop (defaulting to today's
`'default'`) and have the companion stamp `'vscode'` on the sessions it
creates, then scope the history dropdown to that source. The host also
supplies a stable daemon `clientId`, which the bootstrap previously
declared but never sent.

Web Shell localizes its own surface from the `language` signal while the
companion's chrome was hardcoded English, so a zh-CN panel rendered a
Chinese transcript under an English header, history dropdown, onboarding
screen, and account dialog. Route that chrome through a small string table
driven by the same signal, including the host-only slash entries.

Also fix accessibility defects in the history dropdown: rename and delete
were revealed on hover alone and unreachable by keyboard, date headers sat
inside `role="listbox"` as invalid non-option children, arrow-key roving
stopped at group boundaries, `aria-modal` had no focus trap, and a primed
"Delete?" survived both search changes and the pointer leaving the row.

Formatting: `FileMessageHandler` and `SessionMessageHandler` were left
unformatted earlier in this branch and failed the Prettier gate.

* refactor(vscode): drop code orphaned by the WebShell cutover

The webview entry now renders EmbeddedApp against the daemon, which left
the ACP-era hook layer unreachable: nothing imports acpTranscriptAdapter,
useWebViewMessages, useAcpTranscript, useToolCalls, useSessionManagement,
useMessageHandling, useFileContext, useImage, or the permissionTypes added
by this branch. A reachability walk from webview/index.tsx reaches eight
modules; every reference to the rest comes from inside the orphaned set
itself, so it deletes as a closed unit.

EmbeddedWebShell goes with them. It was the host-driven entry point from
the earlier stage of this branch, superseded when EmbeddedApp moved to
WebShellWithProviders, and has had no consumer since — only its own DOM
test and a barrel export.

Also harden the daemon process lifecycle. `start()` returned the cached
runtime without comparing the workspace, so in a multi-root window the
second folder's chat silently reused a daemon bound to the first and
scoped every session, history page, and prompt to the wrong root. Bind the
daemon to its workspace and respawn on a change, keep a superseded child's
late exit from tearing down its successor, and report a post-startup exit
to the webview instead of leaving it fetching against a dead port.

* docs(vscode): describe the daemon architecture the cutover actually ships

The design doc still recorded the plan this branch started from: keep ACP
as the runtime boundary, add no daemon server or loopback port, and treat
"replacing ACP with daemon HTTP/SSE" as a non-goal. The final stage did
exactly that, so the document argued against the code beneath it.

Record the decision and its consequences instead — two processes per
workspace, a daemon shared with the CLI and browser Web Shell, the vscode
source type that keeps the panel's history its own, workspace rebinding in
multi-root windows, and the turn-driven host features that stopped firing.

* fix(vscode): repair round-2 review findings on the web-shell cutover (#9811)

- closeDiff now resolves workspace-relative paths the same way showDiff
  does, so permission-cycle diffs opened from daemon-relative paths can
  actually be matched and closed
- a superseded or disposed daemon child no longer reports its exit as a
  crash of the live daemon
- authCancelled no longer hides an already-authenticated session behind
  onboarding; only an unknown auth state settles to unauthenticated
- selection-only activeEditorChanged events no longer undo an explicit
  active-file exclusion
- prepareSubmit dedupes mentions in both path spaces and matches typed
  references on a whole-reference boundary
- permission diffs open only from the SDK's authoritative file_diff
  preview (writes included, model-controlled toolCall mining removed)
- the webview HTML carries VS Code's locale so chrome strings localize
- discontinued qwen-oauth models are no longer re-applied through the
  new-session initial-model route

* fix(vscode): repair round-3 critical findings on the web-shell cutover (#9811)

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

* fix(web-shell): import daemon-react-sdk from web-shell instead of webui

The cutover branch dropped the ./daemon-react-sdk export from @qwen-code/webui,
but the TerminalPanel merged in from main still imports it, breaking the
web-shell vite build (Missing "./daemon-react-sdk" specifier). Point the import
and its test mock at @qwen-code/web-shell/daemon-react-sdk, which re-exports the
same useWorkspace hook and matches every other web-shell call site.

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

* fix(vscode): close WebShell UI regression gaps

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

* fix(vscode): initialize WebShell refs explicitly

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

* fix(vscode): narrow queued prompt edits

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

* fix(release): enumerate actual npm workspaces

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

* fix(web-shell): include hasOlderHistory in the render-item callback deps

The renderItem useCallback reads hasOlderHistory to gate the edit action
but omitted it from its dependency array, failing CI's
react-hooks/exhaustive-deps gate.

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

* fix(web-shell): report each connection error once to stop the inline onError re-render loop (#10454)

* fix(web-shell): report each connection error once to stop the onError re-render loop

While a connection error persists (e.g. the daemon is unreachable), the
error-notification effect re-fires whenever the onError callback identity
changes. Hosts such as the VS Code embedded app pass an inline onError and
update their own state when it fires, so every notification triggers a host
re-render that hands the effect a fresh callback identity — re-notifying the
same persistent error forever (#10406).

Track the last reported connection.error value in a ref and notify only when
the value changes, resetting the tracker once the connection recovers. This
guards every inline-callback consumer, not just memoized hosts.

Fixes #10406

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

* fix(web-shell): only stamp the dedup ref once an onError handler exists

Stamping lastReportedConnectionErrorRef before delivery meant a host
that attaches onError after a persistent connection error appeared never
received it: the no-op delivery already marked the error as reported.
Guard on the handler first and add a regression test covering the
late-attach case (red when the guard is removed).

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

* docs(web-shell): document the onError dedup contract and fix comment wording

Describe the reported-once-per-distinct-error semantics, the reset on
recovery, and that replacing the handler mid-error does not re-deliver.
Reword the effect and test comments to describe the host class instead
of naming the VS Code embedded app, which passes a useCallback handler.

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

* test(vscode): mirror the web-shell value-dedup in the EmbeddedApp mock

The WebShellWithProviders mock re-notified on every onError identity
change, mirroring the loop App.tsx can no longer produce. Rewrite it to
report each distinct error value once (resetting on recovery), keep the
loop guard as a regression tripwire, exercise it with a changing
callback identity plus a post-delivery effect re-run, and refresh the
handleShellError comment that still cited the old loop as the
memoization reason.

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

* test(vscode): cast the captured onError prop for the mock wrapper

CapturedProps is an unknown index signature, so the destructured
onError needs the same cast the previous mock applied inline to stay
callable under tsc.

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

* test(vscode): bail the EmbeddedApp mock before stamping when no onError exists

The mirrored dedup effect stamped lastReportedError and counted a
notification even when no handler was attached, while App.tsx returns
before stamping on that path. Add the same early return so a handler
attached mid-error still receives the persistent error, and pin the
no-handler no-stamp behavior with a test that fails if the guard is
removed.

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

---------

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

* fix(web-shell): remove duplicate history dependency

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

* fix(vscode): close remaining WebShell cutover regressions

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

* fix(vscode): keep permission diff handling host-scoped

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

* test(vscode): remove orphaned completion trigger test

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

* test(web-shell): update daemon SDK mock import

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

* fix(export): reject unsupported legacy JSONL

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

* docs(transcript): record the legacy HTML renderer deletion evidence

The contract prevalidation doc is this design's single normative source
(§0.1), so retiring the legacy renderer has to be written back into it.
Three places had drifted:

- §2.1's consumer table still said the HTML Export path "keeps legacy
  compatibility for public calls without records". `toHtml` now requires
  records and the legacy branch is gone.
- §14's completion gate said the legacy HTML renderer is removed only once
  there is deletion evidence. New §12.4 records that evidence — no product
  consumer left, signature tightened, fallback explicitly rejected rather
  than silently degraded, and a `check:no-webui` guard against
  reintroduction — and keeps the VS Code legacy timeline half of the gate
  untouched.
- §10.7 promised credential removal without stating its scope. Spell out
  that it is http(s) only, and that non-navigable schemes, bare flag
  credentials and `code` / `inlineCode` nodes ship verbatim, so the
  document boundary is not read as a general secret scanner.

Also comment the two export format signatures: `records` is required
because HTML projects from original records, and the other formatters
ignore the argument.

Docs and comments only; no build or test was run locally.

* fix(docs): correct web-shell SDK hook names, daemon dir table, followup wiring

- daemon-client-adapters/web-shell.md: use the names the barrel actually
  exports (useActions/useConnection/usePendingPermissions/
  useTranscriptBlocks) in the import example and minimal React shape
- daemon/14-cli-tui-adapter.md: rewrite the
  packages/web-shell/client/daemon/ file table to the real layout
  (session/DaemonSessionProvider.tsx; the transcript adapter now lives
  in client/adapters/ and only exports extractPendingPermission)
- users/features/followup-suggestions.md: suggestion generation is
  automatic server-side; Web Shell hosts need no trigger wiring

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

* fix(scripts): catch bare packages/webui references in check:no-webui

Widen the second forbidden pattern to /packages\/webui\b/g so
references followed by spaces, punctuation, or end-of-line are flagged
(CI step text, YAML list items, parenthesized mentions), while
lookalikes such as packages/webuix stay clean. Extract the detection
into containsForbiddenReference() and pin it with scripts/tests
fixtures, following the check-tui-dep-direction export convention.
The current tree still scans clean:
node scripts/check-no-webui-dependency.js exits 0.

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

* fix(docs): drop dead Adapter Matrix citation and stale Chromatic line

- 16-vscode-ide-adapter.md: 01-architecture.md has no "Adapter Matrix"
  section (headings verified at HEAD); drop the dead citation tail and
  keep the verified embedding claim (R1-11).
- terminal-capture/motivation.md: Chromatic retired with the webui
  package; point the "complement" item at the Playwright-based Web Shell
  visual tests already shown in this file's diagram (R1-12).

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

* fix(comments): correct stale webui-era notes in SDK test and followup state

- daemonUi.test.ts: the referenced previewMarkdown/rawOutput preservation
  test does not exist in web-shell (transcriptAdapter.test.ts only covers
  extractPendingPermission); rewrite the note to say the enrichment path
  retired with the webui package instead of citing a nonexistent test
  (R1-6). Wording avoids the retired package literal so check:no-webui
  still passes.
- followupState.ts: no web-shell file imports this module (its daemon
  followup hook keeps its own FollowupState/controller; the only
  createFollowupController consumer is the CLI Ink hook); fix the module
  purpose comment and the createFollowupController docblock accordingly
  (R1-13).

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

* fix(export): load transcript renderer from unpkg (#11035)

* fix(export): load transcript renderer from CDN

* fix(export): harden CDN renderer loading

* fix(export): host transcript renderer on project OSS

* fix(export): serve npm renderer through unpkg

* fix(release): verify export renderer before VSIX packaging

* docs(daemon): retire the stale ACPAdapter and web-ui.md pointers

R1-2/R1-4 from the review round: the Consumers note still claimed the old
ACPAdapter host postMessage path "remains available" while this PR deletes
the only ACPAdapter, and the migration-status bullet still pointed at
web-ui.md after this PR renamed it to web-shell.md. Both lines now state
what ships: the postMessage path retired with packages/webui (webviews
embed Web Shell, matching 16-vscode-ide-adapter.md), and the adapter
design doc is web-shell.md.

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

* docs: avoid retired webui path reference

* fix: close WebUI retirement review gaps

---------

Co-authored-by: heyang.why <heyang.why@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: yiliang114 <jinjing.zzj@gmail.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-05 09:13:54 +00:00
ytahdn
0b8a926348
feat(serve): add workspace-scoped Skills runtime (#10697)
* feat(serve): add workspace-scoped Skills runtime

* test(serve): update Skills capability baseline

* fix(serve): address workspace Skills review findings

* fix(serve): address follow-up Skills review findings

* fix(serve): harden workspace skill management

* fix(serve): harden workspace skills management

* refactor(web-shell): remove inert skill management gate

* fix(web-shell): scope skill actions to active workspace

* fix(web-shell): keep workspace skills state scoped

* fix(web-shell): retry ready skills catalogs

* fix(serve): fail closed on unreadable skill directories

* fix(serve): invalidate global skills cache on errors

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-09-05 08:03:24 +00:00
Shaojin Wen
e3d26283e6
ci: close the guard holes round 2 found in the lint lane (#10919)
* ci: close the guard holes round 2 found in the lint lane

Nine follow-up findings on #10756's guards, merged before the round posted; all confirmed, fixed here.

The serializer at the heart of the prelude-identity guard was wrong: JSON.stringify's array replacer is a property allowlist applied at every nesting level, so each step's nested with:/env: serialized as {} and the guard could not see the fields it claimed to guard — Checkout's ref/fetch-depth, setup-node's versions, and the TRUSTED_CI_PROFILE binding whose drift to a literal would skip every profile-gated step while reporting green. Replaced with a recursive canonicalizer. The divergence exemption is now the field, not the step: a whole-step exemption left the collector's uses: revision — the one action pin in the job — compared by nothing, so a retag to a mutable @v4 was invisible.

Order is now pinned too: name-keyed maps are order-blind, and the steps context reads '' for steps that have not run yet, so a profile producer parked below its consumers would evaluate every ci_profile == 'full' gate false and complete the lane green having executed nothing. And the payload itself — the twenty moved steps the job exists to run — was pinned by nothing at all: deleting Run Prettier, flipping its gate, or re-adding a copy to test all stayed green. An exact ordered-list assertion now holds the full-gated payload, keeps it out of test, and pins the two deliberately-different gates as different.

runs-on joins the pinned surface (whole-expression, equal to test's): pick_runner is the fork-trust boundary and a literal pool label array would bypass it whole while desyncing from the timeout expression that reads the same output. no-ak's trusted-profile consumer loop gains the third consumer.

Two fixes to the collector work itself: it moved to the job's last step — failure() is evaluated when a step is reached and never revisited, so at its old slot just after install it had already been passed, unfailed, before any lint step could die, and could only ever collect a prelude failure. And a failure-gated disk-state dump now precedes it: the install sampler dies with that step's trap EXIT, so the artifact otherwise held only install-window samples for a death in any of the 19 later steps. The dump appends; a bare > would truncate the window it exists to complement.

The lane's banner no longer overstates its case: the property that justifies the required-check role is deterministic outcome, not lightness — the bundle-closure step builds and bundles the CLI, the helper suites spawn subprocesses, and the pool contention the old text denied is exactly what the routed 90/45 ceiling prices. It also records the honest limit: the incidents that motivated the split failed in the unit-test step that stayed in test, and this lane would have been green on those merges.

All ten mutations red: nested fetch-depth drift, TRUSTED_CI_PROFILE literal, producer below install, payload deletion / gate flip / leak-back, collector retag, runs-on literal, collector at its old slot, dump removed.

* fix(ci): keep the failure dump when the disk is full, and pin four guard holes

Review round 1 found five holes in the guards this PR added. Four are fixed
here; the fifth is a comment that misstated its own mirror.

The state-at-failure dump wrote its only copy into the samples file — the very
file whose writability the dump exists to investigate. Under ENOSPC the
redirect failed, `2>/dev/null` and `|| true` swallowed the failure and its
status, and the step exited 0 having recorded nothing, so oncall could not tell
"dumped, nothing interesting" from "could not write". Mirror through `tee -a`,
which still emits the data to stdout when the file write fails and reports why
on a stderr the block does not swallow. An executed case occupies the target
path with a directory and asserts the dump survives in stdout while the step
stays green.

The other three are pins that did not bite:

- The shared-prelude floor stayed at 12 after the divergence exemption narrowed
  and re-admitted the collector, making the intersection 13. A floor below the
  real count lets any one shared step be deleted out of either job unnoticed.
- The serializer at the heart of this PR had no fixture. Both sides of every
  comparison flow through it, so regressing the call site to the pre-PR array
  replacer compares {} to {} and the drift loop reports green — the exact blind
  spot the recursion replaced. Pin a known nested key on the serialized OUTPUT
  rather than on the serializer alone, because the regression that matters is a
  call site swapped back to an allowlist.
- Nothing pinned the `needs` edge that makes every classifier output this suite
  reads non-empty at runtime; deleting it decouples the lane silently, dropping
  runs-on to hosted and taking the degraded empty→full profile fallback.

And the collector's comment cited `test` as its mirror for lastness while
`test`'s own collector sits four steps from the end, so the hole this PR closes
here stays open there. Correct the comment and leave that job to a follow-up
rather than widen this change into a second job.

Mutations measured red: needs edge deleted; a shared prelude step deleted; the
serializer call site regressed to the array-replacer form; tee reverted to a
bare redirect, which reds both the pin and the executed case.

* ci: restore the blank line separating lint_and_static from web_shell_e2e_smoke

Appending the failure dump and the collector after the pre-existing blank
line left the job ending flush against the next job key -- the only one of
ci.yml's eight job boundaries with no separator. Semantically inert: the
parsed workflow hashes identically before and after.

* ci: correct the HELPER_TESTS file count in the lint lane comment

The comment justifying `lint_and_static` as a required check argues the lane
is not light, citing "the 23-file HELPER_TESTS battery". The env list at
ci.yml:82 enumerates 24 files and is byte-identical to main's, so the count
was wrong on arrival -- in the one paragraph that says it exists so the
required-check follow-up is argued "on true premises".

Same class as the collector comment 2082e26 corrected. Byte-neutral: no line
count, no parsed YAML, no gate. Nothing pins comment prose -- scripts/tests
and .github/scripts assert `ci.env.HELPER_TESTS`, not the banner -- so there
is no test to mutate; the count was measured two ways instead (token count of
the env value, and a diff of the main vs HEAD file lists, which are
identical). The sibling count in the dump comment, "19 substantive steps
after install", was checked at the same time and is correct: 22 steps follow
install, minus the always()-gated cache report and the dump and collector
themselves.

---------

Co-authored-by: 易良 <1204183885@qq.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-09-05 03:01:42 +00:00
易良
a9c045561f
ci: schedule dependency audits and use native secret protection (#11005)
* ci: schedule dependency audit daily

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

* ci: track scheduled dependency audit failures

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

* ci: avoid duplicate post-merge secret scans

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

* ci: use native secret protection

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

* ci: extract dependency audit issue tracking

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

* ci: harden scheduled audit tracking

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

* refactor(ci): simplify scheduled security checks

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

* fix(ci): address dependency audit review feedback

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

* fix(ci): fail closed on unknown audit results

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

* ci: stream the audit output instead of capturing it

With an 8-minute cap and one retry this step can now run for sixteen
minutes. Capturing into a variable and printing after each attempt
returns leaves the job silent for that whole time and then emits
everything at once, already labelled a failure. `tee` puts the output in
the log as npm produces it.

`${PIPESTATUS[0]}` rather than the pipeline's own status: `defaults: run:
shell: bash` sets pipefail, so the pipeline would carry npm's code today,
but nothing in the file says the classifier depends on that. Reading
npm's slot directly means a successful `tee` can never stand in for it.

Scope note, because the review that asked for this expected more from it:
streaming does not rescue an advisory from a killed attempt. npm emits
its report at the end, so an attempt killed before that point has nothing
to show either way — verified by A/B-ing both shapes against a stub that
reports only at exit: the progress line survives in both, the advisory in
neither. What keeps advisories in the log is the 8m cap sitting above
npm's own 422s error ceiling, which is already in this branch. This
commit is a log-legibility change, not a correctness one.

Behaviour is unchanged on every arm, driven with a stub npm against the
`run:` block extracted from the YAML: clean exits 0 after one call; a
genuine high-severity finding exits 1 after one call and is not retried;
an endpoint error and a timeout each retry once and exit 1 and 124.

The contract test pins `| tee "$log"`, `${PIPESTATUS[0]}` and the absence
of the old capture, so a silent regression to buffering fails.

Not run locally: the vitest suites and actionlint; CI is the authority.

* fix(ci): quote the explicit audit shell

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-09-05 01:08:43 +00:00
qwen-code-dev-bot
9062725ef4
fix(test): sync the pinned no-AK integration script list (#11050) (#11052)
The wiring test keeps a verbatim copy of the focused no-credential
integration command so that changing which specs run without an API key
has to be deliberate in both places. Adding the prompt-latency policy
spec to that command updated only the manifest, so the comparison has
failed deterministically since: the nightly release for
v0.23.0-nightly.20260904.74fe3a659d lost its Quality Checks (Scripts)
lane to exactly that assertion, which failed the aggregate quality gate
and blocked the publish.

Add the missing entry to the pinned list. Nothing about what CI runs
changes; the pin stays an exact comparison.
2026-09-05 00:51:23 +00:00