* feat(serve): support model and reasoning selection at session creation
* fix(serve): allow model-only session startup configuration
* test(serve): include startup capability in registry expectations
* fix(serve): roll back rejected standalone startup
* fix(serve): make definite startup rejections retryable and honest
- Map the ACP internalError wire shape ({code: -32603, data.details})
produced when switchModel refuses an unregistered model to
startup_config_rejected on the startup-config call path, so the
ordinary route answers 422 instead of 500 and the standalone path
takes its definite rollback branch.
- Roll back the persisted recording of a caller-supplied sessionId on
POST /session after a definite startup rejection, so a retry of the
same id is no longer answered with 409 session_id_conflict; uncertain
failures keep the recording.
- Publish auto in the OpenAPI approvalMode enum and pin it against
APPROVAL_MODES; narrow the 422/protocol/design cleanup wording to
match the implemented best-effort semantics; restore the sessionId in
the new rollback warning.
* fix(serve): keep uncertain startup faults uncertain and roll back ACP recordings (#12308)
- session-startup-config: drop the -32603 arm so child-side internal
errors (auth, credential, timeout, transport) stay uncertain instead
of a false definite startup_config_rejected; the ACP child now maps
only its two caller-caused switchModel refusals (unknown model for the
auth type, media-only primary) to invalid params, so definite
rejections ride -32602 alone.
- dispatch: roll back the persisted recording of a caller-supplied
sessionId on the ACP session/new transports after a definite startup
rejection, mirroring the REST route; uncertain outcomes keep it.
- routes/session: warn once when the recording rollback is refused or
fails instead of silently discarding the boolean outcome.
- standalone-session-service: extract the shared rollback sequence into
one helper used by both the startup-config and model_selection_failed
branches.
- openapi: qualify the 422 close/rollback as best-effort and replace the
unconfirmable status-view pointer with the self-certifying retry check.
* fix(serve): name the rejected session in startup rollback and align ACP error codes (#12308)
- SessionStartupConfigError carries an optional sessionId so both
rollback gates (REST route, ACP dispatcher) roll back the rejected
session's recording even when the caller never supplied an id or the
child did not honor it; a daemon-generated id no longer leaves a
listed, resumable phantom after a definite 422.
- A refused ACP recording rollback now logs the same inconclusive
warning the REST route emits instead of dropping the cleanup result.
- startup_config_rejected maps to JSON-RPC -32602 (caller input), with
data.httpStatus keeping the REST-equivalent 422; SDK transports key
on httpStatus, so client behavior is unchanged.
- SDK throws DaemonStartupConfigError (TypeError with code
'invalid_startup_config') for pre-transport startup rejections.
- Docs: modelApplied is documented as spawn-scoped again (an attach
omits it or reports the spawn owner's outcome).
- Tests: daemon-generated-id rollback pins on both transports, a
refused-rollback log pin, a service-boundary invalid-config guard
pin, full reasoning-selection acceptance coverage, whitespace trim
pins, and a real-ModelsConfig classifier coupling test.
* test(sdk): keep the message assertion beside the stable code on startup rejections (#12308)
* fix(serve): fail closed on control-character startup model ids (#12308)
A modelServiceId carrying a newline slipped past parseSessionStartupConfig
and produced a core refusal message the single-line classifier regexes
could not match, so a definite caller-caused rejection degraded to an
internal error and skipped the recording rollback. Reject control
characters at the parse boundary and make isCallerCausedModelRefusal
newline-tolerant; align the SDK and integration-guide modelApplied
presence rule with the spawn-scoped wording the bridge and OpenAPI
already publish.
* docs(sdk): name both transport error-code locations in DaemonStartupConfigError JSDoc (#12308)
Co-authored-by: Qwen-Coder <[email protected]>
---------
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: qwen-code-ci-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
* refactor(release): Use pnpm for workspace publishing and versioning
* fix(ci): revert desktop-release install to npm ci for Windows compatibility
The pnpm migration added a bash conditional to check for pnpm-lock.yaml,
but the step did not specify shell: 'bash'. Windows runners default to
PowerShell, which cannot parse bash syntax, causing the build to fail
with a ParserError.
Since desktop-release always checks out the same commit and the root
lockfile is now pnpm-lock.yaml, the conditional is unnecessary. Revert
to the simple npm ci command that works on all platforms.
Fixes the Windows build failure in Desktop release v0.24.2.
* refactor(build): Schedule workspace builds with pnpm
* fix(ci): Preserve Windows release installation and version filters
---------
Co-authored-by: Qwen-Coder <[email protected]>
* perf(dev): add pnpm worktree bootstrap foundation
Co-authored-by: Qwen-Coder <[email protected]>
* fix(dev): harden worktree bootstrap process handling
Co-authored-by: Qwen-Coder <[email protected]>
* perf(dev): prefer cache-only worktree installs
Co-authored-by: Qwen-Coder <[email protected]>
* fix(dev): preserve cached install cancellation
Co-authored-by: Qwen-Coder <[email protected]>
* fix(build): harden pnpm worktree bootstrap
Validate pnpm lock updates in releases and exercise real installs and builds across supported hosts. Preserve npm release compatibility and keep dependency-only setup from rewriting npm-layout notices.
Co-authored-by: Qwen-Coder <[email protected]>
* chore: record merge attribution
Record the required attribution for the Stage 1 merge without rewriting published history.
Co-authored-by: Qwen-Coder <[email protected]>
* fix(dev): keep worktree bootstrap clean
Use a bootstrap-scoped notice guard because nested npm lifecycle commands replace npm_lifecycle_event. This preserves explicit notice generation while preventing dependency setup from rewriting tracked output.
Co-authored-by: Qwen-Coder <[email protected]>
* ci: track pnpm smoke workflow size
Register the new workflow in the repository size ratchet as required by the main CI gate.
Co-authored-by: Qwen-Coder <[email protected]>
* fix(ci): drop stale importers from the new pnpm lockfile
The lockfile was generated before the WebShell cutover (#9811) removed
@qwen-code/webui from web-shell and the tailwind tooling plus
@qwen-code/webui from vscode-ide-companion, so frozen-lockfile
installs fail on all three smoke platforms with
ERR_PNPM_OUTDATED_LOCKFILE. Drop the five stale importer entries so
the lockfile matches the current package.json manifests; verified with
pnpm 11.24.0 install --frozen-lockfile --lockfile-only.
Co-authored-by: Qwen-Coder <[email protected]>
* fix(ci): refresh pnpm lockfile after merging main
The merge of origin/main added remend@^1.3.1 to packages/cli/package.json
without updating pnpm-lock.yaml, breaking the pnpm Worktree Smoke workflow
frozen-lockfile install. Regenerated with pnpm install.
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmtid3rse9h
* fix(scripts): resolve the path variable case-insensitively on Windows
A spread of process.env is an ordinary object, and native Windows shells
expose the path variable as `Path`, so `env.PATH` was undefined there and
findOnPath never located corepack — the offline-first Corepack bootstrap
silently degraded to npx on exactly the hosts it exists for.
Co-authored-by: Qwen-Coder <[email protected]>
* test(scripts): pin the worktree bootstrap guards against mutation
- Split the fallback log on /\r?\n/ like the sibling cmd.exe mocks, so the
assertion holds on the Windows lane's CRLF output.
- Add a win32 variant bootstrapping with the native `Path` casing to pin
the case-insensitive lookup.
- Assert the smoke workflow's fail-fast flag, the install-before-clean
step order, and the no-build guard via a substring on the raw job text.
- Extend the pnpmfile rewrite fixture to devDependencies and
optionalDependencies, which the committed lockfile already uses.
- Assert the notice-skip guard by effect (writeFile never called) and add
the flag-absent companion test with I/O stubbed.
Co-authored-by: Qwen-Coder <[email protected]>
* chore(deps): sync pnpm-lock.yaml with qwen-live ACP backend deps
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmtjyz3tdco
* fix(dev): stop patch-package from corrupting the pnpm store
The root postinstall runs patch-package, which rewrites files under
node_modules in place. With pnpm's default import method those files are
hard links into the content-addressable store, so the patch rewrites the
store entry too and its contents stop matching the sha512 it is filed
under. Every subsequent fresh worktree then fails its --offline stage
with ERR_PNPM_NO_OFFLINE_TARBALL, silently falls back to the registry
(exit 0), and the fallback's postinstall corrupts the store again, so the
offline path never hits. The advertised offline timing was only reachable
on a same-tree reinstall, which needs no relinking.
Set packageImportMethod to clone-or-copy: a copy-on-write clone where the
filesystem supports it, a plain copy elsewhere. Either way patch-package
edits only the worktree's own copy and the store entry stays intact, so
later worktrees resolve entirely from the store.
Verified on an isolated fixture store: under the default method the
node_modules file and its store entry share an inode, and an in-place
edit moves the store file's sha512 off its address, after which a fresh
offline install fails with ERR_PNPM_NO_OFFLINE_TARBALL; under
clone-or-copy the same edit leaves the store hash unchanged and the fresh
offline install reports reused 1, downloaded 0.
Also add pnpm-lock.yaml to .prettierignore. prettier reflows it into a
shape pnpm does not emit, so `npm run format` and `pnpm install` fight
over the file; .yamllint.yml already ignores it for the same reason.
Both facts are pinned by tests in scripts/tests/package-scripts.test.js.
Claude-Session: https://claude.ai/code/session_012797rgiteWJxLT9TLkKq8G
* fix(ci): refresh pnpm-lock.yaml for qwen-live prompts deps
Main added prompts/@types/prompts (and the ACP sdk) to
packages/qwen-live/package.json after this branch generated its
lockfile, so the pnpm Worktree Smoke frozen-lockfile install failed
with ERR_PNPM_OUTDATED_LOCKFILE. Regenerated with pnpm 11.24.0
(--lockfile-only); frozen-lockfile and supply-chain policy checks pass.
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmtlc9sozfg
* fix(pnpm): close review findings on worktree bootstrap
- Declare @qwen-code/qwen-code-core in vscode-ide-companion so the pnpm
linker materializes it; regenerate both lockfiles and assert the link in
the smoke workflow (R5-1)
- Pin @types/node 20.19.1 in packages/core so the pnpm layout compiles
against the same types npm hoists (D6-1)
- Accept and pin the corepack +sha512 integrity suffix on packageManager;
strip it for the npx fallback spec (D6-2)
- Cover every workspace member in the pnpmfile rewrite set, mirror the npm
channel list in pnpm-workspace.yaml, and cross-check both in tests
- Run setup-worktree.js installs with cwd pinned to the checkout, gate the
smoke clean-check on git status --porcelain, stop post-merge runs from
cancelling each other, and extend check-lockfile.js to pnpm-lock.yaml
* merge(main): realign pnpm workspace set and lockfiles
Main removed the webui and cua-driver packages; drop both from the pnpm
rewrite set and regenerate the pnpm lockfile against the merged manifests
(picks up playwright, react-markdown, and the other main-side dependency
changes) so the frozen bootstrap matches what the PR merge ref will run.
* style(ci): quote smoke workflow scalars per yamllint
* fix(dev): require Corepack for pnpm bootstrap
* fix(dev): close pnpm bootstrap review blockers
---------
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
* feat(serve): persist prompt terminal ledger for cold-load reconciliation
Turn terminal events (turn_complete / turn_error) were synthesized by the ACP bridge and published over SSE only, so a prompt that was in flight when the daemon died could never be resolved after a restart: the cold load replay emits transcript chunks and carries no terminal evidence, leaving promptId-keyed orchestrators stuck on "unknown".
Each session now owns an append-only sidecar ledger next to its transcript. The bridge appends one in_flight record at prompt admission and one terminal record at the single publishPromptTerminal exit (covering the close/kill/channel-crash/daemon-shutdown flushes) through an injected synchronous sink. Ledger writes are best-effort and never block prompt execution or teardown, and records carry only ids, states, and timestamps — no prompt text, user content, or paths.
On a cold session load the serve layer reconciles prompts left dangling by a dead daemon: it classifies the transcript tail with the existing turn-interruption detector and appends a completed (stop reason reconstructed_from_transcript) or interrupted (code daemon_lost) verdict, guarded by an attribution check so an unattributable tail stays unknown (fail-closed). The load response gains an optional promptTerminals field with the trailing 64 terminal records, omitted entirely when the ledger holds no terminal evidence, and archive/unarchive move the sidecar alongside the transcript so evidence survives storage lifecycle.
Design: docs/design/2026-08-19-prompt-terminal-ledger-design.md
* fix(serve): tighten ledger reconciliation fail-closed semantics and complete sidecar lifecycle
Address review findings on the prompt terminal ledger:
- reconcile: fail closed on multiple dangling prompts (no synthesized
terminal for the newest either); attribute the oldest dangling prompt
only when the attribution guard skips settled admissions (fixes the
[A if, B if, B cancelled] misattribution veto), the transcript's last
write postdates the admission (temporal evidence), and a clean
verdict is upgraded to interrupted when the model tail holds any
functionCall part, id or not (id-less tool-call guard covering the
detectTurnInterruption wire-pairing blind spot)
- lifecycle: removeSessionFiles deletes the ledger in both archive
states; archive/unarchive move it through a single
getPromptLedgerPathForState helper with merge semantics when the
destination already exists (append-and-unlink instead of a permanent
split); move warnings carry full source and destination paths in
both directions
- scans: DataProcessor.scanChatFiles and
usageHistoryService.rebuildFromSessionJsonl exclude .ledger.jsonl
sidecars (the ledger is not a transcript)
- writer: appendPromptLedgerRecord seals a torn tail before appending
so a torn fragment cannot fuse with (and destroy) the next record
- tests: pin the new behavior across multi-dangling fail-closed,
settled-then-queued attribution, valid interleave migration,
temporal veto, id-less tool-call guard, sidecar lifecycle
(move/merge/warn-only delete), torn-tail sealing, queued-admission
flush on shutdown, active-prompt and resume load contracts, and
ledger exclusion from insight scans
- docs: sync the design doc's reconciliation algorithm, lifecycle, and
fail-closed invariants
* perf(serve): read only the ledger tail for load-response promptTerminals
readRecentPromptTerminals ran on every POST /session/:id/load (including
attached hot loads) and synchronously read and JSON-parsed the entire
ledger — a multi-megabyte event-loop stall for long sessions on the
per-request hot path.
Add a tailBytes option to readPromptLedgerRecords that reads a trailing
byte window (the first window line is always dropped: the window start
can tear a line in half). The load path now reads a 256 KiB window,
which holds hundreds of ~150-byte records against the 64-terminal
response cap; sessions whose ledger outgrows the window return a
best-effort trailing subset, which the response contract already allows.
* fix(serve): close wrong-terminal attribution classes in cold-load reconciliation
Strengthen the reconcile attribution evidence per review round 2:
measure the temporal evidence on the same api-history projection the
verdict uses, fail closed on a compression checkpoint written after the
target's admission, and require the visible tail to postdate every other
prompt's settled terminal (FIFO evidence). Also fix a TS18048 narrowing
gap in the window test, make the seal test assert the raw file layout,
and restructure the window test so the call-site tailBytes wiring is
actually observable.
* fix(serve): keep ChatRecord import inline so lint-staged cannot merge it into a type-only import
* fix(serve): fail-closed reconciliation on millisecond clock equality and deadline-overlapped turns
* fix(serve): TOCTOU fence before ledger append and documented residual attribution risk
* test(serve): pin the ledger race fixture on the transcript timeline
* feat(serve): bind cold-load evidence to the admission via a dispatch marker
* test(core): pin the ledger sidecar exclusion in usage rebuild
* fix(serve): create ledger sidecar owner-only and fence marker-era compression by position
Round-7 review Criticals:
- appendPromptLedgerRecord created the sidecar with umask-default
permissions (0o644) while the adjacent transcript is owner-only; the
ledger now follows the 0o600 convention at creation time.
- Marker-bearing admissions fence post-admission compression by marker
position instead of wall clock, so a backward clock step cannot hide
a compression reset that voids the evidence chain.
- Design doc: the residual-risk claim is corrected — the dispatch
marker binds ordering, not ownership; the two ownership classes that
survive it (recordless predecessor with continued writes, ledger-less
cross-client writer) are documented, pending writer identity on
transcript records (#9483).
---------
Co-authored-by: 秦奇 <[email protected]>
* feat: support session media references end-to-end
* fix(cli): catalog session media routes and update mid-turn replay meta test
The three new media routes (POST/GET/DELETE /session/:id/media[/:mediaId])
were registered but missing from legacySessionTelemetryRoutes, tripping the
route drift guard; add them as handler_resolved like their sibling routes.
The mid-turn history-replay expectation now carries the replay meta this PR
adds (source: mid_turn_message_injected, qwenDiscreteMessage: true).
Co-authored-by: Qwen-Coder <[email protected]>
* fix(daemon): harden session media per review feedback
Addresses the Critical review findings on the session-media PR:
- Reject image/svg+xml uploads and serve stored media with
Content-Disposition: attachment and X-Content-Type-Options: nosniff
(same-origin XSS vector on the daemon/Web Shell origin).
- Keep the retained-media TTL sweep running when the session reaper is
disabled (sessionReapIntervalMs <= 0) on the default 60s cadence.
- Record only the inline bytes the media references actually cover:
the gate now counts image blocks only (references are image-only),
and the strip keeps unrelated inline parts (e.g. @-mentioned files).
- Show '[User message with attachments]' on TUI resume for image-only
mid-turn messages recorded with an empty displayText.
- Exempt mid-turn injected echoes from the Web Shell status-noise and
plan-JSON filters.
- Degrade refresh-rebuilt queue rows to summary-only when media
hydration failed, so editing cannot silently discard attachments.
- Retry cross-session media removal without the clientId when the
daemon rejects the stale persisted id (invalid_client_id).
- Register session_media in the integration capabilities baseline.
Each fix carries a regression test that fails on the pre-fix code.
Co-authored-by: Qwen-Coder <[email protected]>
* fix(daemon): bound media content and fix resume/echo edge cases
Addresses the remaining Critical review findings on the session-media PR:
- Cap media content blocks at 256 on the mid-turn and prompt routes and
resolve each distinct mediaId once per resolveContent call — an
unbounded array of duplicate references amplified one small request
into gigabytes of heap at dispatch.
- Record a '[User message with attachments]' placeholder for
inline-media-only mid-turn messages with no references, keeping ''
only for the reference shape that replay projects.
- Restore the same placeholder for image-only ordinary prompts on TUI
resume instead of dropping the message from the restored history.
- Treat a mid-turn injected echo as renderable when its items carry a
non-empty text block, so the degraded-media echo (messages: [''] plus
the placeholder text block) is not discarded as malformed.
- Release session media in killSession's force-kill and closing-session
fallback branches instead of degrading to the crash-path detach
retention.
- Remove the unreachable duplicate return in DaemonSessionClient.load().
Each fix carries a regression test that fails on the pre-fix code.
Co-authored-by: Qwen-Coder <[email protected]>
* fix(daemon): resolve round-4 session media findings
Addresses the round-4 Critical review findings on the session-media PR:
- Move the mid-turn media-reference validation below the idempotent
retry-ack rings so a same-id retry whose media was already removed
(delete racing an in-flight POST, or a refresh re-enqueueing from the
snapshot) settles idempotently instead of failing with
session_media_gone (410).
- Keep image/* (unknown mime type) prompt images inline instead of
uploading them: the media route matches concrete image types only, so
the upload POST 400s and the whole submission hard-failed, regressing
pre-upload behavior for untyped images.
- Project the degraded-media drain echo's placeholder text block when
the echo text is empty, so the Web Shell shows the unavailability
notice instead of rendering an empty bubble.
Each fix carries a regression test that fails on the pre-fix code.
Co-authored-by: Qwen-Coder <[email protected]>
* fix(daemon): resolve round-5 session-media critical findings (#9127)
- Stop deleting a session-media blob when one queued prompt / mid-turn
message referencing it is removed: the store has no reference counting,
so siblings, replay metadata, or other clients may still hold the same
mediaId. Blobs now live until session close / TTL sweep or an explicit
removeSessionMedia.
- Reject duplicate mediaId occurrences in one message at assertReferences
(covers the prompt and mid-turn admission paths); the serializer expands
every reference at dispatch, so repeats amplified one upload into an
unbounded payload even though only one read is needed.
- Align the mid-turn display-text and reference-persistence gates: compute
the same willPersistReferences condition before finalizing displayText, so
a partially-referenced message records the attachments placeholder instead
of an empty displayText with no references.
- Keep the webui mid_turn_message_injected sidechannel alive for degraded
image-only echoes whose items carry only the placeholder text block,
mirroring the SDK normalizer's hasRenderableItemContent.
* fix(daemon): resolve round-6 session-media critical findings (#9127)
* fix(daemon): resolve round-7 session-media critical findings (#9127)
* fix(daemon): resolve round-8 session-media critical findings (#9127)
* fix(daemon): correct session media recovery and queue isolation
* fix(daemon): remove media with deleted queue items
* fix(daemon): bound repeated media in queue drains
---------
Co-authored-by: 钉萁 <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
* fix(serve): make session restore timeouts safe
Co-authored-by: Qwen-Coder <[email protected]>
* fix(cli): restore missing core mock exports in the ACP worktree suite
The restore-tracing change added `extractDaemonTraceContext` and
`withDaemonSpan` to `acpAgent.ts`, but `acpAgent.worktree.test.ts`
replaces `@qwen-code/qwen-code-core` with a full mock factory that never
listed them. `loadSession` then failed on an undefined export, taking all
three cases down and producing teardown rejections from the half-built
agent. The sibling suite was updated; this one was missed.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(serve): bound and disambiguate the abandoned restore lifecycle
Four follow-ups from review of the restore timeout work.
A startup budget may now raise the restore budget but never lower it.
Taking an explicitly configured `initializeTimeoutMs` as the restore
fallback meant a deployment that tightened its child-initialize check
still inherited a sub-default restore deadline — exactly the failure this
change exists to remove. An explicit `sessionRestoreTimeoutMs` still wins
outright, including below the default, for deployments that want restore
to fail fast. Validation now names the field actually at fault.
A restore fenced behind a timed-out predecessor is no longer reported as
an ordinary in-flight restore. It carries `reason:
awaiting_abandoned_cleanup` and a retry hint of one restore budget
(capped at 120s) instead of the ordinary 5 seconds, because the fence
cannot clear until the non-cancellable ACP request settles and a 5-second
cadence just spins the caller against a 409 it cannot resolve.
Whether a channel is condemned is now derived rather than sticky. A
timeout recorded `emptyReapPending` permanently, so any channel that had
ever seen one was guaranteed to be reaped once its remaining work
drained, forcing a cold respawn even when the late restore had landed and
closed cleanly. The reap condition is now computed from an outstanding
`unsettledAbandonedRestores` set, quarantine, or an ordinary pending
empty reap; real settlement clears the entry and hands the channel back
to the configured idle policy.
Abandonment no longer retains ownership without bound. One further
restore budget after the deadline, a still-unsettled restore marks the
channel `restoreSettlementOverdue`: existing sessions and workspace
control keep working, but fresh session work is refused so the channel
can drain, since closing the transport is the only lever that releases a
permanently hung request. Releasing capacity while hidden work runs would
allow unbounded oversubscription, and force-killing a channel with live
siblings would reintroduce the failure this work removes, so neither is
done. Fresh-admission blocking is now scanned across alive channels
rather than tracked in a single reference, so a second condemned channel
cannot silently displace the first.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(serve): keep the abandoned restore lifecycle off ids it no longer owns
Two correctness gaps in the abandoned-restore machinery introduced by this
PR, both reported by automated review and both confirmed by mutation
testing (each new test fails when its fix is reverted).
A caller-supplied `sessionId` is used verbatim by the agent, but
`spawnOrAttach` never consulted `inFlightRestores`. A fresh spawn could
therefore take an id that a restore still owns, in either lifecycle phase.
The consequences were silent: `abandonedRestoreIds` suppresses session
updates, guardrail events, and child notifications, so the new session
would have registered successfully and then emitted nothing; and a late
`settleAbandonedRestore` would have closed and tombstoned it out from
under its owner. Such a spawn is now rejected with the same
`RestoreInProgressError` and reason the restore path uses, so the caller
gets the correct retry hint for whichever phase is holding the id.
The cleanup path is guarded independently, because the request-level check
only covers the id the caller asked for and a session registers under the
id the child returns. An abandoned restore never reaches
`createSessionEntry` — the deadline rejects before registration — so any
live entry under that id belongs to someone else. Cleanup now detects that
and returns without closing or tombstoning, releasing its own bookkeeping
instead.
The notification fence has no TTL and was only cleared by
`markRestoreInFlight`, which covers a subsequent restore and nothing else.
`createSessionEntry` now clears it for every registration route, so a
legitimate owner of the id is never handed a session that silently drops
everything the child sends it.
Also tightens two tests that could not observe the values they pin. The
SDK default restore timeout admitted any value in (30s, 70s]; it is now
split at the exact boundary, so collapsing the default onto the 60s server
budget — which would make the client abort race the daemon's own deadline
and cost the caller its structured 504 — fails. And the advertised-budget
propagation from capabilities through to the SDK call had no live-path
assertion; dropping the capabilities argument at the real call site left
every existing test green. The `as never` casts are replaced with typed
`DaemonCapabilities` values so a field rename fails typecheck.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(serve): let a condemned channel drain without its wedged child
Merging main's active-work close protocol (#8588) into this PR's abandoned
restore bound produced a deadlock that neither side has on its own, and the
conflict resolution was committed without running tests.
`maybeCloseIdleSession` now routes through `confirmChildUnheld`, which asks
the child whether it still holds work before closing a session nobody is
attached to. That is right in general and wrong for a channel this PR has
already condemned. `restoreSettlementOverdue` and quarantine exist precisely
because the child stopped being answerable, and their whole premise is that
visible work drains so the channel can be reaped — closing the transport is
the only thing that can release a restore we cannot cancel. Making that
drain depend on a round trip to the wedged child inverts it: a child stuck
in a non-cancellable restore is exactly the one that cannot reply inside
`ACTIVE_WORK_CLOSE_TIMEOUT_MS`, so the sessions never close, the channel
never drains, the reap never fires, and the bound never takes effect.
A channel condemned by the restore lifecycle now skips the round trip and
proceeds to local teardown. Nothing is attached to the session by then —
`maybeCloseIdleSession` gates on that — and the sibling-safety invariant is
untouched: this closes sessions whose clients have already left, it does not
force-kill a channel that still has live ones.
The regression test drives an overdue channel whose child never answers the
close-if-unheld probe and asserts the detach still reaps it. Reverting the
guard reproduces the deadlock as a test timeout.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(serve): pin the restore-timeout contract the review found unasserted
Automated review identified eleven places where the restore-timeout work's
behavior was correct but unpinned — each with a mutation that ships green.
Every fix below was verified the same way: apply the mutation, watch the new
assertion fail, revert, watch it pass.
The timeout path's telemetry had no coverage at all, which is the sharpest
gap given that observability is what this work exists to deliver. A shared
recorder now asserts the public timeout result and its kill_empty-vs-
fence_shared signal, the late arrival, and the cleanup outcome for both the
closed and quarantined cases.
The deadline timer's cancellation on a successful restore was likewise
unpinned: deleting both `clearTimeout` calls kept the whole suite green,
while in production the stale timer fires one budget after a successful
restore and abandons a live session — fencing its frames, closing its event
bus, and emitting a spurious timeout. A success-path test now advances past
the deadline and asserts no second public result.
Three more bridge assertions proved less than they claimed: the concurrent-
restore case never checked that the abandoned restore settles, the
workspace-control case never checked that the deferred reap eventually
fires, and the resolver never pinned the accepting side of the MAX boundary
(a `>` to `>=` mutation rejects the largest legal delay at boot). The
workspace-control case also needed a positive channel idle budget, since
with the default zero the idle-timer kill substitutes for the reap junction
under test; its assertions are rewritten around the derived reap semantics
rather than the sticky flag they predate.
Outside the bridge: the scheduled-task timeout wiring had no test, so
deleting the arguments silently fell back to the helpers' own defaults; the
cold restore path never asserted that `live_restore_ms` is absent; the SDK's
per-request validation and its over-ceiling clamp were untested; the WebUI
watchdog test jumped straight to its own value, staying green for any
watchdog at or below it, including the 30s attach value that would recreate
the original symptom in the browser; and the two new known error types were
unexercised, so dropping either would relabel every restore-timeout and
quarantine error as unknown.
Two review items are deliberately not taken here and are recorded in the
design doc's non-goals instead: transcript materialization is still not
separately attributable from `config_setup`, which needs instrumentation
inside the core session loader that P1/P2 restructures anyway, and sibling
event-loop latency during a large restore remains unmeasured.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(serve): bound the condemned-channel close and complete the fence contract
Second automated review round, on the code the first round produced. One
Critical and twelve suggestions; all verified by mutation before and after.
**The Critical is a regression I introduced.** Letting a condemned channel
skip the bounded hold probe routed it into `closeSessionImpl`, whose agent
close is unbounded when it throws on failure — so the fix traded a bounded
wait on a wedged child for an unbounded one. A settlement-overdue channel
with an unresponsive child would hang `detachClient` forever, strand the
session in `closing`, never drain, never reap, and 503 every new session
until restart: strictly worse than before. `CloseSessionOpts` now carries an
`agentCloseTimeoutMs` that the condemned path sets, so a hang lands in the
existing unknown-outcome recovery, which kills the channel — the teardown
the drain was waiting for. The earlier test missed this because its fake
child still answered the plain close; it now answers nothing at all, and
asserts the detach itself returns.
**The fence was invisible on the transports clients actually use.**
`toRpcError` had no `RestoreInProgressError` case, so over acp-http and
acp-ws — which SDK negotiation prefers over REST — the fence degraded to an
opaque internal 500 with no code, reason, or hint, and the backoff contract
this work documents was impossible to honor.
**Two retry hints still advertised five seconds for states that outlive a
budget.** The restore 504 creates the fence, and quarantine lasts until the
channel drains; a fresh-id caller never reaches the 409 that carries the
real hint, so its header was the only signal it got. Both now derive from
the budget through one shared clamp helper, which also replaces the formula
that was inlined in the bridge and gives the documented 5-120s bounds a
test.
**A spawn collision reported an operation the caller never issued**, naming
the restore owner's action as both the active and the requested one and
telling the caller to retry an endpoint it never called.
The rest: five places still described the initialize-timeout fallback as a
plain chain rather than raise-only, contradicting sibling docs shipped in
this same PR; the design doc omitted the retry-hint clamp; the protocol
reference omitted the new spawn emission site; the error taxonomy omitted
`restore_settlement_overdue`, which matters because its audience is
monitoring. Test-only gaps: the dynamic 409 had no HTTP-layer coverage, the
120-second cap was unpinned, and the SDK's precedence of an explicit global
timeout over the advertised budget was pinned only branch-by-branch.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(serve): preserve restore session ownership handoff
Co-authored-by: Qwen-Coder <[email protected]>
---------
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
* feat(serve): observe daemon memory pressure against a real denominator
The daemon samples its own RSS and heap but has nothing to divide them
by, so nothing in `/daemon/status` says whether a figure is fine or
nearly fatal. #8245 landed the denominator (`limits.memory`); this turns
it into a reading.
`runtime.memory.pressure` reports `level`, `ratio`, `source`, and the six
raw figures behind them. The level is the worse of two independent
ratios, because the two failure modes are independent: a container dies
by RSS against its cgroup limit, while a process on a large host can
exhaust V8's heap long before RSS is a meaningful fraction of the
machine. Reporting only one hides whichever failure the deployment is
actually heading for. `source` names which ratio produced the level, and
`unknown` says the daemon could not measure itself — which a consumer
must not read as healthy.
The denominator is `availableMemoryMb`, not `effectiveBudgetMb`: pressure
asks how close this process is to being killed, and what kills it is the
cgroup limit or host memory. An operator's budget is a policy number, so
classifying against it would report `critical` for a daemon in no danger.
`--memory-pressure-mode` is `off | observe`, default `observe`. Both
modes report every figure; only `observe` also raises the
`daemon_memory_pressure` warning, so `off` leaves the top-level `status`
rollup untouched — the thresholds are inherited from an interactive-CLI
monitor and are not yet calibrated for a long-running daemon, and a
deployment that alerts on `status` needs the reading without the verdict.
There is deliberately no `enforce`: nothing here remediates, and a value
a caller can pass but never use is a dead switch. It arrives with the
enforcement.
Scope is the daemon root process only. `childRssCoverage` still reads
`primary_only` and says so on the wire; aggregate child RSS and channel
workers are separate measurements and land separately.
Severity is `warning` at every level including `critical`, because
`error` would make `rollupStatus` return `error` for the whole daemon —
too strong a claim to stake on uncalibrated thresholds.
Refs #8051.
Co-Authored-By: Claude Opus 5 <[email protected]>
* feat(serve): report aggregate ACP child RSS, not just the primary's (#8462)
* test(serve): close the under-determined assertions review probed
The automated review mutation-probed this diff and found several
assertions that were live but under-determined — each mutant it names
kept the whole suite green. All confirmed locally, and all now fail:
- Deleting `level !== 'normal'` from the issue gate raised
daemon_memory_pressure on a healthy daemon and flipped top-level
status to warning on every response — the exact false positive
`--memory-pressure-mode off` exists to opt out of. Now covered on both
sides: nothing raised at a realistic denominator, exactly one warning
at a denominator sized to land this process in `soft`.
- Summing children over `list()` instead of `listManaged()` dropped a
draining-but-process-holding workspace while `activeAcpChildren` still
counted it. The draining bridge now reports RSS, so the byte count can
only come from that child.
- The message's denominator ternary had no coverage; inverting it sent
an operator hunting RSS growth during a heap-driven incident.
- A truthiness guard on `ageMs` turned a measured-fresh reading (age
exactly 0, when a status read lands in the sampler's millisecond) into
`null`, which the field's own docs say never means fresh.
- The multi-contributor age test listed ages ascending, so a
plain-overwrite accumulator produced the same answer as Math.max.
Reordered descending, which kills last-wins and first-wins both.
Two declaration-only hunks — the issue-code union member and the
`pressure` field — were guarded by tsc alone, which vitest does not run.
Both are now pinned at runtime by asserting the code string and the full
key set.
Also fixes a real display defect: `toFixed(0)` renders a ratio of 0.795
as "hard at 80%", and 80% is critical's documented threshold. One
decimal, so the number and the level cannot contradict each other.
And corrects a JSDoc claim of mine that was simply wrong: `pressure` is
absent not only for direct-embed but on the bootstrap /daemon/status
route, which omits runtime.memory wholesale even though the budget is
resolved — and that window is not just startup, since a daemon whose
runtime fails to start serves the bootstrap app for its lifetime.
Co-Authored-By: Claude Opus 5 <[email protected]>
* refactor(serve): model a per-child heap partition of the daemon budget (#8508)
* feat(serve): add the child-heap admission primitives, unwired
Groundwork for #8182 step 2. Nothing calls any of this yet, so no child
is sized differently and no spawn is refused.
`ProcessRegistry.committedProcessCount` counts attached children plus
reservations that have not attached. That is the figure admission has to
key on: `reserve()` inserts its token synchronously before `spawn()`, so
two racing spawns each see the other, while neither appears in
`activeProcessCount` until its child attaches. A child leaves the count
on exit rather than when `terminate()` starts, so a channel swap counts
twice while the old process winds down — deliberate, since its memory is
still resident.
`getAcpMemoryArgs(explicitMb?)` takes an optional share that bypasses
both the module cache and the raise-only guard. Both bypasses are
load-bearing. The cache, because the share depends on how many children
are live now rather than on the host. The guard, because a
budget-derived share is normally *below* the daemon's own heap limit, so
routing it through `targetMB > currentLimitMB` would drop the flag,
silently restore the overcommit, and leave every test green — the trap
against a multi-GB runner, and mutation-checking it by reinstating the
guard fails two tests.
`createChildHeapPolicy` holds the mode, the budget, and the would-be
refusal counter, and answers `decide(concurrentChildren)`. The refusal
is derived from the unclamped quotient, not from
`recommendedChildShareMb`, because that function clamps *up* to the
512 MB floor: past the point where the pool stops covering the count its
answer saturates and can no longer distinguish "barely does not fit"
from "wildly does not fit".
`ChildHeapPoolExhaustedError` with both transport mappings — REST 503
with Retry-After, ACP `child_heap_pool_exhausted` — added together,
since the two mappings are hand-written and drift silently otherwise.
Refusing at spawn rather than at registration is the correction #8182
demands: registration allocates nothing, so this surfaces as "no new
session in this workspace right now", which is true and retryable.
Refs #8182.
Co-Authored-By: Claude Opus 5 <[email protected]>
* feat(serve): size each ACP child by concurrently live children
Wires the primitives from the previous commit into the spawn path, behind
`--child-heap-mode off | observe | enforce`, default `observe`.
Under `enforce` a child's `--max-old-space-size` is a share of the child
pool divided by the children concurrently committed at the moment it
spawns — read from the shared ProcessRegistry after `reserve()`, so two
racing spawns each see the other. When the pool cannot cover another
child at the 512 MB floor the spawn is refused with
ChildHeapPoolExhaustedError, which is what turns a per-child ceiling into
an aggregate bound: concurrent children can never exceed pool/512.
Keyed on concurrency, never on registrations. A dormant workspace has no
child, so it costs nothing — the specific correction #8182 records
against the withdrawn proposal, which would have shrunk a lone live child
to 614 MB because of 24 idle registrations.
Default `observe` computes the share and the admission decision and
applies neither, counting the refusals that would have happened. The
divisor has never been checked against a real multi-workspace deployment,
and a non-zero count is how an operator learns enforcement would have
broken them without being broken. It also catches the case worth
worrying about: a channel swap counts the dying child alongside its
replacement, so on a saturated pool enforcement could refuse a restart
and leave that workspace with no child at all. Excluding terminating
children would authorise real overcommit to dodge a hypothetical
refusal, so the count reports it instead.
Ceilings already granted are not revisited — V8 cannot lower them — so
granted ceilings transiently exceed the pool. Acceptable: the flag is a
ceiling, not a reservation, and a workspace with no live sessions has no
child and picks up the current share on its next spawn.
`limits.memory.enforced` stops being a required literal `false`. #8245
made it one so a client could never mistake that namespace for
enforcement that had not shipped; it has now, so the field is a boolean
derived from the mode — and stays `false` under `observe`, which applies
nothing.
Refs #8182.
Co-Authored-By: Claude Opus 5 <[email protected]>
* docs(serve): correct the claims child-heap enforcement makes false
Two sentences in the protocol doc described the memory section as
unconditionally observational: "a required `enforced: false`", and "no
child spawn argument derives from these values, and no request is
refused on their basis". Both are false under `--child-heap-mode
enforce`, so both are rewritten rather than left to rot — `enforced` is
now documented as the boolean that answers exactly this, and the refusal
is documented with its wire shape on both transports.
Also documents `childHeap.refusals` as the calibration signal, since a
would-be-refusal count is useless if operators do not know to read it
before switching to `enforce`; the flag row in the three operator docs;
and the design doc's Part 1, which listed applying a share as a
compatibility risk without recording how that was resolved.
The end-to-end test asserts the policy reaches a real booted daemon's
status with `enforced: false` under the default mode — the wire type in
that test is a hand-written mirror, so its `enforced: false` literal had
to widen too, which is the check that caught the type not being widened
everywhere.
Refs #8182.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(serve): cover both branches of the enforced tripwire
`enforced` was only ever asserted false — the unit tests build no policy
and the end-to-end daemon runs the default `observe` mode, so the branch
that makes the field worth having was untested. Hardcoding it back to
`false` passed everything.
Also pins `childHeap: null` as distinct from a policy in `off` mode: the
first says no policy exists (direct-embed, or the bootstrap window before
the runtime is built), the second says one exists and computes nothing.
Refs #8182.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(serve): partition the child pool so granted ceilings stay inside it
Review was right that the previous design did not deliver the aggregate
bound it claimed. Sizing each child by the count live at *its* spawn
bounds the child count but not the memory: V8 cannot lower a running
child's ceiling, so grants accumulate as P + P/2 + P/3 + ... = P x H(n).
Reproduced exactly — 9557 MB authorised against a 3687 MB pool at seven
children on an 8 GB host, and 61355 MB against 15360 MB at the limit on
32 GB. That is 2.6x and 4x the pool, which is what the policy exists to
prevent.
Grant accounting alone does not fix it: the first child would take the
whole pool and the second would be refused immediately. Keeping the
invariant requires early children not to receive the whole pool, so the
ceiling is now a fixed partition — childPoolMb / maxConcurrentChildren,
constant for every child, with maxConcurrentChildren itself derived from
the pool and capped at MAX_DAEMON_WORKSPACES. The sum is then
n x ceiling <= pool by construction, with no ledger of outstanding
grants and no dependence on arrival order. Tested as an invariant across
four host sizes: fill the daemon to its admission limit and the
authorised total still fits.
The cost is deliberate and now documented rather than hidden: a lone
workspace on a 32 GB host gets 614 MB rather than the pool, because any
child may still be running when the house fills. An 8 GB host admits
seven concurrent children at 526 MB each.
Also from review:
- The policy is no longer built for an injected `deps.bridge`. That
bridge carries its own channel and never reaches the factory the
policy rides on, so status could report `enforced: true` while nothing
was being sized.
- Both transport mappings now have direct tests. They are hand-written
beside each other and drift silently; the spawn-policy tests cannot
catch a wire regression.
- Swept the "does not size any child" claim, which enforce makes false,
out of the CLI help text, ServeOptions docs, the two operator tables,
and the e2e header comment. The 17-configuration table realigns
wholesale because that cell was its widest — whitespace only.
Refs #8182.
Co-Authored-By: Claude Opus 5 <[email protected]>
* refactor(serve): model the child heap partition, defer applying it
Review established that the refusal counter cannot tell an operator
whether enforcement is safe, and that is the ground the enforcing mode
stood on. While observing, children run on the host-derived ceiling
(16384 MB on a 32 GB host), so a workload needing 2 GB of old space is
healthy with zero refusals and OOMs the moment a 614 MB partition is
applied. The counter measures admission pressure, not ceiling adequacy.
Rather than ship a switch with no safe way to decide when to turn it on,
`enforce` is removed. `--child-heap-mode` is `off | observe`, and the
mode that would apply the partition arrives with the measurement that
justifies it: peak old-space per child, compared against the modeled
ceiling. That is a real measurement chain — the child reports rss and
cpu today, and `--max-old-space-size` bounds old space specifically, so
neither rss nor heapUsed answers the question.
With nothing applying the partition, the machinery that existed only to
apply it goes too rather than shipping unreachable:
`getAcpMemoryArgs(explicitMb?)`, `ChildHeapPoolExhaustedError` and both
transport mappings, and `limits.memory.enforced` reverts to the required
literal `false` it was before. The spawn path is untouched again; the
factory asks the policy what it would decide purely so the count is
real.
Also fixes the zero-pool defect review found, which the removed clamp
caused: forcing at least one admissible child on a 512 MB host — where
the root reserve consumes the whole 256 MB budget — produced a ceiling
of 0, and `--max-old-space-size=0` is V8's *default* heap, not a zero
ceiling. A pool that cannot cover one child at the floor now reports
`maxConcurrentChildren: 0` and `perChildCeilingMb: null`, and the test
that enshrined the old behaviour is inverted.
Status now publishes `maxConcurrentChildren` and `perChildCeilingMb`, so
an operator can judge the partition against their own workload — the
substitute for a counter that cannot judge it for them. Every claim that
a zero refusal count means the partition is safe to apply is removed
from the flag help, the operator docs, the protocol doc, and the design
doc.
Refs #8182.
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* fix(serve): repair the child-heap assertion and the reservation leak
Three findings review raised against #8508 after the partition became
observation-only, all still live on this branch now that it has merged.
The status assertion in `run-qwen-serve.test.ts` failed on head: it used
`toEqual` against `{ mode, refusals }` while the wire also carries
`maxConcurrentChildren` and `perChildCeilingMb`, so the suite was red at
217 passed / 1 failed. The local type restating the wire shape was short
the same two fields. Both are filled in, and the assertion stays `toEqual`
so an unannounced field still fails it — the two derived figures get
matchers because this suite boots a real daemon and the pool follows the
machine. What they have to satisfy is now pinned separately: a fixed
ceiling times the number admitted must fit inside the pool it partitions,
which is the whole reason the partition bounds anything.
`decide()` and `getAcpMemoryArgs()` ran between `reserve()` and the `try`
that cancels the reservation. `childHeapPolicy` is a public
`createSpawnChannelFactory` option, so `decide()` is caller code and may
throw; the spawn then rejected with the token held for the process
lifetime, inflating `committedProcessCount` for every later spawn. Both
calls move inside the `try`. The regression test is mutation-verified —
reverting the move gives `expected 1 to be +0`.
`ServeOptions.memoryBudgetMb` still promised a `childHeapMode: 'enforce'`
that sizes children and refuses spawns. No such mode exists.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(serve): report no child-heap partition under `off`
`snapshot()` returned `maxConcurrentChildren` and `perChildCeilingMb`
unconditionally, so a daemon run with `--child-heap-mode off` still
published a partition — 7 children at 526 MB on an 8 GB host — under a
mode whose documentation says "do not model it". Review raised it, and it
mattered more than it looked: with `enforce` gone, `off` and `observe`
differed only in whether `refusals` incremented, so nothing on the wire
distinguished a model that was switched off from one in force.
Both figures are now `null` under `off`, which required widening
`maxConcurrentChildren` to `number | null` in the daemon type and the SDK
mirror. `null` rather than `0`: zero is already the computed answer for a
pool too small to host one child at the 512 MB floor, and collapsing the
two would tell an operator who disabled the model that their host cannot
run anything. That leaves three distinguishable states — no policy at all
(`childHeap: null`), a policy modeling nothing (`mode: 'off'` with null
figures), and a live model — and each now has a test.
The `off` unit test previously asserted only `refusals`, so its name
("models nothing at all when off") promised more than it checked. It now
covers the figures, with a sibling test pinning 7 / 526 under `observe` on
the same budget so nulling them unconditionally cannot satisfy both.
Mutation-verified in both directions.
Co-Authored-By: Claude Opus 5 <[email protected]>
* fix(serve): never model a child heap ceiling below the documented minimum
`perChildCeilingMb` is `min(floor(pool / maxConcurrentChildren),
legacyChildCeilingMb)`. The first term is at least `MIN_CHILD_HEAP_MB` by
construction; the second is `floor(available / 2)` and is not, so the
`Math.min` could publish a ceiling *below* the `minChildHeapMb` sitting beside
it in the same snapshot:
avail=768 --memory-budget-mb 1024 pool=512 legacyCeil=384 perChild=384
avail=1023 --memory-budget-mb 1024 pool=767 legacyCeil=511 perChild=511
Unreachable from a derived budget — the pool reaches 0 first — but an explicit
budget has a floor of 1024 while available memory does not, and
`docs/users/qwen-serve.md` tells operators on exactly these hosts to pass that
flag. The documented remedy is what reaches the band.
Refuse the model rather than shrink under the floor, with
`maxConcurrentChildren` zeroed in lockstep: a ceiling no child may run at is
not a partition, and "one child fits" beside a null ceiling is the same
contradiction from the other side. Nothing is applied today so the impact was a
wrong published figure, but this is the number the partition asks to be judged
by and the one an `enforce` mode would hand to `--max-old-space-size`.
The existing matrix resolves derived budgets only, which is why the mutation
sweep came back clean; add the `budgetMb` axis, asserting in each case the
shape that makes it reachable, and pin the inclusive boundary (1024/1024 ->
one child at 512) so nulling unconditionally cannot pass instead.
Also, in the same review pass:
- Split usable-gauge handling into numerator and denominator. Coercing an
unusable numerator to 0 published `rssBytes: 0, rssRatio: 0, level: 'normal',
source: 'rss'` — a daemon that measured nothing, indistinguishable from an
idle one, which is the confusion `source: 'unknown'` and `sampled: 0` exist
to prevent everywhere else here. An unusable numerator now retires its own
side. Zero stays a reading for a numerator and not for a denominator.
- Document that `rssRatio` divides by host total under
`availableMemorySource: 'host'`, so it is a lower bound on real pressure
there — a denominator problem no threshold calibration addresses.
- Document that `refusals` counts channel swaps at full occupancy (the
terminating child is counted until it exits) and equals the total spawn count
on a host too small to model a partition. Deliberately not fixed by giving
the comparison swap headroom, which would admit a 26th ceiling against a
25-child pool.
- Keep the sampler's rejection handler as a documented backstop — the shipped
`refreshChildResource` never rejects, but it is an optional `async` interface
member, so a foreign implementation throwing early would otherwise surface as
an unhandled rejection — and give it the workspace so it is attributable
across the fan-out.
- Test hygiene: drop a duplicated `enforced` assertion; replace a host-
dependent `expect.any(Number)` with a key-set pin plus a branch, since a
small host now legitimately reports no partition; use `vi.spyOn(Date, 'now')`
over direct assignment; reuse the exported `ChildHeapMode` on the child-heap
side, leaving the independent `memoryPressureMode` switch alone.
Reported by @wenshao.
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
* feat(serve): add required external tool guard
* fix(serve): keep guard constants off fast path closure
* test(serve): cover guard startup options
* refactor(acp-bridge): centralize external tool guard validation and ack value (#8125)
* fix(core): align MCP reconnect timeout test with safe replay policy (#8125)
The reconnect-on-timeout test still built its mock tools without server
trust or tool annotations, which the safe replay change now requires
before automatically replaying a connection-loss failure. Update the
fixtures the same way the surrounding reconnect tests were updated,
keeping the test's original assertion that a timeout on a known
disconnected server goes through the reconnect path. Mirrors the same
alignment already landed on main.
* fix(cli): alias externalToolGuard subpath for vitest source resolution (#8125)
This PR added `@qwen-code/acp-bridge/externalToolGuard` imports to cli
serve/acp modules but not the vitest source alias every other acp-bridge
subpath carries. Without it, any vitest run whose acp-bridge dist is
stale or absent fails to resolve the import and the five serve test
files die at transform time. Add the alias following the documented
convention in the config so tests read the live source.
* fix(serve): reject non-ASCII external tool guard bearer tokens
A token outside the ASCII range passed construction but made the
handshake throw ERR_INVALID_CHAR when interpolated into the
Authorization header, blocking qwen serve startup in required mode
with an unexplained error. Enforce printable ASCII (0x21-0x7E) at
validation time so the configuration fails fast with a clear message.
---------
Co-authored-by: 秦奇 <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: qwen-code-ci-bot <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>