1281 Commits
Author SHA1 Message Date
jianyx1 f7885f9fa9 fix(verify-pr): bind reports to their round snapshot (#15746) 2026-10-29 16:42:50 +08:00
Shaojin Wen 702c1773a1 fix(managed-agent): retry owed Hosted cleanup after its generation dies (#15824)
* test(managed-agent): gate G3 cleanup and terminal recovery faults

Add three packaged fault arms to hosted-failover-e2e: a refused Runtime cleanup release followed by another crash, a committed terminal whose reply is lost, and a refused terminal write over a payable checkpoint. Each keeps the original Action, execution, file effect and terminal identity, and the runner now witnesses the fault on the store or Broker boundary rather than on a client-side retry.

Part of #14481.

* test(managed-agent): harden the G3 cleanup and terminal fault arms

Drop every bounded terminal retry so the terminal reply-loss crash lands on a committed-but-undelivered terminal, and require the surviving public transcript to carry the recovered answer. Document both observations. Part of #14481.

* test(managed-agent): address the G3 terminal arm review findings

Arm C1's crash barrier only on a confirmed store 200, record that pre-crash count, and state that the replacement projects the committed terminal without a second store commit. Snapshot fault counts before the relief Turn, keep the result-commit diagnostic for after-result, and fail loudly on an unhandled crash barrier. Remove the cleanup arm: a delayed replacement reproduces a control-plane cleanup/adoption gap, so C3 stays open as a product follow-up.

Part of #14481.

* fix(managed-agent): retry owed Hosted cleanup after its generation dies

A Turn that completed with cleanup still owed had no driver once its Harness
generation died: no Turn was left to dispatch, and the operator recheck route
was the only path that could pay the durable `HOSTED_CLEANUP` projection.
A delayed replacement therefore never received a load, kept the original
Runtime binding family retained, and the packaged C3 arm passed or failed
depending on whether the terminal projection beat the crash.

The control plane now scans that projection. For each Session with no active
Turn it resolves the owed Turn from the cleanup descriptor and issues the
existing prompt-scoped cleanup-only load, which pays the original Runtime
identity and commits `confirmed`. Attempts back off in memory from 10 seconds
to 5 minutes, one debt per Session per scan tick, and a failed attempt drops
the connector's cached attachment so the retry performs its own cold load.
Unreadable or foreign descriptors are logged once and stay owed.

The C3 arm is packaged again: it refuses every release until the crash, waits
for the control plane to project the terminal before killing the generation,
and requires the replacement's own release, one `confirmed` event and no
remaining debt. Disabling the scan makes it time out at the retry barrier.

Design: docs/design/2026-10-28-hosted-cleanup-debt-retry.md

* fix(managed-agent): qualify the cleanup retry executor and clock

Main now publishes the shared executor and clock as named beans, and every
coordinator injects them by qualifier. Without the qualifiers the new
coordinator fails context startup, which breaks every Hosted Spring test and
the packaged server.

* fix(managed-agent): keep a live attachment when a cleanup retry is refused

The debt scan dropped the connector's cached attachment on every failure. A
named refusal (409/503: a Turn still owns the Session, or recovery is required)
is not evidence that the cached generation is dead, and evicting it churns a
Session whose Turn is still running — the scan can race a Turn admitted between
its query and its attempt.

Only transport loss, a moved generation, a vanished reference (404) and the
connector's own generation-moved signals now evict; a named refusal just backs
off. Two tests pin both sides, and the design and README state the rule.

* fix(managed-agent): address the cleanup debt scan review findings

Two P2 findings from the review of the cleanup debt retry:

The store injected an unqualified JdbcTemplate. In an embedded deployment the
managed template is defaultCandidate=false, so the scan bound the host's
business database and failed every tick with "ma_resource_ref not found",
never retrying the dedicated database's debts. Qualify the injection with
managedJdbcTemplate; a new embedding regression test fails with the exact
table-not-found error when the qualifier is removed.

The scan always read the oldest fixed page and validated descriptors after the
LIMIT, so a prefix of unclearable debts (unsupported profile, unreadable
descriptor) starved every recoverable debt behind it across all Sessions and
tenants. Make the scan keyset-paginated: the cursor advances over every raw row
it reads, including unreadable ones, and wraps when a page comes up short.
Pacing entries now expire by idle time rather than by page membership, so
rotation never resets a still-owed debt's backoff. New tests pin the cursor
advancing past an unreadable prefix and wrapping on a short page; reverting the
cursor to advance only over valid rows reproduces the starvation.

The packaged C3 arm and the terminal reply-loss regression stay green on the
real MySQL/Spring/Broker stack after both changes.

* fix(managed-agent): address the second cleanup debt review round

- Name the scheduler. retryOwedCleanup was the module's only @Scheduled tick
  without scheduler = "managedAgentScheduler"; managedAgentScheduler is
  defaultCandidate=false, so the bare tick bound the shared one-thread
  sessionWorktreeScheduler that a blocking Git scan already owns. Align it with
  its siblings and pin its 1s fallback in the (now four-site) scan-delay test.
- Register HostedCleanupDebtStore in the row-lock-order audit. Its read-only
  NOT EXISTS guard on ma_turn made it a turn reader, so the exact-set sibling
  boundary test (SessionRowLockOrderTest) failed on SDK Java (fast). It takes no
  row lock and never writes, so it is a turn reader but not a session locker;
  list it explicitly rather than relax the audit.
- Classify attachment staleness by an explicit whitelist mirroring
  HarnessPoolConnector's reachability set (transport loss, moved generation,
  unknown mutation outcome) plus 404/5xx. Named refusals that are not
  DaemonHttpException — HostedHarnessRecoveryDeclinedException,
  harness_pool_unavailable — and 4xx now only back off instead of evicting a
  live Session's attachment. A table-driven test pins every branch; reverting
  the fallback to "evict" reds the declined and pool-unavailable cases.
- Pin the backoff ladder (10/20/40/80/160/300) and prove the idle-time prune
  keeps a still-owed debt's backoff when it leaves the scan page; clearing the
  pacing map each tick reds that test.

The packaged C3 arm and the terminal reply-loss regression stay green on the
real MySQL/Spring/Broker stack.
2026-10-29 16:35:42 +08:00
易良andQwen-Coder 7fc327825e fix(ci): exempt surefire-bound IT classes from the failsafe roster (#15830)
* fix(ci): exempt surefire-bound IT classes from the failsafe roster

The non-hosted roster required every *IT class to appear in
target/failsafe-reports, but packages/managed-agent-server binds
ManagedAgentPackagingIT to the artifact-packaging surefire execution and
excludes it from the mysql-integration failsafe (#15718). A class that is
forbidden to run under failsafe therefore could never satisfy the roster:
Maven reported BUILD SUCCESS with the class's 3 tests passing, then the job
failed on 'ran no test in this job'. It reproduces on main with no PR code
present (#15820 item 1) and turned the full tier red for every managed-agent
PR.

Name the surefire-bound classes per module, skip them in the roster, and fail
the check if the module's pom stops binding such a name to both a surefire
include and a failsafe exclude, so the exemption cannot drift away from the
pom it describes.

* test(managed-agent): use managed JDBC for message fixture

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-29 14:42:50 +08:00
jinyeandQwen-Coder b969de851a ci(java): remove MariaDB full integration lane (#15827)
Keep the complete MySQL 8.4 integration profile and per-fork isolation. Retain JDBC precision and CAS coverage on the supported database and align workflow guards and documentation.

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-29 06:09:57 +00:00
易良andQwen-Coder 3308bd2fda fix(release): unblock Managed runtime 0.26.3 backfill (#15825)
* fix(release): repair managed runtime 0.26.3 verification

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

* style(release): format managed runtime regression tests

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-29 11:54:45 +08:00
9f79669a9d test(core,cli): stop flat 30s budgets shadowing the raised ECS timeout ceiling (#15801)
* test(core,cli): stop flat 30s budgets shadowing the raised ECS timeout ceiling

Both vitest configs raise testTimeout/hookTimeout to 60s on the shared
ecs-qwen-* pool and fall back to 15s elsewhere. Three suites instead set a
flat per-file or per-describe `vi.setConfig({ testTimeout: 30000, ... })`,
which overrides the config unconditionally, so on the pool the raised 60s
ceiling never applies and a slow-but-healthy run is killed at 30s.

Reshape each clamp to the repo's existing `isSlowTestHost() ? slow : fast`
idiom instead of deleting it. Deleting a flat 30s clamp would drop the
off-pool budget to the config's 15s floor, a regression for fork PRs and the
macOS/Windows runners, so the fast arm stays at a non-trivial 30s everywhere:
off-pool is unchanged at 30s and the ECS pool rises from 30s to 60s.

In server-default-bridge-wiring.test.ts the file-level clamp already used the
idiom but with a 15s fast arm, and a describe-scoped 30s clamp shadowed it.
Raise the fast arm to 30s and drop the shadowing clamp plus its now-orphaned
comment, leaving the file-level setConfig as the single source of truth.

Refs #10922

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

* test(managed-agent): qualify the api-contract fixture's JdbcTemplate

Same latent wiring as #15809 fixes on main: after the managed template became
defaultCandidate=false (#15718), this unqualified autowire resolves to the
plain template in the full CI context, and H2 refuses String-vs-VARBINARY
comparisons on the identity columns. Pin the fixture to the managed template
as in #15809; identical hunk, keeps both trees agreeing when this branch next
merges main.

* test(core): sync the slow-test-host threshold with its cli mirror

packages/core/src/test-utils/slow-test-host.ts documents that it mirrors
packages/cli/src/test-utils/slow-test-host.ts and that both must stay in
sync, but its fallback required the full available parallelism while the
cli copy splits at half. The cli side explains why 0.5 is load-bearing:
the review-address verification gate re-runs package tests through an
`env -i` child that drops RUNNER_NAME and clamps vitest to
--maxWorkers=25%, so the gate's own workers never lift the load average
to the full core count.

The two packages/core call sites this PR adds therefore resolved the
weaker predicate, and in exactly the gate the docstring is about the
raised budget this PR exists to grant never fires. Add the two
assertions the cli copy already carries so the sync claim is pinned.

Verified discriminating rather than decorative: with the threshold left
unchanged the two new rows fail (40>=64 false, 32>=64 false) while the
three existing rows stay green; after the change all five pass.

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

* test(core,cli): pin the slow-test-host threshold and its mirrored copy

R3-1: port the cli copy's contention rationale into the core docstring, which
still called the predicate "measured saturation" while shipping a `* 0.5`
multiplier that fires at half the core count.

R3-2: add a parity guard beside the existing permission-bit probe guard. The
two slow-test-host copies are not byte-identical (only the cli copy also
exports slowHostBudgetMs), so it compares the shared isSlowTestHost body.

R3-3: add an 8-core case to both mirrored test files. Every existing case
mocks availableParallelism to 64, so the multiplier was indistinguishable
from a hardcoded 32.

Mutation witnesses, each mutant applied and run separately then restored:
- threshold hardcoded to 32 in both copies -> the new 8-core case fails in
  core (1 failed | 5 passed) and in cli (1 failed | 9 passed) while the five
  pre-existing cases stay green; the parity guard stays green because both
  copies moved together.
- `* 0.5` dropped from the core copy only -> the parity guard fails.

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

---------

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-10-29 10:43:53 +08:00
Shaojin Wen ca204559a7 feat(verify-pr): make "re-verify after update" a follow-up trigger (#15816)
* feat(verify-pr): make "re-verify after update" a follow-up trigger

After a local verification round, a maintainer who sees the PR move says
"有更新了,重新验证". That phrase was not a documented trigger: the skill
could miss it entirely or start a fresh round, losing the previous-finding
status table and the delta classification. The local follow-up lookup only
searched PR comments, so a round that was never published was invisible and
silently degraded to a fresh round, and nothing checked whether the head had
actually moved before spending a full round.

Add the re-verification phrases to the skill description, and a manual
re-verification contract: resolve the PR from the session or the newest
local artifact dir (ask when ambiguous), take the previous verified head
from the newest substantive round across local artifacts and published
comments, and fetch the current head before scoping. An unchanged head gets
an answer, not a round — comments and CI results are data, the same rule the
watch gate applies. A moved head runs the existing follow-up protocol.
Coordinates inherit from the previous round; publishing stays a
per-invocation request. A completed manual round records its head in the
watch dedup file so an installed watch does not re-fire for it.

The local follow-up lookup now searches both sources and takes the newest
substantive round, so unpublished rounds participate in the protocol.

* fix(verify-pr): register a manual round in the watch state before it starts

The manual re-verification contract recorded its head in the shared watch
dedup file only after the round completed, covering just the watch-first
ordering. With the watch state still on the previous head, a manual round
running in another session registered nothing while it worked, so the next
watch fire read the stale head and started a second round for the same head.

Make the check-and-register a step of its own ahead of running the round:
read the shared file, defer when it already records the head or marks a
round in flight, otherwise write the head as in-flight before starting —
the same write-before-start rule the watch prompt follows — and record it
as verified once the round completes. Both orderings now dedup through the
one file.

Pin the contract, including the registration-before-round ordering, in the
skill-text suite so the race cannot return unnoticed.
2026-10-28 19:27:36 +00:00
Shaojin Wen 35fd1f44ff feat(ci): bound autofix takeover with a round budget (#15388)
* feat(ci): bound autofix takeover with a round budget

`@qwen-code /takeover` engages a persistent loop capped at 100 rounds,
so a maintainer who wants one bounded pass has to spend the whole cap
in a single Critical-only round and accept a "reached its round cap"
pause, or watch the run and stop it by hand.

Add `<cmd> for N` (1-99), which engages takeover and records a
window-scoped round budget on the acknowledge comment. The scan lowers
the effective cap to N for that window; once the budget is spent, a
human-authored PR is released — one bilingual note, the label removed,
no needs-human escalation — while a bot-authored PR keeps the paused
path, since releasing it would hand the PR back to standard management.
Failed, timed-out and crashed rounds consume budget, so N means N
attempts.

The workflow sits against its 470 KB start-runs gate, so the duplicated
env commentary moves into the rationale record (af-160), leaving a
one-line summary plus a pointer per constant.

* fix(ci): count attempts and keep breaker stops in the takeover budget

Three review findings on the round budget:

- A no-op round writes its eval marker with the round UNCHANGED, so a
  budget riding on ROUND never spent for a PR whose rounds kept finding
  nothing — with `for 1` it stayed managed indefinitely. Spend is now
  the number of eval markers in the window: one per attempt, no-op
  included.
- The same reading separates a breaker stop from real exhaustion: a
  breaker stamps its terminal marker at round=MAX_ROUNDS after far
  fewer attempts, and that sentinel jump adds no marker, so a
  twenty-round window that hit five failures stays paused and escalated
  instead of being released with a "nothing is waiting" note.
- A queued conflict round carries the OLD cap, and conflicts were
  exempt from the superseded-window check, so after a smaller re-arm it
  could run past the new limit. A budgeted live window makes it stale
  too; the next scan re-selects the conflict under the new window.

The workflow sits on its 470 KB gate, so the env commentary these reads
pushed past the limit moves into the rationale record the same way as
af-160.

* fix(ci): keep the budget bounded across audit re-arms and queued rounds

Two review findings on the round budget:

- The growth audit appends `<!-- autofix-rearm -->` to open a fresh
  window for its own round accounting. A window opened that way carries
  no budget marker, so an automatic re-arm turned a `for 2` engagement
  back into a 100-round one with no maintainer asking. The report script
  now skips that automatic re-arm while the current window is budgeted,
  so the audit round stays inside the budget it was given. Both copies
  of the helper (script and the failure-path inline one) change together,
  which the existing equality test pins.

- A queued round can outlive the sibling that spent the window's last
  attempt: a no-op leaves ROUND at 0 and the window key unchanged, so
  neither the superseded-window check nor the round cap discarded it and
  with fresh feedback it ran one more attempt. `review-address` now
  recounts the window's eval markers and discards on a spent budget.

The new tests also caught a real defect in the first draft of the
re-arm guard: a `[[ ... ]] && echo` as the function's last command
returned 1 under `set -e` and would have aborted the report.

* fix(ci): scope the budget to the engagement, not the round window

Third review round. Three correctness findings, all real:

- Suppressing the growth-audit re-arm (the previous round's fix) also
  froze the growth baseline: it is the only automatic mover of that key,
  so after one over-budget round every later round recomputed growth
  against the pinned baseline and went Critical-only. The re-arm is
  unconditional again. The budget survives it because budget and spend
  are now read per ENGAGEMENT: a maintainer command opens one, while an
  automatic re-arm (whose comment also carries that round's eval marker)
  does not.
- The attempt census was a raw marker count with no identity, so a
  GitHub "Re-run failed jobs" re-posted a round's report and charged
  that round twice — releasing the PR early. Attempts are counted as
  distinct (ts, round) markers in both the scan and prepare.
- The release fired on a spent budget alone, so a circuit breaker that
  tripped exactly on the last budgeted attempt was reported as a
  finished engagement with "nothing here is waiting on a human". A
  trailing un-evaluated-failure streak of at least TIMEOUT_WINDOW_CAP
  now skips the release and lands on the paused path with
  autofix/needs-human; a provenance-verified forced dispatch likewise
  falls through to its own refusal instead of being preempted.

Design decision 7, its Chinese twin and the operator guide now describe
the carry-forward instead of the suppression.

* fix(ci): end the budget with the engagement that opened it

A spent budget marker outlived the label it belonged to. `stop` posts a
`takeover-ack released` ack, which is not one of the window-key anchors,
so the marker kept reading back: on a bot-authored PR the scan re-entered
the cap branch on every tick and re-applied `autofix/needs-human` at N/N,
and a hand-removed label had the same effect. The budget is now honoured
only while `autofix/takeover` is present, which ends it in both cases.

The regression harnesses now set HAS_TAKEOVER for the managed case.

* fix(ci): repair the two checks the last commit broke

Both failures were introduced by the label-gate commit and are now fixed:

- The pointer suite stopped recognising `autofix.yml`'s env pointers
  because they had been shortened to `→ af-NNN`. Restored the full
  `→ qwen-autofix.md#af-NNN` form the suite matches, so every section is
  pointed at again.
- `HAS_TAKEOVER` was read unguarded while a pre-existing replay harness
  runs its shell with `set -u`, which aborted that harness with
  "unbound variable". The read is `${HAS_TAKEOVER:-false}` now: callers
  that never set it keep the pre-budget behaviour.

Verified locally: 72 focused workflow/size cases pass; the file is
469,982 bytes, under the 470,000 gate.

* fix(ci): attribute budget attempts to the window that dispatched them

The engagement census filtered markers by publication time (`.at >=
floor`), so a round dispatched under the previous engagement and posted
after a fresh `for N` ack was charged to the new allowance: `for 1` could
be spent before its own work ran. Markers carry the window they ran
under, so the census now selects `.win >= floor` (and still skips the
legacy `win=none` markers), which associates an attempt with its
originating engagement while keeping the automatic child windows of that
engagement inside it.

Verified: 73 focused workflow/size cases pass; 469,943 bytes.

* test(ci): pin the budget's lifetime to the takeover label

Audit finding: the label gate that ends a budget when `autofix/takeover`
goes away had no regression. The budget-read harness now takes the label
state as a parameter, and a fixture asserts that the same anchor yields
the takeover cap (100) once the label is absent — the shape that used to
re-park a bot-authored PR at N/N with autofix/needs-human on every scan.

* fix(ci): read the carried budget per engagement in prepare too

`review-address` still resolved `LIVE_ROUND_BUDGET` from the latest growth
window, so after an audit re-arm a queued task read `live_budget=0` and
the attempt cap could not see the engagement's spend. The leg now uses
the scan's engagement scope: a maintainer command opens one, an automatic
re-arm (whose comment also carries an eval marker) does not. That also
makes the superseded-conflict clause see the real budget instead of the
audit window's empty one.

* docs(ci): align the budget record with what the loop now does

Three claims had drifted from the implementation: the effective-cap
bullet said nothing else reads a budget (prepare and the report script
both do), the growth-audit paragraph still described the suppressed
re-arm (it is unconditional; budget and spend are read per engagement),
and the queued-round paragraph said a no-op leaves the round "at 0"
(it leaves it UNCHANGED).

* fix(ci): let the shepherd see the parameterized takeover commands

The auto-release lever builds its resume candidates by exact body match
against the bare takeover and retry commands, so a paused PR re-engaged
with `@qwen-code /takeover for 2` (or `from N`) matched neither and the
release could fire after the pause window despite a fresh engagement.
The predicate now also accepts the two parameterized forms as prefixes of
the configured command, interpolating TAKEOVER_COMMAND rather than adding
a literal.

* docs(ci): name every site that carries budget logic in af-159

The site index listed the three original steps while the prepare leg and the report script's guard read and enforce the same budget.

* docs(ci): name each af-160 entry by the constant it records

The section's own preamble promises that every entry names the constant it
documents; none did, so a maintainer following an env pointer had to read
all eight to find the one they came for. Each header now leads with its
constant. The command entry also drops its "ONE parameterized form" claim,
which this PR's second form falsified, and names both.

* docs(ci): nest the seed sections under their own heading

The guide was retitled for both forms, but the seed's sections stayed at H2, so a document-wide reading of 'does not shorten the round cap' sat directly above the form that does. The seed's sections are now nested and its Picking N no longer collides with the budget one's anchor.

* docs(ci): complete the budget record's inventory and risk list

The files-affected inventory omitted the report script and the prepare
leg's reads, and the risk list only covered the DELETE-failure direction.
Both now name the release's actual shape: the label gate ends the budget,
and the note-then-DELETE order means a failed note still releases.

* docs(ci): bring the Chinese budget record in line with the English one

The previous commit's script aborted after writing only the English file, so the pair disagreed. This lands the same inventory and risk additions in zh-CN.

* fix(ci): keep the budget release note ahead of the label, and read dispatch identity correctly

The release note is the only terminal comment the budget path ever posts;
once the label is gone the PR is in neither selection pool, so a failed post
could never be retried. Skip the DELETE when the note fails, so the next scan
retries both.

The release gate keyed on FORCED_PR being empty, but route publishes pr_number
for every targeted event, not just workflow_dispatch. A pull_request or
pull_request_review run that reached the spend-to-release window therefore fell
through to the round-cap copy and applied autofix/needs-human to a PR that hit
no cap. Test the dispatch identity the way the sibling refusal gate does.

* docs(ci): correct the budget record against the shipped mechanism

The record and the guide disagreed with the code in four ways, all of them in
the direction that misleads whoever debugs it next:

- the budget is read per engagement (the eval-marker exclusion is what tells a
  command from the automatic re-arm), not by window-key equality;
- the re-arm is not the only thing that re-anchors the growth baseline — a
  base-branch update and the scan's heal ack do too, so the exclusivity claim
  is gone while the per-engagement conclusion stays;
- a cancelled run that reaches the finalizer DOES spend an attempt, so the
  error direction is an early release, not a late one;
- a crash, timeout or breaker stamps the cap, ending a budgeted window at once
  rather than spending the rest of the budget.

Also: the label — not the command — is what engages management (a hand-applied
label cannot be bounded), the 470 KB number is this repo's gate rather than
GitHub's limit, /retry does nothing on a released human PR, and the queued
conflict discard is this change's new clause rather than the window-key check.
af-160 gains the TRUSTED_ASSOC entry the prose move deleted, af-007 names both
parameterized readers, and the guide index advertises both forms.

* docs(ci): label the takeover input by its scope, not by a window

* test(ci): stop calling the seed the one parameterized form

* fix(ci): read prepare's budget only while the takeover label is present

The scan zeroes a spent budget once the label is gone, but prepare still read
it from the comment history alone. A bot-authored PR whose label was removed
after its budget ran out was therefore dispatched by the scan and discarded by
prepare on every tick, with no marker written - a livelock rather than a
release. Gate the read the same way the scan does, on the same live label
re-read prepare already performs.

* fix(ci): count attempts per run, park breaker stops, keep a spent budget

The budget census keyed on `ts|round`, so two executions reporting the same
watermark and round — the conflict no-op, which re-dispatches every tick —
collapsed into one attempt and a for-N engagement could never spend. The eval
marker now carries `run=GITHUB_RUN_ID`, the dedup identity the growth marker
already uses, and both censuses key on it with `ts|round` as the legacy
fallback, so a re-run of one run still charges once.

A breaker stop also spent the budget on its way out. The consecutive-failure
breaker jumps the round to MAX_ROUNDS with a real timestamp, the cumulative
timeout breaker counts timeouts across the window with pushes between them, and
the convergence breaker only pushes — none leaves the trailing sentinel run the
release gate tested. The gate now reads BUDGET_HALTED: a sentinel anywhere in
the engagement, a round past ROUND_START + attempts, or a convergence-break
notice newer than the window key all park the PR instead of releasing it.

Finally, the shepherd's three-day auto-release strips the label from a paused
bot PR. The budget used to die with it, dropping the effective cap back to 10
and re-selecting the PR for rounds N+1..10. A spent budget now survives for a
bot-authored engagement; an unspent one still dies with the label, and a human
release keeps no cap at all.

* test(ci): pin the budget's lower-only clamp and both census filters

The cap clamp's -lt half was exercised by no fixture: the replay hardcoded
EFF_MAX_ROUNDS=100 and every budget is <=99, so deleting the comparison left
the suite green while a stale marker could raise a strict cap. Parameterize the
incoming cap and replay a 20-round marker against a 10-round cap.

The census filters were equally unpinned: BUDGET_FLOOR was frozen at '' and no
fixture carried win=none, so dropping either clause stayed green. jq compares
window keys as strings, so 'none' sorts above every ISO timestamp and only the
win != none clause keeps pre-takeover rounds out. Thread the floor through and
add the two cases plus an in-engagement one.

* chore(ci): re-baseline after merging main

* fix(ci): describe a budget stop as a budget, and record the release only once it happens

Two operator-facing copies lied at the boundary. A bot-authored PR whose
BUDGET_SPENT brought it there fell through to the round-cap body and printed
'hit the round cap (0/3)' - a round count of zero against a cap of three, on a
PR that never reached a cap. It now gets its own line and fleet state naming the
attempts used.

The fleet row claiming a release was written before the block could skip it five
ways (dry run, unreadable labels, changed consent, PAT identity, failed DELETE),
so a dry-run scan reported every budgeted PR as released. The row now follows the
DELETE that actually happened.

Reclaimed the bytes from three pointer comments whose rationale lives in their
sections (af-045, af-118, af-120).

* test(ci): replay the budget boundary end to end, bot path included

The release harness stubbed fleet_row to a no-op and extracted from the inner
if, so neither the fleet row nor the bot-authored half of the boundary was
executed: a dry-run scan could report a release it never performed, and a
restructure that stopped the bot check reaching the branch would release a bot
PR's label with the suite green.

The stub now records rows, every skipped arm asserts no release row, and a
second replay runs the whole outer block with a bot author: no DELETE, and the
notice names the budget instead of a round cap the PR never reached. The outer
entry condition is replayed on its own for the no-op-only window, whose ROUND
never advances past 0 - the only reason a for-2 PR that found nothing twice can
still end.

* fix(ci): state the ack's one exception instead of promising an unconditional release

The budget ack said 'the loop removes the label' for every reader, but a
bot-authored PR keeps the label and pauses for a human - the exact case a
maintainer reading their own ack would mispredict. Both languages now carry the
exception in the same sentence, so the ack is accurate whichever PR it lands on
without a second authorship read at the ack site.

* test(ci): feed the parameterized commands to the shepherd's resume detector

The widened matcher was exercised by nothing: deleting either startswith arm
kept both suites green, and the anti-drift cross-check pins only the two exact
forms. A future grammar change would leave the shepherd blind between a
maintainer's command and the ack landing, auto-releasing a just-re-engaged PR -
the regression the cross-check exists to prevent. Add the two forms plus a
near-miss that must not count.

* fix(ci): match the shepherds resume commands as strictly as the router does

The startswith arms accepted bodies the router rejects - for 0, for 100, for 3
please, from 3 for 2 - so a command that did nothing (no label, no ack, no log
line) vetoed the auto-release for the whole grace window, renewable by any
further malformed variant, and three such bodies from distinct authors in one
window tripped the two-read budget into a permission-fault deferral.

The matcher now strips the prefix and requires a 1-2 digit tail, with the
for-arm also requiring a value >= 1 - the same grammar the parser enforces,
including leading zeros read as decimal.

* docs(ci): correct the prose move's warranty and the inventory's map

af-160 claimed the block was moved verbatim, but the TAKEOVER_COMMAND entry is
the one rewrite - and the rewrite had dropped the base's assurance that the
captured value reaches nothing but an integer comparison. The preamble now names
that exception and the entry restores the clause.

The inventory named the report script, which this change does not touch, and
omitted the fleet shepherd, which it does; the scope boundary repeated the
omission. af-159's site index pointed at the script instead of the shepherd's
resume detector, and still credited the script with carrying the budget.

* test(ci): pin each language of the budget ack separately

Both ack bodies are single printfs with positional arguments, and the only
assertions on the budget copy searched the whole posted body - so the exact
mutation this file documents elsewhere (swapping the EN/ZH arguments, shipping
the Chinese sentence inside the English paragraph) stayed green. Split at the
details summary and assert each language names its own sentence, for both the
engage and the re-arm ack.

* test(ci): replay the prepare budget reads instead of injecting them

Both discard tests handed LIVE_ROUND_BUDGET and LIVE_ATTEMPTS in as literals, so
the two jq programs decision 8 rests on were executed by nothing: a wrong window
key or a dropped marker scan stayed green while a queued round either ran past
the budget or was discarded on an unbudgeted PR.

Replay both reads against a real census: a budgeted ack plus an eval-carrying
re-arm must still yield the budget (the re-arm does not open an engagement), a
human release must zero it, and a spent budget on a bot PR must survive that
removal.

* test(ci): read the report script through the module constant

The re-arm test resolved the script path a second way (resolve/dirname/
fileURLToPath) where ~35 sibling sites use the module-scope read, so renaming or
moving the script meant finding a second spelling whose stale literal would only
surface as a lone ENOENT in this test.

* fix(ci): keep legacy eval markers distinct and migrate the last reader

Two defects in the run= marker format, both from readers that assumed the old
shape:

- The marker builder stored an absent run= as "", and jq's `//` does NOT fall
  back for an empty string, so two DIFFERENT pre-change reports collapsed onto
  one dedup key: a for-2 window under-counted its attempts and could run one
  more round. Both builders now keep a missing run null, so `//` falls back to
  the ts|round key as intended, and a replay of two distinct legacy reports
  asserts 2 (a re-posted one still asserts 1).
- OVER_BUDGET_AUTHORS still required ` -->` immediately after the optional
  win=, so it rejected every new report and left the per-maintainer regular-
  feedback batch limit disabled during Critical-only rounds. Its scan now
  accepts the optional run= field like the other readers.

* test(ci): pin the eval marker's run field where the assertions read it

Appending `run=${GITHUB_RUN_ID}` to the eval marker moved five expectations out
from under the tests: the report step's terminal-handoff marker and the report
script's two pushed/no-op markers. The assertions still pinned the pre-`run=`
text, so `Test (ubuntu-latest)` failed on `posts a human-handoff marker…` and
`stamps the push marker…` while the writers were correct.

The markers themselves are unchanged — only the expected literals now carry the
field the writers emit.

* fix(ci): let an explicit release close the budget, and keep idle wedges out of the halt

Two defects at the engagement floor, both reported against the shipped code:

- The spent-budget survival clause read label absence plus bot authorship, so it
  could not tell the shepherd's silent removal from a maintainer's `stop` or a
  hand-removal. A bot PR that spent `for 2` stayed parked at 2/2 after `stop`:
  the release leg was skipped by the bot check and the paused leg re-applied
  needs-human, contradicting the ack's "standard management continues". The
  floor now treats `takeover-ack released` as a boundary, so an explicit release
  ends the engagement while a silent removal still keeps a spent budget.
- The halt census counted idle (silent-sandbox) sentinels, which the cumulative
  breaker deliberately excludes, so three infra wedges parked a bounded
  engagement instead of releasing it. Their runs are mapped from the idle
  headlines and subtracted, mirroring the breaker's own `TIMEOUT_N - IDLE_N`.

Reclaimed ~2 KB from three pointer comments (af-155, af-149, af-152) whose
rationale stays in their sections, taking the file from 113 bytes of headroom
to 1,451.

* docs(ci): state the budget's real lifetime, halt thresholds and prose warranty

Four records told the operator the opposite of what shipped:

- the design record (both languages) and af-159 said the shepherd's auto-release
  ends the budget and a paused bot PR returns to ten-round management. A SPENT
  budget now survives any removal that leaves no `takeover-ack released` marker,
  and only an explicit `stop` posts one — so `stop` is the release that ends it.
- af-159 and the guide said any sentinel halts the release, and that the first
  crashed or timed-out round parks the PR. The halt needs TIMEOUT_WINDOW_CAP (3)
  sentinels (idle ones subtracted), or a round past ROUND_START + attempts, or a
  convergence notice; one crash only spends its attempt.
- af-160's warranty promised ONE exception; TRUSTED_ASSOC is a second, now
  marked at its entry and counted in the preamble.
- af-157's quoted block had lost its first line, leaving an orphaned fragment
  under a heading that still quotes it.

The pointer convention now says a summary line may be rewritten once its block
was compressed (the section is authoritative), and af-159 points at the
growth-audit record for the re-arm invariants it depends on.

* test(ci): round-trip the run field and witness both release suppressors

Two gaps the last round opened:

- The `run=` format was pinned only on the writer side. Both builders now get a
  round-trip fixture: a body carrying `run=1111` parses round, window and run
  through the real jq, a legacy body still parses round and window with run
  absent (null, not ""), and the census keeps two distinct runs apart. Dropping
  the optional group from either reader now reds it instead of silently
  emptying the census.
- The release gate's two suppressors had no failing witness: `runBoundary`
  hardcoded `BUDGET_HALTED: 'false'` and `runRelease` omitted the variable. Both
  are parameterised now, with a halted engagement and a real forced dispatch
  asserting no label DELETE, a passive event carrying the same pr_number still
  releasing, and `runRelease` passing the literal production always has.

The halt census also gains its false side: one sentinel is not a halt (the
threshold is TIMEOUT_WINDOW_CAP), and a convergence notice older than the window
key is not one either.

* fix(ci): retry the handoff post once

The handoff comment is the only carrier for a round that errors, times out or
crashes: its gate excludes fixed/noop rounds, and the always() finalizer only
patches the status comment. It posted single-shot, so one transient 5xx during a
comment-API blip left the round uncounted — the census did not move and the next
scan re-dispatched the same round, a free attempt.

It now retries once, five seconds apart, like the re-arm ack. A sustained
outage still costs the attempt, which the guide already states: a lost marker
delays the release, it never ends the engagement early.

* test(ci): watch the seed beside the budget, and both notice-dedup filters

Three assertions that could not fail before:

- the route replay printed only TAKEOVER_CMD and TAKEOVER_FOR, so "the two
  forms never co-emit" was pinned in one direction only. It now prints the seed
  too: `for N` must leave the seed empty and `from N` the budget.
- the budget-spent dedup fixture was newer AND bot-authored, so dropping either
  filter stayed green. A human-quoted marker and an older bot notice must both
  fail to suppress the note now.
- the growth marker's window prefers GROWTH_BASE_WIN over WINDOW; every existing
  case passed the same value for both, so swapping them was invisible.

* docs(ci): record what happens when a breaker meets the spending round

The boundary table covered a breaker that stops the loop BEFORE the budget is
spent and said nothing about its condition being met exactly on the attempt that
reaches N. That attempt is already counted, so the PR takes the release path and
no breaker diagnosis is posted: the consecutive-failure gate cannot fire on the
cap round. Stated in both languages rather than left to be inferred from code.

* test(ci): witness a failed label DELETE without aborting the scan

The release harness's gh stub exited 0 on every write, so the DELETE's failure
arm was dead in the suite: unwrapping the 'if !' would have shipped green, and in
production one transient 502 would abort the scan job — costing every other PR in
that tick its round.

The stub can now fail a label write; the case asserts the warning, no fleet row
claiming a release, and that the block still reaches its continue.

* test(ci): inline the round-trip inspection command

A single-use constant left over from iterating on the fixture; the comment above
it already says why the command is what it is.

* docs(ci): finish removing the claim that the budget ends with the label

Two sites still carried the old rule. The guide told the operator that the
shepherd's auto-release returns a paused bot PR to ten-round management, and
af-160's TAKEOVER_MAX_ROUNDS entry promised that removing the label restores the
strict cap — which a still-live for-N budget keeps clamping, exactly in the
spent-bot case af-159 now documents. Both now say what the code does.

Found on an audit pass over this round's diff; no behaviour change.

* docs(ci): list the released ack among the floor's boundaries

Decision 2 enumerated two of the three markers that move the engagement floor;
the explicit-release marker, which is what tells `stop` apart from a silent
removal, was missing in both languages.

* fix(ci): clear the escalation a deferred budget release applied

A forced dispatch that meets a spent budget answers the dispatch on the
paused path, so the release lands one scan later — and left the
needs-human label that pause applied on a PR whose release note says
nothing is waiting on a human. The release now deletes it too, 404
tolerant and only after the takeover DELETE landed. The notices on that
deferred pass quote the attempts the budget counts: a no-op-only
engagement reaches the gate at round 0, and 0/N named a cap the PR never
reached.

* docs(ci): record the forced-dispatch deferral of a budget release

The guide, af-159 and both design languages promised an unconditional
release with no escalation label. A forced dispatch is the one pass that
cannot release, so the deferral, the cleanup it now owes, and the
attempts numerator are written down where a maintainer reads them.

* docs(ci): stop claiming the breaker is the only thing that withholds a release

The guide said the release is withheld ONLY on the three breaker
thresholds, and read any pause as 'a human should look'. A forced
dispatch withholds it too, for one scan, and that pause means the budget
finished. Both design languages lose the same exclusivity claim.

* docs(ci): spell out the label pointer like every other one

The compressed TAKEOVER/SKIP comment kept a bare 'af-160' while the
other 184 pointers in the file name the record they live in.
2026-10-28 18:58:31 +00:00
Shaojin Wen 6ef3eaa2f2 feat(managed-agent): add an admin launcher for the maintenance entry points (#15772)
* feat(managed-agent): add an admin launcher for the maintenance entry points

The server has twelve offline maintenance entry points, but the reference
distribution only shipped a launcher for operator recovery. The docs ran
the rest from a module build, sometimes through a classified jar the
distribution does not ship.

bin/qwen-managed-agent-admin <command> now runs each entry point by a
short name from the server jar, through PropertiesLauncher, since every
classified jar is the same application with another main class. It lists
its commands without starting Java, exits 2 on an unknown command, and
loads config/application.yml as the other launchers do. Packaging fails
when a mapped class is missing or has no main method.

The deployment guide gains a maintenance-commands section that names the
W1_JDBC_* and K2_JDBC_* credentials the commands read. The server and K2
READMEs show the distribution form first, and the K2 module-build commands
use the plain server jar.

* docs(managed-agent): name the admin commands that read the Spring datasource

operator-recovery and resident-retirement start the server's Spring
configuration and take its datasource, so they read SPRING_DATASOURCE_*
and the other control-plane settings, not W1_JDBC_* or K2_JDBC_*. The
deployment guide and the design said every database command read its
own credentials; they now name the two exceptions, say to run them with
server.env loaded, and note that csi-protection opens no database.

The config template's list of launchers that default
SPRING_CONFIG_ADDITIONAL_LOCATION now includes the admin launcher, which
shares the server launcher's preamble instead of a copy of it. A test
now also fails when a class with a main method has no admin command.
2026-10-28 20:15:54 +08:00
Shaojin Wen 21c8b594db feat(verify-pr): attribute failures, close the publish guard, enumerate coverage (#15764)
* feat(verify-pr): attribute failures, close the publish guard, enumerate coverage

Three gaps measured on a maintainer round, each a deterministic rule that
lived only in prose and failed silently.

The counting rule was taught in two halves that never met: one paragraph
showed how to prove a red environmental with an A/A control, another said
expected reds are passes. Nothing joined them, so a host-environment red
had no category. The round booked the same repository test twice — head
and its base control — and published fail: 2 with findings while its own
verdict line said neither reason was a defect in the PR. The base cell
asserts that base fails; it failed as predicted, so it passed. assertions
gains an optional failures ledger attributing each red to pr, harness or
environment, and the checker refuses findings or blocked when nothing is
attributed to the PR. The verdict vocabulary stays at four words.

The pre-publish guard asked "anything new?", and a failed fetch returns
zero items, so the answer was "no". Measured 324 review comments down to
0 on a retry that never reached the API. It was the only local-path
safety rule with no script behind it. The guard now owns both halves: a
failed call is fatal and writes no file, and the compare side independently
treats a short list, a vanished id, a moved head or a changed state as
dirty. No retry flag — a silent retry budget hides a machine that cannot
reach the API.

"Counts are sacred" bound only numbers. A universally-quantified coverage
claim over an enumerable set was asserted, not generated, and two
production commits had no mutation row. mutations.json now carries the
claim and the enumeration it is about, and the checker names any listed
commit no row covers.

Verified: 230 pin tests across the three suites, an 11-row vacuity matrix
with 11 killed including a control on a pre-existing guard, and a real
round re-encoded from findings to merge-ready at 548/548. Three of the
matrix rows exist because self-audit found defects in this change itself.

* fix(verify-pr): summarize the ids a short list lost

Driving the new guard against a live PR to capture evidence for this change
showed it printing the short-list reason and then 321 "disappeared" lines.
The one actionable line was buried under its own echoes, which is the same
failure the guard exists to prevent: a maintainer who cannot read the output
cannot act on it.

The absent ids are a consequence of the truncation, not independent reasons,
so a short list now reports its reason plus a count. At equal length the
missing id is the whole signal and is still named — a second pin keeps the
suppression from swallowing a genuine deletion, and a vacuity pair proves
both halves (2 rows, 2 killed).

* fix(verify-pr): enumerate the branch's own commits, and ask for the files the checks read

A second audit pass over this change found five defects in it, two of which
defeated the rules the change exists to add.

The coverage rule told the round to enumerate with `git log --no-merges`, which
drops a merge commit but keeps every commit that merge brought in. On the very
branch whose wrong claim motivated the rule, that recipe lists 85 production
commits where `--first-parent` lists 24; the 61 extra come from one merge of
main. A pin was defending the wrong command. The recipe now carries
`--first-parent`, explains why the other flag does not substitute for it, and
quotes the measurement.

The checker read `mutations.json` and the failures ledger, but the artifact
contract never asked for either, so a round following the contract would
produce neither and both new checks would sit silent. The contract now names
both, with a pin per file.

A per-commit claim over an empty `productionCommits` list passed: it is
vacuously true, and the empty list is the one incomplete enumeration that
satisfies every other check. It is now refused, while making no claim stays
silent so a docs-only round is unaffected.

Also corrected a cross-reference to a report item that does not exist, and
dropped a `warnings` return value no caller could ever observe.

The vacuity matrix was rebuilt to cover every guard this change adds: 25 rows,
25 killed, including a control row on a pre-existing guard. Each row carries
the commit that introduced its clause, verified against that commit's blob and
its parent's, so this change's own per-commit coverage claim is checked rather
than asserted.

* fix(verify-pr): ask for the mutation matrix only when a round ran one

The artifact contract listed it unconditionally while the checker treats it as
optional, so a documentation-only round with no matrix was told to produce a
file it has nothing to put in. The ask is now conditional on having run
mutations, which is what closes the gap: a round that ran them must write them
down.

* docs(design): count the third-pass finding in the self-audit enumeration

The validation section enumerates what self-audit found in this change, so
leaving one out is the same incomplete enumeration the change is about.

* docs(design): report the matrix as per-guard, which is what it is

Applying the enumeration this change teaches to the change itself names
docs-only commits that carry no mutation row, and the checker refuses a
per-commit claim over them. Narrowing the claim is the honest response; the
refusal is recorded as the rule catching its own author.

* fix(verify-pr): the per-commit claim is earned by the enumeration

The paragraph told every round to record coverageClaim "per-commit", which is
the same overstatement the artifact contract had: a round whose enumeration has
an uncovered commit would be instructed to assert coverage it does not have.
The claim is now conditional on every enumerated commit having a row, and a
round with a gap reports per-guard and names what it did not reach. Pinned, and
added as a vacuity row.

* fix(verify-pr): a review edited without its stamp moving is still an edit

The guard compared ids and timestamps, and a review carries submitted_at
with no updated_at: editing its body moves neither, so a CHANGES_REQUESTED
review rewritten to LGTM compared clean. The snapshot now carries each
item's body next to its stamp and compares both, and refuses a fetched
list whose items have no body rather than reading it as unchanged.

* fix(verify-pr): a dismissed review moves neither stamp nor body

On PR 15388 review 5673309455 was submitted CHANGES_REQUESTED and dismissed
five and a half hours later with its 4838-character body and submitted_at
unchanged; only state moved, and that dismissal unblocked the merge gate.
The snapshot now carries each review's state beside its body and stamp, and
refuses a review list without one. The compared fields are the ones that
change what a reviewer said or decided; reaction counts stay out, so a
thumbs-up is not a dirty round.

* docs(design): cite what the dismissal record actually proves

The dismissed-review note claimed its 4838-character body was unchanged.
The API records only state and dismissal_message for a dismissal, so the
body could have been edited too and nothing would show it. What the record
does prove is that submitted_at still reads the submission time 5.5 hours
before the dismissal, which is why state is the field that has to be
compared.

* docs(verify-pr): say which refusal applies to which list

A comment list has no state, so 'refuses a fetched list missing either'
overstated what the guard refuses. It refuses an item with no body, and a
review with no state, and the sentence now says that; 'a changed state' in
the same paragraph becomes 'a changed PR state' so it cannot be read as the
review state the snapshot now compares.

* docs(design): finish the self-audit enumeration and scope the refusal

The design doc repeated the scope error the skill just dropped: a comment
item has no state to be missing. And the enumeration of findings fixed
without a row stopped before the last two corrections, so the count of
self-audit work was short of what the branch carries.

* docs(design): state the interval the record shows, not a rounded one

19:47:16Z to 01:22:57Z is five hours and thirty-six minutes. 'Five and a
half hours' understates it by six minutes, which is the sort of figure a
reader checks.

* refactor(verify-pr): LISTS has no consumer outside the guard

It was exported and nothing imported it, which is the same shape as the
unobserved return value an earlier pass removed.

* docs(design): enumerate the non-guard findings instead of counting them

'Five further findings' is a count over a set that has grown twice since
it was written, which is the failure this change exists to name. The list is
now the list, and it carries the three findings that landed after it.

* docs(verify-pr): name the matrix the survivors belong to

Inserting the coverage rule between 'pinned / not-pinned' and 'Survivors are
not noise' left the second sentence opening a paragraph with no antecedent.
It now says which matrix.

* docs(verify-pr): keep the no-projected-entries rule with the rule it belongs to

The coverage sentence was inserted between 'maps to a check that ran' and the
prohibition that continues it, so the prohibition read as part of the aside.

* fix(verify-pr): subtract the target instead of cutting at the first parent

--first-parent removes the over-count from a main merge by discarding every
second parent, which also discards the PR's own commits when it merged one of
its own feature branches: on a four-commit graph with one feature merge and
one main merge, --no-merges lists 5 (2 of them main's), --first-parent lists
1 (dropping the 2 the PR wrote), and --not <target> lists the right 3. The
undercount is worse than the overcount, because a complete-looking enumeration
lets the checker accept a claim over commits that have no row.

--not is a live-round recipe: it subtracts what the target already contains,
so a branch that has landed enumerates empty. Measured on this change's own
merged range, 85 / 24 / 0 for the three recipes, and 15 / 15 / 15 on its live
one. The rule now carries that precondition, and the empty-enumeration refusal
is what catches a round that runs it too late.

* fix(verify-pr): the refusal recommends the recipe this change now teaches

The empty-enumeration refusal still told the round to run
`git log --first-parent --no-merges`, which the skill now documents as
dropping the PR's own feature-merge commits, and a test pinned that advice.
A round that hits the refusal, follows the hint to the under-counting recipe,
can then file a per-commit claim the checker accepts, because the omitted
commits are simply not in the list. Message, doc comment and pin now name
`git log --no-merges <previous>..<head> --not <target>`, and the pin forbids
the old form.

* docs(design): catch the matrix counts up with the rows that exist

The two numbers in the design docs had drifted three revisions behind the
body and the comment (32/16 against 35/19). The claim checker now parses all
four surfaces, so the next drift fails a check instead of surviving.
2026-10-28 11:57:28 +00:00
易良 3de07f37eb fix(ci): reset desktop release runner Git metadata (#15761) 2026-10-28 11:51:45 +00:00
Shaojin Wen c3e1c9bcd6 test(managed-agent): gate G3 terminal recovery faults (#15756)
* test(managed-agent): gate G3 cleanup and terminal recovery faults

Add three packaged fault arms to hosted-failover-e2e: a refused Runtime cleanup release followed by another crash, a committed terminal whose reply is lost, and a refused terminal write over a payable checkpoint. Each keeps the original Action, execution, file effect and terminal identity, and the runner now witnesses the fault on the store or Broker boundary rather than on a client-side retry.

Part of #14481.

* test(managed-agent): harden the G3 cleanup and terminal fault arms

Drop every bounded terminal retry so the terminal reply-loss crash lands on a committed-but-undelivered terminal, and require the surviving public transcript to carry the recovered answer. Document both observations. Part of #14481.

* test(managed-agent): address the G3 terminal arm review findings

Arm C1's crash barrier only on a confirmed store 200, record that pre-crash count, and state that the replacement projects the committed terminal without a second store commit. Snapshot fault counts before the relief Turn, keep the result-commit diagnostic for after-result, and fail loudly on an unhandled crash barrier. Remove the cleanup arm: a delayed replacement reproduces a control-plane cleanup/adoption gap, so C3 stays open as a product follow-up.

Part of #14481.
2026-10-28 11:38:48 +00:00
jinyeandQwen-Coder db88f2da5e fix(ci): Decouple managed runtime distribution from Maven Central (#15782)
Co-authored-by: Qwen-Coder <[email protected]>
2026-10-28 11:07:56 +00:00
Shaojin Wen 11ef1988d8 ci(sdk-java): run the Runtime Broker fault gates when the Hosted verify fails (#15776)
The fault-gate step had no condition, so it ran only when every step
before it passed, the 201-minute Hosted verify included. Across the
nightly SDK Java runs from 10-14 to 10-27 that verify failed on 10
nights and was cancelled on one, and each time the 44 Broker fault
gates were skipped: they ran on 3 of the 14 nights. PR #15437's own
full-tier run skipped them the same way.

The gates need only the CLI bundle and the installed SDK, so give them
the condition the O4 step in the same job already uses: run unless the
run was cancelled, provided the SDK install succeeded. A failed setup
step still skips them, because the install step runs only after the
bundle has succeeded.

hosted-process-ci.test.js pins the condition, the install step's id
(an unknown id reads as '' and would skip the gates on every run) and
the setup order the condition relies on.
2026-10-28 18:25:11 +08:00
易良andQwen-Coder a6ed0518df feat(managed-agent): support opt-in same-context Java embedding (#15718)
* feat(managed-agent): support opt-in same-context Java embedding

* fix(managed-agent): isolate embedded callback filter

* test(managed-agent): qualify fixture clock

* fix(managed-agent): preserve executable maintenance artifacts

* test(managed-agent): finish the by-name bean migration at test injection points

This PR moves managed-agent infrastructure to name-only beans
(@Bean(defaultCandidate = false) plus @Qualifier("managed*") at the
production consumer sites). The migration stopped short of the test
fixtures, which left two classes of breakage:

- ApplicationContextRunner slices registered unnamed JdbcTemplate,
  Clock, ObjectMapper, DataSource and PlatformTransactionManager beans
  that no longer match the production qualifier, so the context failed
  to load (EventTransportConfigurationTest,
  RuntimeRetentionConfigurationTest,
  ToolPublicationStorageConfigurationTest,
  ToolPublicationAsyncVerificationTest, EventTransportBrokerGate).
- Full-application suites autowired JdbcTemplate by type and silently
  got Spring Boot's plain auto-configured template instead of the
  schema-aware ManagedSchemaJdbcTemplate, which H2 rejects against the
  VARBINARY identity columns as "Values of types BINARY VARYING and
  CHARACTER VARYING are not comparable".

Apply the pattern the PR already establishes in
ManagedArtifactApiIntegrationTest (registerBean("managedAgentClock", ..))
and StandaloneDataSourceConfigurationTest (@Qualifier on the injection
point): name the fixture beans, qualify the injection points. Test-only;
no production code changes.

The 18 suites named by SDK Java (fast) go from Tests run: 583,
Failures: 25, Errors: 79 to Failures: 1, Errors: 0. The remaining
failure is acceptsClientBuiltCensusAfterTheDefinitionIsCommitted, which
needs packages/core/dist and fails identically before this change.
mvn checkstyle:check reports 0 violations.

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

* test(managed-agent): qualify the last three plain-JdbcTemplate test injections

The by-name bean migration landed at fourteen injection points but left
three full-application suites still binding Spring Boot's plain
auto-configured jdbcTemplate instead of the schema-aware
ManagedSchemaJdbcTemplate. H2 rejects the resulting String-to-VARBINARY
comparisons, so SDK Java (fast) kept reporting Tests run: 5805,
Failures: 1, Errors: 3 across ManagedApprovalSelectionTest,
SurfaceAdmissionAcceptanceTest and WorkspaceSessionRetentionTest.

Apply the identical @Qualifier("managedJdbcTemplate") pattern at those
three field sites, plus the retention fixture's @Bean ManagedAgentStore
parameter: that suite's CI stack runs through
ManagedAgentStore.syncLifecycleClaim, and the store is built from that
parameter, so qualifying the field alone would not have cleared it.
Test-only; no production code changes.

Local managed-agent-server reactor on this change:
WorkspaceSessionRetentionTest 23/1/1 -> 23/0/0, the BINARY VARYING
signature drops from 7 occurrences to 1, and checkstyle reports 0
violations.

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

* test(managed-agent): inject the broker gate's clock by its bean name

ExecutionConfiguration now registers managedAgentClock and
managedAgentExecutor with defaultCandidate = false so an embedding
context's own Clock/ExecutorService neither shadows nor is shadowed by
the control plane's, and every consumer resolves them by qualifier.
EventTransportBrokerGate still injected Clock by type, so the RocketMQ
lane failed to build the test instance: "No qualifying bean of type
'java.time.Clock' available". Qualify the field the same way the
production consumers and the gate's own startup() helper already do.

The other fields keep by-type injection: Boot's auto-configured
JdbcTemplate and ObjectMapper are default candidates, and this test
runs the whole application on H2.

* build(managed-agent): keep the packaging IT out of the failsafe selection

ManagedAgentPackagingIT reads the jars the package phase wrote and
needs managed.artifact.version, so the pom runs it from the dedicated
surefire artifact-packaging execution at integration-test with both
supplied. Its *IT name also matched failsafe's default includes, which
ran it a second time without the jars' version or the property:
standaloneArtifactsIncludeConfigurationWhileLibraryDoesNot opened
target/qwen-managed-agent-server-null.jar and
retirementLauncherLoadsFromExecutableJar could not load
org.springframework.boot.loader.launch.PropertiesLauncher from it.
Exclude it from failsafe next to the existing Hosted*IT exclusion; the
surefire execution stays the single, property-bearing run.

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-28 17:43:31 +08:00
易良 b66711972c ci: run installed npm acceptance nightly instead of on PR updates (#15719)
* ci: run installed npm acceptance nightly instead of on PR updates

* ci: report nightly installed acceptance failures as issues
2026-10-28 15:51:39 +08:00
易良andQwen-Coder 9acb3a5a1d fix(ci): pin PR retries to the triggering head SHA (#15734)
* feat(managed-agent): materialize inert external workspaces

* feat(managed-agent): enable external files with verified close (#15685)

* feat(managed-agent): add the external-workspace provisioning prerequisite (#15512)

Step 3a of the external Hosted Workspace plan: a new `external-workspace`
Runtime provisioner kind that places a session-isolated worker on a registered
EXTERNAL storage tree and mints the boot document the shipped worker already
admits (`managed-context/1` plus the existing `storage` member).

The storage identity comes only from the durable registration: a read-only
primary-key lookup that admits `REGISTERED` rows, bounds the revision to
2^53-1, joins the owner row on its own primary key, and fails closed when a
persisted row disagrees with itself. A registration that cannot be read to
completion stays retryable (`runtime_external_unavailable`) so the Broker keeps
the placement adoptable instead of burying it. `external-workspace` accepts
only the closed evidence set {storageRef, mountRoot, continuity}; `mountNonce`
from the design is deferred and explicitly refused, because the registration
record cannot persist it yet and an unverifiable claim must not be ignored.

Hosted tools, EXECUTION admission, publication consumption, `stopDrained` and
close gating are not enabled: `supportsDrainedStop()` stays false, every
managed Workspace feature remains refused, and preflight now states the
prerequisite in an `external-prerequisite` warning. No Session can be placed
yet, and every placement fails closed.

`external-scratch` behavior and wording, the shipped worker
boot/storage/registration.json contract, the schema and the CLI are unchanged.

* test(managed-agent): pin external storage refusal in lock canary

* fix(managed-agent): require exact external workspace digest

* fix(managed-agent): reject storage evidence for non-running workers

* feat(managed-agent): add inert exact-worker external drain

* feat(managed-agent): fence external workspace ownership and drain

* feat(managed-agent): enable external files with verified close

* docs: record verified external files and remaining phase two work

* fix(web-shell): avoid recovery warning for closed sessions

* test(ci): pin HostedExternalPublicFilesIT in the hosted-process class list

#15685 adds HostedExternalPublicFilesIT, so hosted-process-ci.test.js's
expected class list must carry it; the meta-test pins which IT classes the
hosted-process lane runs and had 35 entries against 36 discovered.

* fix(managed-agent): restore admission precedence and honour a LOST drain claim

The external-files increment resolved the Workspace above the Agent pin, so a
request whose stored definition cannot take effect, or whose revision cannot be
selected, was refused on Workspace grounds first: it answered 404
workspace_required and a bare IllegalStateException instead of 409
agent_definition_unsupported / 400 unsupported_feature. Resolve the pin first
again and keep the external pin rewrite once the Workspace is known.

The close-claim validation admitted only DRAINING, but drainClaimedBinding
deliberately keeps a LOST binding in LOST until its stop proof commits, so the
arm refused its own claim with runtime_close_claim_pending instead of surfacing
the stop's runtime_broker_recovery_blocked. BindingRenewal now records that its
claim was taken on a LOST binding and admits that state.

The settlement probe reads the storage kind to refuse an EXTERNAL tree the
bridge holds no mount for, so its fixture needs the managed catalog rather than
an empty database. The lock-order canary gains the new ExternalWork.apply/3
ambiguity, whose callee locks the Session row first and never reads ma_turn.

* fix(ci): pin PR retries to the triggering head SHA

* docs(ci): correct two comments the head-SHA pin made stale

Pinning the four pull_request lanes to github.event.pull_request.head.sha
left two comments describing the old checkout: the one above test_macos
still cross-referenced the Ubuntu gate's superseded wording, and
check-lint-gate-freshness.mjs still said the lint lane checks out
refs/pull/N/head.

Comment-only. ci.yml shrinks by 5 bytes (156090 -> 156085), so neither the
.size-baseline ratchet nor the .ci-budget ledger moves.

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

* test(ci): pin all four PR checkout lanes to their whole ref expression

The whole-expression `toBe` pin covered only `test` and `lint_and_static`.
`web_shell_e2e_smoke` and `integration_no_ak` were guarded by
`toContain(head.sha)` plus `not.toContain('refs/pull/')`, neither of which
can see arm order: moving `|| github.ref` ahead of the pull-request arm
keeps both green while the lane checks out the asynchronously rebuilt merge
ref this PR exists to get away from, and the ancestor guard still passes
because a newer merge tree contains the expected head.

Pin both lanes against their own full ref literal, so arm order is part of
the contract the same way it already is for the push-arm lanes. Verified one
mutant at a time on each of the two lines: the new assertion turns red
(1 failed | 79 passed) while the two substring assertions stay green, and
the tree is byte-identical after reverting.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-28 10:22:22 +08:00
e305a774be fix(release): use the run-scoped token for the publish checkout (#15728)
* fix(release): stop authenticating the publish checkout with the bot PAT (#15726)

The publish job's read-only checkout used secrets.CI_BOT_PAT while every
other checkout in the release workflow uses the run-scoped token. Two
release runs failed at exactly that step with "could not read Username for
'https://github.com': terminal prompts disabled" (git exit 128), and both
passed on re-run. The bot PAT is already exported to the push and
release-creation steps that write, so the read-only fetch does not need it.

* test(ci): correct the release workspace wipe rationale (#15728)

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

---------

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

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

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

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

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

* test: verify Workflow mode recording and Mod permission boundaries

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

* test(cli): assert headless Workflow mode eligibility

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

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

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

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

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

* fix(sdk): close query after initialization failure

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

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

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

* fix(cli): preserve workflow mode startup failures

* fix(acp): refresh restored session config options

* fix(core): rearm workflow notices after cancellation

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

---------

Co-authored-by: qqqys <[email protected]>
2026-10-28 01:09:37 +08:00
jinyeandQwen-Coder dee6ace9d8 fix(ci): Preserve canonical artifacts across job retries (#15713)
Download the immutable artifact emitted by the successful producer when only a distribution consumer is rerun.

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-27 13:10:57 +00:00
易良 b227af2b58 fix(release): close the failure issue once a re-run passes (#15711)
notify_failure files an issue whenever the latest attempt of a release
run fails, but the job is skipped as soon as a later attempt succeeds,
so nothing ever closes the issue it filed. v0.26.2-preview.0 shipped on
a successful re-run while #15672 stayed open, was picked up by the
scheduled autofix agent, and produced a PR against a release that had
already landed.

Add a success-gated sibling job that reconciles that issue: it comments
the passing attempt, clears the autofix labels, and closes as not
planned. Closing requires bot authorship, so a human's same-titled
issue is never touched, and the job also stays away when any validation
job failed so it cannot race the notifier under force_skip_tests.
2026-10-27 17:43:38 +08:00
ChiGao 7a9a88938e feat(cli): complete OpenTUI extension discovery and source management (#15671)
* fix(cli): align OpenTUI cancellation and retry with Ink

* fix(cli): discard abandoned OpenTUI tool attempts

* feat(cli): preserve OpenTUI session replay and rewind

* fix(cli): preserve checkpoint restore ownership and cold continuation

* test(cli): cover committed conversation rewind across renderers

* feat(cli): capture and restore OpenTUI checkpoints

Candidate checkpoint before parent test integration; full normal and combined product validation are still pending.

* test(cli): remove duplicate checkpoint regression declarations

* fix(cli): preserve Ink compact tool summaries after restore

* fix(cli): preserve OpenTUI clipboard source through draft recovery

* test(cli): cover OpenTUI raw recall and framed Vim submissions

* fix(ci): register clipboard regression in TUI parity gate

* feat(cli): complete OpenTUI extension discovery and source management

* fix(cli): consolidate catalog interactions and focused key subscriptions

* fix(cli): release catalog keyboard subscriptions during consent

* fix(cli): preserve complete OpenTUI source information

* test(cli): wait for restored assistant before recovery assertions

* fix(test): preserve runner Git in installed CLI isolation
2026-10-27 16:38:59 +08:00
Shaojin Wen 7b09c0a940 fix(ci): keep the sdk-java cache write-back on a failed suite (#15555)
* fix(ci): keep the sdk-java cache write-back on a failed suite

The self-hosted test step exits on the first Maven failure under errexit, so
the cache write-back below it never ran for a red suite. The downloads that
run had already made were dropped, and the next leg re-fetched them.

Record each Maven failure in mvn_status instead, stop the remaining suites,
and apply that status after the write-back. A tolerated write-back skip now
reports the same status rather than turning the step green.

This is the residual of #15289 after #15254 landed the suite-outside-the-lock
split on main. Verified on Linux CI: the write-back publishes a stub Maven's
artifact and the step exits 7 with no second Maven run; reverting the final
exit, or one status capture, reddens the new pins.

* fix(ci): close the sdk-java deferred-status review gaps

Two harness defects shipped with the deferral: the new case never created
its runnerTemp, so its load-bearing "one Maven call" assertion threw ENOENT,
and the older write-back harness sliced below the `mvn_status` assignment,
so the deferred exit aborted it with status 2. Both reddened the ubuntu
lane, which is the only one that executes them.

The tolerated-skip arms were only pinned as literal strings, so a skip that
reported 0 on a red suite would have passed every test; they now run with
both a green and a red status. A red run can also leave Maven's resolver
tracking markers in the job-local repository, which the delta filter would
publish into the host cache one consumer seeds without stripping, so both
write-backs prune the names the seed already drops. The deferred exit emits
an annotation naming the failing stage, because the write-back output now
sits between the failure and the log tail.

* fix(ci): bound the red-path wait and name the stage at every write-back exit

A red leg reached the write-back but then queued for the full 9600 s behind
a failure that was already decided, holding a pooled runner to publish what
is often nothing; it now waits only the 300 s the copy itself may run.

The stage annotation only covered the final exit, so a skip left the false
"the gate result above is unaffected" warning as the last word and the
fatal-flock arm reported 1 without naming the suite. One reporter now runs
at each of those exits. The annotation also lands where the rerun
classifiers grep, so the behavioral test pins it against
INFRA_FAILURE_SIGNATURES, and the MariaDB lane's inline-failure premise —
which the whole two-lane asymmetry rests on — is pinned too.

A hard-killed transfer leaves *.tmp fragments that never resume, and the
delta filter published them; both write-backs and every seed now drop that
name, with the marker/tmp exclusions exercised by a red-suite fixture
instead of only by string pins.
2026-10-27 05:55:28 +00:00
易良andQwen-Coder 74efa46640 fix(ci): preserve setup-java JDKs during pool fallback (#15623)
* fix(ci): isolate pool JDK fallback cache publication

* fix(ci): use the aarch64 JDK mirror directory

* fix(ci): normalize setup-java cache versions

* fix(ci): match setup-java's real tool-cache directory name

The pool fallback probed setup-java's cache at
Java_Temurin-Hotspot_jdk/${dir%%-*}/${arch}, stripping the build suffix.
setup-java does not strip it: getToolcacheVersionName() rewrites the first
'+' of Adoptium's semver to '-' BEFORE tc.cacheDir() applies semver.clean(),
and clean() keeps a prerelease -- so the real directory keeps the build
suffix (11.0.32-101, 21.0.13-9.0.LTS). The probe path never existed, the
adoption branch was dead code, and every fallback re-downloaded the pinned
archive even on a host that already held that exact JDK.

Key the probe by the full ${dir}. Our own namespace is unchanged: the script
still publishes only into qwen-pool-jdk/, never into setup-java's.

The test fixture derived the same directory with semver.clean(setupVersion),
encoding the same wrong premise, so both sides agreed by construction and the
suite stayed green over a dead reuse path. Derive it the way setup-java does
instead; reverting only the script line now turns 8 of 14 tests red.

Also assert GITHUB_PATH and toolchains.xml's jdkHome on the reuse path, and
make success() report stdout -- the script writes its ::error:: diagnostics
there, so the old stderr-only message was empty exactly when it was needed.

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

* fix(ci): defer private JDK staging until cold install

* fix(ci): reinstall JDKs with orphaned cache markers

* test(ci): pin the cold-install failure path of the pool JDK fallback

Deferring JDK staging into the cold-install branch left `::error::cannot
create` reachable with no witness. Add the cold-path arm: no shared entry,
private namespace shadowed by a regular file, assert the script fails closed
on its own marker and publishes nothing to GITHUB_ENV.

The assertion has to name `::error::cannot create` rather than the bare
phrase: `mkdir` prints "cannot create directory" itself, so `toContain
('cannot create')` still passes when the guard is dropped and the script
falls through to `::error::cannot stage the download`.

Also drop `expect(f.downloads()).toBe(0)` from the sibling-marker case.
Under `MODE=network` the curl shim exits 22 before it appends to the
counter, so that assertion could not fail; `success()` already pins zero
downloads there, because any curl attempt fails the script closed.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-27 11:54:25 +08:00
c9197a2dce fix(release): extend standalone runtime download retries (#15673)
* fix(release): extend standalone runtime download retries (#15672)

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

* docs(release): state retry-budget evidence without claiming the failing leg (#15672)

The v0.26.2-preview.0 archive step aborted and passed on an unchanged re-run, but its job log was never retrievable, so raising the download retry budget from three to five attempts is defence-in-depth, not a confirmed fix for the download leg. Record that in the source comment and drop the same unconfirmed attribution from the retry test.

* fix(release): correct retry-budget comment with attempt-1 log evidence

The comment claimed the v0.26.2-preview.0 archive-step log was not
retrievable and the failing leg unpinned. The job log is available via
the Actions API and shows three consecutive 504s from the GitHub
Releases CDN downloading the bun darwin-x64 runtime, so state the
pinned leg directly.

---------

Co-authored-by: Qwen Autofix <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: 易良 <[email protected]>
2026-10-27 11:51:23 +08:00
易良 51c59e69bb test(integration): widen the ready-prompt budget on GitHub-hosted macOS x64 (#15677) (#15692)
The tui-parity installed-distribution darwin-x64 leg (macos-15-intel)
stalled past the 30s default on 5 of its first 6 runs with the banner
already rendered, so hosted macOS x64 joins the 90s stall tier the
self-hosted hosts already carry. Every other hosted host keeps the
tight 30s fail-fast budget.
2026-10-27 11:13:32 +08:00
易良 e24f9c48fd fix(ci): deepen until the lint scope's own acceptance holds (#15644)
* fix(ci): deepen until the lint scope's own acceptance holds

The deepen loop stopped as soon as `git merge-base --all` exited 0, but
scripts/lint-scope.js also rejects an ambiguous merge base and a shallow
boundary above it. A branch that merged the base tip proves a merge base
immediately while its head-side boundary still sits above it, so the loop
broke with zero deepens and lint-scope fell back to a full scan with
three unused attempts. Mirror the consumer's acceptance instead:
ambiguity stops the loop (deepening cannot fix it), a shallow boundary
keeps it going.

The two give-up paths — cap reached and an aborted deepen fetch — end in
the same downstream warning whose remedy (a larger cap) is wrong for the
aborted one. Emit the deepen count and the base depth reached so they are
distinguishable in the log and the step summary.

Pins now assert the mechanism rather than a bare `--deepen=` substring:
the blobless deepen, the merge-base guard, the shared predicate call,
`|| break`, the 3-round cap, and the give-up line.

* fix(ci): report completed deepens and ambiguous history
2026-10-27 09:52:58 +08:00
Shaojin Wen eea44208b8 ci: run every suite but CLI and core in the side test lane (#15570)
* ci: run every suite but CLI and core in the side test lane

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

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

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

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

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

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

The print-step test also pins the `if [ -f "${side_log}" ]` guard around the
cat, which is what keeps that step quiet on an ordinary red test job.
2026-10-27 00:01:58 +00:00
jinyeandQwen-Coder 34769f9d41 fix(ci): Isolate Managed runtime release test classes (#15676)
Co-authored-by: Qwen-Coder <[email protected]>
2026-10-26 20:09:55 +00:00
Shaojin Wen 2f3d1b2ff5 feat(verify-pr): add a local preflight and a verdict check (#15614)
* feat(verify-pr): add a local preflight and a verdict check

A maintainer round resolves the metadata snapshot, the base and head OIDs,
the artifact directory name, and the verdict/count agreement by hand. The CI
lane supplies all four; a workstation supplies none, and every one of them
fails silently rather than loudly — a snapshot from the wrong repository, a
parent commit taken for the base, a directory the collector never sees, or
counts that contradict the stated verdict.

Add a preflight that resolves the coordinates and reports whether this
process is isolated, and a checker that applies the publisher's consistency
rules to a local artifact directory. Point the skill at both where the flow
needs them.

* fix(verify-pr): address the local-helper review round

The macOS sandbox marker is not an isolation boundary: its default profile
still reads host files, so it is reported as a caveat instead of a pass.

The coordinate path no longer gates on isolation. It must reach `gh`, which
the boundary excludes, so it always runs on the host; gating there fired on
every round and made the override the normal path. The check moved to
`--require-isolation`, meant to run inside the boundary.

A null or array assertions.json is now rejected instead of skipping every
count check, the missing-OID hint names the `--repo` repository rather than
`origin`, and the untrusted PR title is stripped of control characters before
it reaches a terminal.

The shared entry-point check moved under scripts/lib, and a workflow meta-test
pins the checker's verdict vocabulary to the publisher's case labels.

* fix(verify-pr): gate on credential-free readiness, not a container marker

The CLI's own Docker/Podman launcher mounts ~/.config/gcloud and the ADC
file, copies model API keys it can see, and then sets SANDBOX to the
container name. Counting that marker as a passing boundary let a
credentialed container through, which is the same class of false pass the
Seatbelt fix removed.

A marker is now an observation. Passing requires the explicit
QWEN_VERIFY_CREDENTIAL_FREE=1 attestation and no known credential signal
(cloud, SSH, kube, docker, git and gh variables plus their well-known
paths, and SANDBOX_MOUNTS, which can carry anything). An attestation
contradicted by a visible signal exits 4.

* fix(verify-pr): close the three findings from the verification round

The boundary gate's credential list now names ~/.config/gh and ~/.npmrc —
the persistent form of the gh token and a registry token, already covered in
their environment form.

A report now has to cite the verified head when the round snapshot is
present, not merely some 40-hex token that a base OID satisfies. An
unreadable snapshot is reported instead of silently downgrading the check.

A boolean-looking SANDBOX value no longer counts as a container marker.
2026-10-27 01:49:59 +08:00
ChiGao 0539160f35 fix(cli): preserve previews and verify installed OpenTUI distribution (#15655)
* fix(cli): preserve previews and verify installed OpenTUI distribution

* fix(ci): record installed distribution workflow growth

* fix(ci): record installed distribution matrix budget
2026-10-27 00:23:29 +08:00
易良 2c9fe85a58 fix(ci): resolve the pool's Maven dependencies from the intranet mirror (#15641)
The pool's public bandwidth is 100 Mbps for 32 runners, so a burst of
dependency fetches saturates it, and the resulting loss turns into the
setup-java stalls and 429s seen on the Hangzhou hosts. Alibaba Cloud serves
Maven from mirrors.cloud.aliyuncs.com inside the VPC, where the traffic never
touches that cap: byte-buddy 1.17.8 arrives as the same 9,018,553 bytes in
0.54 s, faster than the public cap allows.

Probe a real artifact (the directory root answers 404) and keep the public
mirror for the hosts that cannot reach the intranet one.
2026-10-26 12:31:13 +00:00
3521066b4f feat(serve): allow secondary sessions to cd within their workspace (#15323)
* [to #DW-549] feat(serve): allow secondary session cd within its workspace

* [to #DW-549] fix(serve): preserve cd rejection diagnostics

* [to #DW-549] test(serve): cover session cd in no-AK CI

---------

Co-authored-by: 罗辉 <[email protected]>
Co-authored-by: 易良 <[email protected]>
2026-10-26 20:19:50 +08:00
易良 71e35eb263 test(lint): pin the .gitignore grouping arm and the batch stride (#15640)
Three gaps from the review of #15531:

- The `.gitignore` arm is the only grouping boundary that can decide a
  non-root group in this repository (26 tracked nested .gitignore files,
  zero nested prettier configs), and the only fixture that placed one sat
  next to a .prettierrc, so deleting the arm kept the suite green. New
  case groups a package by its own .gitignore alone; dropping the arm now
  collapses it to one group and fails.
- Nothing covered a target set larger than one spawn batch. New case
  changes 51 files at the root group and asserts both spawns and that
  their union is the whole selection; a stride or slice edit that drops
  the tail now fails instead of reporting green on unchecked files.
- The appended correction left the previous rationale in place, so the
  `toEqual([lane])` assertion sat under two mutually exclusive
  explanations. Remove the stale lines.
2026-10-26 19:42:06 +08:00
ChiGao edd1282a1a feat(cli): align OpenTUI foreground shell controls with Ink (#15642) 2026-10-26 19:11:27 +08:00
易良andQwen-Coder 49056c09e9 feat(ci): canary-split the PR review model by PR number (#15538)
* feat(ci): canary-split the PR review model by PR number

The review job read its model from a single repository variable, so
evaluating a second model on real review traffic meant flipping every
review at once and flipping back to undo it.

Read a canary model and a percentage alongside the default, and route
`PR_NUMBER % 100 < percent` to the canary. The bucket is per PR, so a
PR keeps one model across its review rounds. Both variables unset (or a
zero/non-numeric percent) leaves today's behaviour byte-identical, and
reverting a rollout is one variable write.

The run also announces the model it chose: nothing else records it (the
cost ledger carries tokens, not a model name), so a rollout nobody can
attribute afterwards is unmeasurable.

* fix(ci): fail the canary percent safe to off, and pin the clamps that guard it

Out-of-range canary percents clamped to 100 — maximum rollout. Both
out-of-range paths failed open: a 4+ digit value hit the digit-count bound
and became 100, and a 1-3 digit value above 100 hit the >100 clamp and
became 100. Fat-fingering 50 as 500, or pasting a per-mille 1000, would
switch every PR review in the repository to the unproven model at once.
The block's own invariant is "the canary must never be the reason a review
breaks", so out-of-range now lands on off.

That diverges from SALVAGE_ELAPSED_PERCENT deliberately — there 100 means
cede, here it would mean full rollout — and the comment now says so.

Tests, each mutation-verified one mutant at a time:

- The clamp table pinned the old direction ('250' and '1000' both expected
  'canary'), so it flipped with the fix. Each row now isolates one clamp
  step: '0050' the leading-zero strip, '250' the >100 clamp, and
  18446744073709551666 the digit bound — bash wraps that to 50, so
  deleting the bound is a silent half rollout rather than off. The '1000'
  row discriminated nothing and is gone.
- The "Sticky per PR" assertion compared two identical calls into a pure
  function, so it could not fail. Replaced with a sweep of the whole bucket
  range at 10% asserting the selected set is exactly 12300..12309. That
  dominates the two literal boundary pins it replaces (12309 in-set, 12310
  excluded) and additionally catches a nondeterministic bucket.

Mutation matrix (focused run, one mutant each, restored byte-identical):
revert clamp direction / delete strip / delete digit bound / delete >100
clamp / %100->%10 / -lt->-le / RANDOM bucket — all RED. RANDOM killed 3/3,
where the old self-equality assertion was measured at 22.5%.

Full file: 263 passed | 4 skipped (267). check-workflow-size.sh and
check-ci-budget.mjs rc=0; .size-baseline bumped to the file's exact 270879
bytes.

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

* fix(ci): report the canary percent actually applied, and pin the call site

The cohort notice interpolated the raw REVIEW_MODEL_CANARY_PERCENT while
review_model_for_bucket applied a clamped one. Since the clamp-direction
fix, out-of-range values clamp to off, so a repository-variable typo (250,
1000, abc, "10 ") announced `canary 250%` while every PR routed to the
default model — the notice asserted a cohort that never ran, which is the
inverse of the truth in exactly the case the clamps exist to absorb. The
clamp chain moves into canary_percent_effective so the router and the
notice read one normalized value.

Also trims the canary model name before the call. A padded paste reaches
--model verbatim, the provider rejects it, and that failure is terminal
for the whole cohort on every re-review round while reading as flakiness.
Whitespace-only still means "no split" rather than an invented cohort. The
default model's untrimmed path is main-side at two call sites and is left
to its own change.

Adds an executed call-site test: it slices the real block (helper, router,
trim, call site, notice, MODEL_ARGS) and asserts on stdout, so the four
arguments the workflow actually passes are pinned for the first time — the
unit rows above supply their own positional arguments and cannot see them.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-26 15:19:15 +08:00
易良 57e71d7fbb fix(ci): resolve libfaketime for the host architecture (#15609)
* fix(ci): make the full-tier fixtures portable across OS and architecture

Two full-tier legs fail on main for reasons unrelated to the change under
test, and both block every PR that reaches an approval.

The Windows leg dies in ManagedExtensionExecutionProfileTest: the fixture passes
POSIX literals such as /private/cache, which Path.of does not consider absolute
on WindowsPath, so the linux-cgroup assertion throws 'Managed extension cache
directory must be absolute'. Build the paths from java.io.tmpdir instead.

The EventTransport multi-node leg hardcodes /usr/lib/x86_64-linux-gnu/faketime:
the aarch64 pool hosts install libfaketime under aarch64-linux-gnu, so the
preload check and the -Dqwen.gate.faketime.library coordinate both point at a
file that cannot exist there. Resolve the library with find and pass it through
GITHUB_ENV.

* fix(ci): install the multi-node faketime from the pool's intranet mirror

The pool hosts sit inside Alibaba Cloud, where the internal Ubuntu mirror
answers from the VPC; the public archive is slow from that egress and the
install step is one of the two failures that reddened the full tier. Point apt
at the internal mirror when it answers, keep the hosted runners on their own
sources, and probe it first so the branch cannot run away from a reachable
archive.

* Revert "fix(ci): install the multi-node faketime from the pool's intranet mirror"

This reverts commit dc738af383.
2026-10-26 14:41:06 +08:00
qqqys 09b1244150 feat(mods): add ui.render, $.ui.resolve and $.ui.invalidate to the runtime (#15591)
* feat(mods): add ui.render, $.ui.resolve and $.ui.invalidate to the runtime

The first stage of terminal rendering (#13749): the worker raises
ui.render for a site the host offers in its load request, and judges each
hook's tree with Claude Code 2.1.310's rules for Box and Text. Mods get
$.ui.resolve's frozen element table, the h and Fragment globals, and
$.ui.invalidate, which reaches the host after the dispatch that called it.
No host offers a site yet, so ui.render hooks are still skipped and
listed at startup.

The design for the whole stage is in
docs/design/2026-10-26-mod-terminal-render.md.

* fix(mods): keep render text cuts, notes and h children aligned with Claude Code

- Cut text past the cap before a high surrogate, so the adjusted tree never
  holds a lone surrogate the validator refuses, and report the characters
  actually drawn.
- Say each kind of adjustment (border style, text cut) once per load and
  site, so a text cut whose length changes on every render is told once.
- Hand a function component its children flattened, always an array, as
  Claude Code's h does.
- Freeze ENGINE_NODE, and document Claude Code's invalidate backoff for the
  Ink scheduling step.
2026-10-26 12:50:01 +08:00
qqqys 645d32eed3 feat(acp): run the model round of fresh prompts on the shared agent loop (#15509)
* feat(core): let a host run its own rounds on the shared model round

ACP keeps its tool step while its model round moves to LlmClient, so it
needs a few things the client did not expose:

- Turn gives Thought the chunk's raw text and ToolCallRequest the
  functionCall it was built from;
- with SendMessageOptions.toolCallPreparations, Turn yields a
  ToolCallPreparation event for a call the model started streaming,
  also in a blocking Stop hook's continuation; without it Turn yields
  the events it did, which matters to the Hosted Harness;
- SendMessageOptions.userTurnRecordedByHost skips the UserQuery's user
  message record and todo work chain start;
- admitUserPrompt takes the hook's submitted_prompt and a signal;
- the agent loop exports runModelRound.

Part of #15327.

* docs(design): plan PR 2 in three steps and describe PR 2a

* feat(acp): run the model round of fresh prompts on the shared agent loop

With experimental.unifiedAgentLoop on, a fresh user prompt outside a
Goal, channel or goal-proposal approval turn sends each model round
through LlmClient via runModelRound. The client then does what ACP
re-implements: UserPromptSubmit on the verdict admitUserPrompt took at
the same point, memory recall, the file-history snapshot, system
reminders, workflow intent, MessageDisplay, compaction, the session
token gate and the external Stop hook. ACP keeps its tool step, its
user message record, its Stop loop for the Todo Stop Guard and
background results, mid-turn input and its session/update output.

The Web Shell settings panel now lists the setting. A new no-AK suite
drives ACP twice per scenario and requires the same requests, updates
and stop reasons, apart from three asserted differences.

Part of #15327.

* docs(design): link the PR 2a pull request

* fix(acp): keep the Stop hook in ACP's Stop loop on the shared round

Review fixes for the shared model round of fresh ACP prompts:

- the client skips its Stop hook (new stopHookRunByHost send option) and
  ACP's Stop loop fires it as with the setting off, so ACP's own
  continuations stay interruptible and are judged by the hook, and the
  Todo Stop Guard is suspended at the cap
- the queued-input window closes when a response finishes
- a round that ends with a stop reason discards its resolved tool cards
- ACP throws the error the provider threw (getStructuredErrorCause)
- one failure helper for both rounds, one compression notice, and
  getThoughtSummary built on getThoughtText

* fix(acp): leave the interaction span and tool-call guards to ACP on the shared round

Second review round for the shared model round of fresh ACP prompts:

- interactionSpanByHost: the client neither starts nor ends an
  interaction span, so ACP's span (with its daemon parent) is the only one
- toolLoopGuardsByHost: the client skips its always-on tool-call checks,
  the exploration budget and reminder, the stateful-read result checks and
  the tool-call heuristics (new LoopCheckOptions.toolCallPatterns); ACP's
  daemon guards stay the only ones, so the reminder is no longer sent twice
- a stream Error is thrown after the round, so the client's error epilogue
  runs first
- ACP schedules the managed memory tasks itself when no UserQuery or
  ToolResult round ended the response, as after queued input cut it short
- parity: a scenario past the per-turn tool-call cap; fix the
  typecheck:integration errors in the parity suite

* docs(design): list host-owned send options and move each surface's default on its own

- Add a ledger of the send options that tell the client to skip part of
  its work for one host, with the step that removes each, and a
  Stop-point continuations subsection for ACP's own continuations.
- Swap ACP's next steps: PR 2b moves the tool batch of fresh prompt turns
  onto the scheduler, PR 2c moves the other turns onto the whole loop, so
  each ACP loop body changes once.
- Each surface changes its default on its own once it meets the listed
  conditions, starting with headless, and drops its current loop one
  release later.

* fix(acp): keep tool-result recall and run memory tasks after the Stop loop on the shared round

Third review round for the shared model round of fresh ACP prompts:

- toolLoopGuardsByHost now skips only the tool-call guards, the
  exploration reminder and the todo reminder ACP's tool-result message
  carries; a tool-result send again delivers the memory recall and the
  queued loop reminder and runs the size-only microcompaction
- under stopHookRunByHost the client no longer schedules the managed
  memory tasks when a round ends; ACP runs them through the now public
  runManagedAutoMemoryBackgroundTasks once its Stop loop ends the turn,
  with its existing end_turn and loop-protection conditions
- a UserPromptSubmit block of a tool-result round shows the block notice
  and suspends the Todo Stop Guard instead of ending the turn silently
- core values come from their defining modules, not the package root

* test(acp): leave the follow-up suggestion out of the ACP parity suite

The suggestion request runs after session/prompt returns and may or may
not land before the agent is stopped, so one arm could record an extra
request. The suite compares the prompt's model rounds only.

* fix(acp): snapshot the user turn and open send-now from the request on the shared round

ACP records the user turn before the shared round's first send, but the
round left the file-history snapshot to that send. A prompt cancelled
between admission and the first send kept its user turn without a
snapshot, and rewindToTurn indexes snapshots by user-turn position. ACP
takes the snapshot again, as on the current path, and
userTurnRecordedByHost also turns off the client's snapshot.

The response-interrupt window opened on the client's first event, while
the current path's watch() is open once the request is out, so input
sent before a slow first token waited for the first chunk. The window
now opens at the send; a cut is held while the round's request has not
reached history, so it cannot drop the message it carries, and the
first event pulls again.

* fix(acp): recall from the prompt text and close blocked tool results on the shared round

- Pass the raw prompt text as `memoryRecallQuery`, so managed-memory recall
  no longer searches resolved @-file contents and host notices.
- When UserPromptSubmit blocks a tool-result round, close its calls with an
  explicit block result instead of leaving them to the orphan repair, whose
  "retry if needed" text invites rerunning the tool into the same block.
- Take the memory task promises the client queues after each turn; ACP
  reports no memory updates, so they otherwise accumulate for the session.

* docs(design): drop the recall-text difference and note the blocked tool-result close

* fix(acp): keep unsent input and per-response facts on the shared round

- Start fresh response facts and report usage per request when the
  client's next-speaker check continues within one round, so a
  continuation's complete edit is not refused for the earlier response's
  max-tokens cut, and its usage is not dropped.
- When the round throws before its request pushed the message, such as a
  cancel during a tool-result round's UserPromptSubmit hook, keep the
  message in history as the non-throwing stops do, so tool results that
  already ran are not left to the "retry if needed" repair.
- When UserPromptSubmit blocks a tool-result round, keep the input taken
  with the results (already shown as delivered) beside the blocked
  results instead of dropping it.
- Cover the merged hook-context dedup's empty-request append in core.

* test(acp): pin the unsent-message keep on a tool-result hook timeout

A tool-result round's UserPromptSubmit request carries no abort signal,
so a cancel does not make it throw; the case that throws before the push
is the hook request failing, such as its 60 s message-bus timeout. Test
that case instead, and say so in the comment.
2026-10-26 04:49:42 +00:00
Shaojin Wen ef4c13283f fix(managed-agent): pass the pool Harness the Broker pair serve removes (#15394)
* fix(managed-agent): pass the pool Harness the Broker pair serve removes

The Kubernetes launcher handed `qwen serve` the Broker URL and token as
QWEN_HOSTED_HARNESS_BROKER_URL/_TOKEN. runQwenServe read that pair as a
fallback but never removed it from process.env, so the token stayed in the
Harness environment for the life of the process, and children started with
that environment inherited it. The systemd Harness passes
QWEN_MANAGED_RUNTIME_BROKER_URL/_TOKEN instead, which the serve handler reads
and deletes at startup.

- The launcher and harness-pool.yaml use QWEN_MANAGED_RUNTIME_BROKER_URL and
  _TOKEN, so both deployments share one pair and one removal.
- Drop the QWEN_HOSTED_HARNESS_BROKER_* fallback and its process.env guard
  allowance.
- Test the launcher's child environment against the names serve deletes, the
  manifest against every variable the launcher reads, and the handler's
  removal of the pair.

* fix(managed-agent): name a missing pool Broker URL

- The launcher reports a missing QWEN_MANAGED_RUNTIME_BROKER_URL as "The
  pool requires a Broker URL." rather than the generic configuration error,
  so a manifest that still sets the old name shows up in the Pod log.
- The handler test records the Broker pair when the runtime starts, since
  the runtime snapshots process.env for every process it starts. Checking
  after the handler returned could not tell a delete before runQwenServe
  from one after it.
- The Kubernetes README no longer says the systemd Harness uses these names;
  on main it still passes the pair as command-line flags.

* fix(managed-agent): name the pool Broker URL variable in launcher errors

The Broker URL variable is the input this change renamed, so each
launcher error about it now names QWEN_MANAGED_RUNTIME_BROKER_URL, and a
URL that does not parse gets its own message instead of the generic
configuration error. Messages still never include the value.

The Kubernetes README now limits the startup-scrub statement to the
pool's `--profile hosted-harness` launch, which always reaches the full
serve handler; the serve fast path does not read the pair.

* fix(managed-agent): name the Broker URL rename and refuse a path in the pool launcher

The v0.26.1 manifest sets the Broker URL as QWEN_HOSTED_HARNESS_BROKER_URL,
which nothing reads any more, so a Deployment created from it stops at
the launcher. The launcher now names the rename when only the old name is
set, and both Kubernetes READMEs say to rename it before rolling out a
later image.

qwen serve takes an origin only, so a Broker URL with a path passed the
launcher and then stopped serve at boot without naming the variable. The
launcher now refuses it with its own message.

The READMEs also say that the environment qwen serve started with stays
readable in /proc/<pid>/environ to processes of the same user in the
container, so removing the pair stops inheritance, not that exposure.

* docs(managed-agent): rename the pool's Broker URL in the same apply as the image

Renaming the Deployment variable on its own rolls Pods that still run
the v0.26.1 image, whose launcher reads only the old name and exits with
the generic configuration message, so the rollout stalls without naming
the rename; an image rollback produces the same mix. Both Kubernetes
READMEs now apply the rename and the new image digest together, describe
the stalled rollout, and say that a rollback restores the old name with
the old image.

The launcher test also pins the full qwen serve argv, including
--profile hosted-harness, and checks the credential, query and fragment
arms of the HTTPS Broker URL guard, not only the protocol.

* test(managed-agent): say why the pool launcher test pins the serve profile

serve.ts removes the Broker pair whatever the profile. The profile is
what lets validateHostedHarnessProfile accept the pair, and a launch
without it exits instead of starting a default daemon, so the comment
over the argv pin now says that.
2026-10-26 02:03:14 +00:00
jinye 06d82d786a feat(managed-agent): support Workspace file uploads (#15586) 2026-10-26 01:10:18 +00:00
Shaojin Wen 9c0414ab42 feat(managed-agent): let the bootstrap set the service account and MySQL up (#15543)
* feat(managed-agent): let the bootstrap set the service account and MySQL up

With --service-user, run as root, the bootstrap writes the systemd
layout's files with their final owners and modes: the account owns
harness.env and application.yml, server.env is 0640 root:<group>, and a
new directory is 0750 root:<group>. It refuses an unknown account, a
malformed name, a non-root run, or a directory path the account cannot
enter, before writing anything. server.env records the account as
QWEN_MANAGED_AGENT_SERVICE_USER.

The bootstrap also generates the database password and writes
mysql-setup.sql, readable by root only, which creates the database and
the qwen account for localhost and 127.0.0.1 with the performance_schema
grant, and fails rather than changing an existing database or account.

Preflight gains a service-user row, and the config and credentials rows
check access as that account, including every directory above the file,
so a root --check reports a file the service cannot read.

* fix(managed-agent): check service-account access along symbolic links

Both access checks judged directories from one side of a symbolic link
only. Preflight resolved the file's real path first, so it skipped the
directories on the way to the link: a link in a root-only 0700 directory
passed although the account cannot enter that directory. The bootstrap
stat'ed through the link but walked up the path as given, so it skipped
the directories above the link's target: a configuration directory
linked under a root-only 0700 directory passed, and the bootstrap wrote
files the service could not read.

Both now walk the path the way the kernel resolves it, one name at a
time, following each link's target, and check every directory they look
a name up in. More than 40 links count as a loop, as ELOOP does.
2026-10-26 01:10:17 +08:00
qqqys b3ab3ec64c feat(mods): observe turn start and completion (#15443)
* feat(mods): observe turn start and completion

Mods written for Claude Code follow the session's turns. Token Weather
reads $.session.usage() in a turn.complete hook, summary Mods return a
line from turn.complete, and guards end a turn with $.turn.abort. Qwen
Code skipped those hooks at load, and $.turn.abort threw.

turn.start now fires once UserPromptSubmit admits a prompt, before its
first model request, with the prompt and a new turn id. The request does
not wait for the hooks; the turn's tool middleware and its end queue
behind them. turn.complete fires once when the turn ends, with the last
response's visible text, the duration, whether it answered, was aborted
or failed, and the token counts of the turn's own model responses. A
different text a turn.complete hook returns is shown beneath the answer,
as a Mod transcript line, and the record keeps the answer.

$.turn.abort ends the running turn and its tools as a cancel does,
without the cancel notice or restoring the prompt. The host, which owns
the turn, judges the id. In a headless run the turn ends with the error
result "A Mod ended the turn." and exit code 130.

Turn events cover the prompts a person submits, in headless runs and in
the Ink and OpenTUI terminals. Core reports admission through a new
onPromptAdmitted option of sendMessageStream, so a blocked prompt starts
no turn and the events do not depend on classic hooks.

* fix(mods): address the turn events review

- The Responses API counts reasoning inside output_tokens, so turn.complete
  usage counted it twice for openai-responses. The session and the
  uiTelemetry token fallback now share one rule, outputIncludesThoughts.
- In OpenTUI, an Esc and a $.turn.abort on the same turn, in either order,
  lost the Esc's settle: the queued prompt did not go back to the composer,
  and the composer's cancel stayed pending, so later Esc presses did
  nothing. An interrupt now settles as a cancel even when a Mod ended the
  turn too.
- A prompt or an answer past the worker's payload limit made the request
  fail, so every Mod missed that event with only a debug line. The session
  now keeps the head and tail of such a text within 128 KiB as JSON, as tool
  results are cut for the headless and ACP transports, and the event
  carries isTextTruncated or isAnswerTruncated.
- turn.start hooks ran under the session's signal, so Esc did not end a
  slow one and the turn's tool calls waited behind it. They now also end
  with the turn, as in Claude Code; turn.complete keeps the session's
  signal.
- The 4,096-character note and the terminal's 2,000-character log line are
  cut by code point, so a surrogate pair is never split.
- The bundled-worker script test expects the turn fields of the load reply.

* fix(cli): end an interrupted shared-loop tool batch with its result

On the shared agent loop, an interrupt that landed while a tool ran made
runNonInteractive reject with the TurnInterruptedError instead of ending
with the interrupted result and exit code 130. The 'cancelled' case
returned routeAbort()'s promise without awaiting it, so its throw skipped
the run's catch. A stream-json session then got no result for that turn,
and in a -p run a Mod's $.turn.abort from a tool.call hook ended the
process with "An unexpected critical error occurred".

The case awaits routeAbort() now, as every other call site does. A test
runs the interrupt on both loops.

* fix(mods): follow turns on the shared agent loop and OpenTUI retries

main now runs headless rounds on the shared agent loop behind
experimental.unifiedAgentLoop (#15369), and OpenTUI retries a failed
request (#15283). The merge left the turn events wired to the current
loop only.

- On the shared loop, the main loop's model events reach the turn
  through a new onEvent scope hook, as they do on the current loop.
  Drain items stay out of it.
- A tool that ends the person's Goal turn ends the Mod turn before a
  queued Goal continuation runs, as on the current loop. That path had
  no test on either loop.
- An OpenTUI retry starts a turn with no typed prompt, like Ink's,
  though it reuses the failed turn's submit options.

The headless Mod turn tests now run on both loops, with new cases for a
Mod ending its turn from a tool.call hook and for a Goal continuation.

* fix(mods): report run-budget stops as errors and reject no-op turn aborts

- A headless run budget (--max-wall-time / --max-tool-calls) aborts the
  run's own controller, so turn.complete reported reason 'aborted' for
  it. It now reports 'error', as routeAbort already treats it.
- $.turn.abort marked the turn as ending before the host cancelled
  anything. Ink's quiet cancel returns early once the stream is idle,
  so a call landing between the idle commit and the turn's completion
  resolved, and the turn that answered reported 'aborted'. The host's
  abort callback now returns whether it ended the turn; when it did not,
  the call rejects as "already ending" and the turn keeps its reason.
- The Mods guide says a turn.start hook that aborts its own turn is
  cancelled with it.
- Drop a duplicate `failed = true` in the OpenTUI turn's catch.

* fix(mods): classify every Ink turn ending the Mods hear

- A turn cancelled without an Esc, such as a Goal preemption, now ends
  as aborted. Its stream returned UserCancelled with no ending recorded,
  so turn.complete heard an answer.
- A tool continuation whose delivery fails no longer records an error.
  That callback also reads a flag a concurrent /btw failure can set; the
  continuation's own failure already marks the turn through its events.
- A failed request records its ending before the delivery callback runs.
- A slash command whose prompt reaches the model waits for the Mods to
  start, as a typed prompt does, and is refused the same way when they
  failed to start. It used to run with no turn events.

New Ink tests cover a cancel without an Esc, cancelled tools, tools that
cannot go on, a failed continuation, the slash command wait, and that a
Mod's end leaves the self-paced loop's wakeups alone.

* fix(mods): end the running turn when the session closes

- Closing the session, as /quit does, ends a turn still running as
  aborted before session.end, within the same budget. A turn whose end
  was queued when the close began still runs it. Both used to be
  dropped once the close shut the queue.
- In headless text mode a turn.complete note waits for stdout to drain,
  so it lands beneath the answer.
- In a terminal a turn.complete text keeps its own 4,096 characters
  instead of the 2,000 a $.ui.log line gets.

Session tests now pin the turn.start guard after the queue runs, the
session token limit as an error, the last model of a fallback, and no
longer wait out the stderr flush timeout.

* fix(cli): let a structured-output run end its Mod turn

The three `return emitStructuredSuccess()` paths returned the promise
unawaited from inside the run's try/finally, so the finally closed the
Mod session before the result's completion ran and turn.complete was
dropped. They are awaited now, as routeAbort is.

New headless tests, on both loops, cover a structured-output answer and
the continuation of an interrupted turn, and check the second
$.turn.abort's return outside the stream, where the run's catch cannot
swallow it.

* test(core): cover a continuation send and an unanswered turn abort

- A send that continues itself reports its prompt once.
- A $.turn.abort the host never answers is settled when its dispatch
  ends, and the next dispatch still runs.

* docs(mods): correct which turns $.session.turns() counts

A retry and a headless continuation fire turn events but add no user
turn to the transcript, so they are not the turns $.session.turns()
counts. The guide and the design also say a turn still running when the
session closes ends as aborted, and the validation lists name the new
tests.

* refactor(telemetry): share the reasoning convention with cost estimates

The billable output estimate main added in #15464 repeats the list of
auth types whose output count already includes reasoning. It now uses
outputIncludesThoughts, the helper turn usage and the token total use,
so the three read one rule.

* fix(mods): finish turn ends on close and count turn usage as the session does

Closing the session:

- A close no longer cancels a turn.complete that is already running.
  Turn ends run under a signal of their own, which the close aborts only
  once their budget has passed, so the hook finishes and its note still
  shows. A hook that ran past the cancel's 250 ms grace used to have its
  worker terminated, and session.end failed with it.
- Every end the close finds queued runs, in order, before the end of
  the turn the close cuts short. Only the last ended turn was kept, so
  in a terminal an earlier turn's end could be lost once the next turn
  had started.
- A turn ends once, even when the host reports its end during the close.

Turn usage:

- `usage.model` names the model of the turn's own prompt. Each host
  passes the prompt id its requests carry, so a concurrent /btw that
  answers last on another model no longer names the turn's model.
- Output tokens follow the rule #15464 gave the session's billable
  output: a total in the band both conventions allow decides whether
  reasoning is already in the output, the provider's convention
  otherwise. `outputTokenCount` now holds it for telemetry and the Mod
  session alike.

`see()` also stops copying the stream once the turn has ended, or when
no turn.complete hook will read it.

* fix(mods): end Ink's turn.start hooks for a cancel in any round

Ink gives each tool round its own controller, and the turn.start
dispatch ran under the first round's. A cancel in a later round, by Esc
or by the Mod, left a slow turn.start running for up to its budget,
with turn.complete queued behind it, and a hook that called
`$.turn.abort` went on after the call. The Mod turn now holds a
controller of its own that every aborted ending cancels, as OpenTUI and
headless runs already do with the turn's single signal. Ink also passes
the turn's prompt id.

The repeated "Mods failed to initialize" handling becomes one helper.

* docs(mods): describe turn ends at close and the new usage rules

The guide and the design say that a close lets a running or queued
turn.complete finish within the end budget, and the validation lists
name the new tests.

* fix(mods): align turn endings across hosts and sanitize turn usage

- OpenTUI ends a turn whose tools were all declined as `aborted`, as
  Ink does, through a new `onToolsCancelled` live prompt option.
- Ink no longer lets a `$.turn.abort` on a turn the person already
  cancelled act as a second Esc, which aborted the detached work
  (such as a running side question) the first cancel spared.
- Turn usage takes each provider count as a whole, non-negative number,
  so a hostile count no longer fails the `turn.complete` result check
  and drops the hook's note.

* fix(mods): end loop-detected turns before close and keep Ink prompt text

Headless returned emitLoopDetectedResult() without awaiting it inside the
run's try/finally, so the finally closed the Mods first and the turn ended
as aborted instead of error. All five sites now await it.

Ink built turn.start text only from humanInput or submittedPrompt, so a
prompt whose composer provenance was lost (a restored prompt stash, an
edited restored submission) reached the Mods as "". It now falls back to
the query it sends; a retry still carries "".
2026-10-25 16:13:24 +00:00
易良 8d213ba641 fix(lint): group incremental Prettier by the nearest config directory (#15531)
* fix(lint): group incremental Prettier by the nearest config directory

Prettier's experimental CLI only loads a config or ignore file in the
directory it starts from, so the lane spawned one process per touched
directory. Each spawn pays a cold start (0.24 s measured) against a 37 s
whole-repo budget, which the finding showed inverts around 154
directories; this repository holds 655 directories with
prettier-eligible files, so a wide diff could cost more than the scan
this feature removes.

Group by the nearest ancestor that actually carries a Prettier config or
ignore file instead, falling back to the repository root: a diff with no
nested configuration collapses to one spawn, while a nested config keeps
its own group (pinned by the real-Prettier nested-config case).

Also documents the 50-target spawn batch, which the same finding flagged
as unexplained.

* fix(lint): resolve Prettier targets relative to the group directory

Grouping by the nearest config-bearing ancestor moved the spawn cwd away
from dirname(file), but the targets stayed bare basenames — a pair that
only holds while the file sits directly inside the cwd. Any changed file
below its group directory was therefore handed to Prettier as a path
that does not exist, and the lane checked nothing. In this repository
that is the normal case: the root carries .prettierrc.json,
.prettierignore and .gitignore while scripts/ and docs/ carry none.

Name each target relative to the group directory instead, using the
POSIX form so the same string is produced on every platform, and pin it
with a real-Prettier case whose changed file sits below its group. The
existing argv assertions could not catch this — they run against mock
binaries that never stat a target — and the matrix case that asserted
"./below-file.ts" recorded the bug as expected behaviour; both are
corrected.

Also restate the motivation in the measured numbers: break-even is ~154
touched directories and recent diffs top out at 39, so the honest saving
is about half a second at the median, not a 157 s inversion.
2026-10-25 23:41:48 +08:00
易良 22c89204f0 fix(ci): deepen the incremental lint history until the merge base is provable (#15525)
A fixed 100-commit window expires in about a day at this repository's
merge rate, after which merge-base finds no common commit and every
longer-lived PR silently ran the full scan (measured when the finding
was filed: 68.7% of recently-updated branches were more than 100
commits from their base, median 196).

Keep the fetch bounded: after the depth-100 blobless fetch, deepen in
capped steps until Git can prove the merge base. Each deepen transfers
trees only, and the existing history-fallback warning still fires if the
cap is reached.

Verified with the literal step body under bash -e -o pipefail against a
local filter-capable server whose fork sits 300 commits behind the base
tip: the current single fetch leaves merge-base unprovable, the new loop
proves it after one deepen round and yields the expected changed-file
list.
2026-10-25 21:57:28 +08:00
易良 f885f39ee9 fix(ci): price ARM64 fleet lanes from the self-hosted label (#15529)
The two Linux lanes that raise their timeout on the shared self-hosted
pool -- the merge-gating lint lane and the no-AK integration gate --
decided "reserved pool" by looking for the x64 fleet's name inside the
classifier's runner label set. The ARM64 fleet renders
["self-hosted", "linux", "arm64"], which carries no fleet name, so the
same contended machines were priced as hosted: lint at 45 minutes and
no_ak at 30 instead of 90/60. An ARM run killed by the shorter ceiling
reads as an ARM compatibility failure, which is the signal the fleet
pilot exists to collect.

Scope on the routing property that matters -- self-hosted versus
GitHub-hosted -- and model the new key in the routing meta-test with an
explicit ARM64 case.
2026-10-25 21:41:55 +08:00
易良 bdd4a8fa8a test(lint): observe the --setup installer guard (#15533)
* test(lint): observe the --setup installer guard

setupLinters skips a lane with nothing to lint before it checks for that
lane's binary, and no test could see it: the check is `command -v <bin>`,
a shell builtin that never executes the mock binary, so every assertion
saw an empty call log regardless. A regression there restores the
actionlint and shellcheck archive downloads and the pip install of
yamllint on every PR — the install cost this lane exists to remove — with
nothing turning red.

Give the fixture an 'omit this mock' mode plus a constrained PATH (the
mock bin, Node's own directory, git's, and the base system dirs, with no
user bin), add a pip3 mock, and assert both directions: one changed .yml
installs exactly yamllint, and a diff that selects no lane installs
nothing. The constrained PATH is self-checked so an ambient yamllint
fails the test loudly instead of making it vacuous.

* test(lint): make the installer PATH hermetic and check the real one

The constrained PATH kept dirname(process.execPath) and /usr/bin, and the
ECS images carry a real yamllint in both, so on the pool this case could
never reach its own assertions: `command -v yamllint` succeeded before
the child ever ran. Mirror the system executables into a fixture bin with
every yamllint entry filtered out instead — the installers still get the
real coreutils and tar, and the ambient binary is provably unreachable.

The self-check had the same shape of flaw the PR exists to fix: it
validated the constrained PATH on its own, while the child's effective
PATH is getLinterPath() over it, which appends $HOME/.local/bin on Linux
— exactly where `pip3 install --user yamllint` lands. Pin HOME to a
fixture with no .local/bin and assert that, so the net and the tested
PATH agree instead of the net vouching for a stand-in.
2026-10-25 20:53:03 +08:00
jinyeandQwen-Coder 1876eb4997 test(managed-agent): Add shared Harness Kubernetes qualification tools (#15519)
* test(managed-agent): Add Kubernetes pool qualification tools

Build from immutable source, constrain deployment to explicit business workers,
and verify deployment identity plus signed multi-tenant public flows.
Include the cross-Pod foreground publication configuration exercised on ACK.

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

* style(managed-agent): Match Kubernetes qualification YAML lint rules

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-25 20:48:38 +08:00
Shaojin Wenand易良 e7a67ee4e3 feat(managed-agent): give the Hosted Harness its Broker through the environment (#15244)
* feat(managed-agent): give the Hosted Harness its Broker through the environment

The Hosted Harness reached the Runtime Broker only through command-line
options, so the shipped unit expanded the Broker token into argv, where
any local user can read it, and the token was stored again in
server.env for the control plane. A busy Harness port also moved the
Harness to another port that the control plane could not reach.

harness.env now carries QWEN_MANAGED_RUNTIME_BROKER_URL and
QWEN_MANAGED_RUNTIME_BROKER_TOKEN, which qwen serve already reads and
deletes from its environment, and the unit no longer passes Broker
arguments. Every secret gate lists the token too: core's child and
${VAR} denylist, the ACP scrub list, the channel worker environment and
the control plane's Runtime worker filter. The Hosted Harness profile
fails on a busy port instead of moving.

The Harness credentials file may carry the Broker token for the control
plane, refusing a second direct copy and binding nothing on a conflict;
both Broker lines follow the strict credential rules. Preflight adds a
broker-url row that compares the file's URL with the Broker's address
and warns when the file has none, and the broker row's fix names the
file only when startup read it. The bootstrap, templates, systemd unit
and guides keep the token in harness.env only and tell upgraded hosts
to replace QWEN_HOSTED_HARNESS_BROKER_ARGS.

* fix(managed-agent): check the Harness Broker URL against the actual listener

The broker-url preflight row matched loopback addresses by class, so a
Broker bound to 127.0.0.1 passed a file URL on 127.0.0.2. It failed any
URL that differed from the bind address, although a proxy or TLS front
end may forward it. And it ignored the scheme once a port was given, so
https to the plain-HTTP Broker passed.

The row now passes only a direct http URL: the Broker's port, and a host
that resolves to the bound address itself or, for a wildcard bind, to an
address of this machine. https to that same listener fails, since the
embedded Broker serves plain HTTP. Any other URL warns that a proxy may
forward it, which preflight cannot verify, so it no longer fails --check.

* fix(managed-agent): hold the Harness Broker URL to the Harness's own rules

- broker-url first applies the rules managed-runtime-broker-url.ts applies
  at Harness startup (an http or https origin, plain http only on loopback),
  reads the host of a registry authority such as an underscore name, and
  names the direct loopback URL, or the TLS-terminating proxy for a
  non-loopback bind, in its fix.
- A broker check fails a non-loopback bind without
  runtime-broker.allow-non-loopback, which the Broker refuses at startup.
- The Harness credentials file requires the Broker URL and token together,
  refuses a key containing whitespace such as `export KEY=value` (systemd
  ignores it, a shell assigns it), and lets the URL bracket an IPv6 literal.
- qwen serve warns when --managed-runtime-broker-token is on the command
  line; serve tests cover the environment fallback, its removal and flag
  precedence.
- The upgrade runbook names the server.env token to delete, the server
  README lists the Broker prefix among the stripped ones, and the bootstrap
  test checks stdout for every generated secret.

* fix(managed-agent): read the Harness Broker URL the way the Harness parses it

The broker-url preflight row predicted the Harness with java.net.URI and a
hand-written loopback check, which disagree with Node's WHATWG URL parser in
both directions. It failed http://127.1:4182, http:/127.0.0.1:4182 and
http://127.0.0.1:4182/./, which the Harness takes as its loopback origin, and
passed http://::1:4182, http://127.0.0.08:4182 and port 99999, which the
Harness refuses before it starts.

- HarnessBrokerUrl follows the URL Standard for the part of an http(s) URL a
  credentials-file value can reach: the scheme, the slashes after it, the
  IPv4, IPv6 and ASCII-domain host parsers, the port and the path's dot
  segments. An xn-- label needs IDNA, so the row warns that it cannot read
  that URL instead of failing.
- managed-runtime-broker-url.fixtures.json records the Harness's verdict for
  each spelling; the CLI test holds the Harness to it and HarnessBrokerUrlTest
  holds preflight to it.
- The fix's direct URL is built from the address the bind resolves to, so a
  ::ffff:127.0.0.1 bind yields http://127.0.0.1:4182.
- Docs: the upgrade backs up server.env and harness.env and a rollback
  restores them; the guide names the row's no-URL warning; the operator pages
  allow [ and ] in the URL key; the server README states the URL/token pairing
  rule.

* fix(managed-agent): keep the Broker pair across a daemon update restart

A daemon self-update replaces `qwen serve` through execve, which keeps
the PID, so systemd never hands the replacement the Broker pair from
harness.env again, and serve has already removed the pair from its own
environment. Hand the update restarter the resolved pair and add it to
the replacement's environment only, beside the server token.

Also from the same review round:
- preflight decides the plain-http, path and ends-in-a-number refusals
  for a host with an xn-- label without IDNA, and leaves only an https
  URL to such a host unclassified;
- the Java fixture loop allows only the two documented approximations, a
  scheme or path read before the host and an xn-- host read without
  IDNA; the shared fixture gains cases for both;
- the CLI fixture loop fails on a verdict it does not know instead of
  passing on any throw;
- the broker-url row brackets an IPv6 Broker address;
- the credentials file refuses an empty Broker URL or token, which the
  Harness takes as set and then refuses to start with;
- the deployment guide no longer says a wildcard Broker bind is reached
  through a proxy.

* fix(managed-agent): warn on an IPv6 URL for an IPv4 wildcard Broker bind

The JDK binds 0.0.0.0 to IPv4 alone under -Djava.net.preferIPv4Stack=true,
so a Harness given http://[::1]:P cannot reach a Broker bound there, while
the broker-url row passed it as served. Preflight cannot see the server's
JVM options, which may come from an environment it does not share, so a
wildcard bind now serves only addresses of its own family, and an IPv6 URL
to the IPv4 wildcard warns and names the IPv4 loopback URL. An https URL
there still fails. The JDK binds :: only on a dual-stack socket, so the
IPv6 wildcard keeps serving IPv4 too.

* fix(managed-agent): warn on an https Broker URL across address families

When the Broker binds the IPv4 wildcard and the URL names an IPv6
address, the Broker's own socket takes it only from a dual-stack JVM.
Under -Djava.net.preferIPv4Stack=true another listener, such as a TLS
proxy, may hold that address and forward to the Broker, so preflight
cannot tell an https URL there apart from a working deployment. It now
warns for both schemes and fails https only for an address the Broker's
plain-HTTP socket takes in either mode.

---------

Co-authored-by: 易良 <[email protected]>
2026-10-25 20:29:23 +08:00
Shaojin Wen 1df5031e70 perf(ci): overlap web-shell and standalone tests with the CLI suite (#15438)
* perf(ci): overlap web-shell and standalone tests with the CLI suite

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

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

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

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

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

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

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

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