mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
Merge commit 'd4b54a46ff' into autofix/9394
This commit is contained in:
commit
4bf040766c
417 files changed
+68707
-5854
No files matched your search
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# GitHub does not START RUNS for a workflow file larger than 500 KB (512,000
|
||||
# bytes), and it says nothing when it stops: schedule ticks vanish, dispatches
|
||||
# sit "queued" forever with zero jobs, `issues`/`issue_comment` go quiet, and
|
||||
# only PR-event runs keep working — those resolve the workflow from the PR's
|
||||
# own branch, so an older, smaller copy runs and the workflow looks half-alive.
|
||||
# qwen-autofix.yml crossed the line on 2026-08-19 and the autofix loop went
|
||||
# dark for a day before anyone read the size.
|
||||
#
|
||||
# The gate below sits well under the real ceiling on purpose: a PR that trips
|
||||
# it still has room to land the fix, instead of discovering the wall with no
|
||||
# space left to move prose out.
|
||||
set -uo pipefail
|
||||
|
||||
GITHUB_LIMIT_BYTES=512000
|
||||
GATE_BYTES="${WORKFLOW_SIZE_GATE_BYTES:-470000}"
|
||||
WARN_BYTES=$((GATE_BYTES - 25000))
|
||||
|
||||
status=0
|
||||
shopt -s nullglob
|
||||
for file in .github/workflows/*.yml .github/workflows/*.yaml; do
|
||||
if ! size="$(wc -c <"${file}")"; then
|
||||
echo "::error file=${file}::unable to read ${file}"
|
||||
status=1
|
||||
continue
|
||||
fi
|
||||
pct=$((size * 100 / GITHUB_LIMIT_BYTES))
|
||||
if ((size > GATE_BYTES)); then
|
||||
echo "::error file=${file}::${file} is ${size} bytes — ${pct}% of GitHub's ${GITHUB_LIMIT_BYTES}-byte start-runs limit, past this repo's ${GATE_BYTES}-byte gate. Move prose into a sibling .md and long steps into .github/scripts/; do not raise the gate."
|
||||
status=1
|
||||
elif ((size > WARN_BYTES)); then
|
||||
echo "::warning file=${file}::${file} is ${size} bytes (${pct}% of GitHub's limit) — approaching the ${GATE_BYTES}-byte gate."
|
||||
fi
|
||||
done
|
||||
|
||||
if ((status == 0)); then
|
||||
echo "✅ every workflow file is under the ${GATE_BYTES}-byte gate"
|
||||
fi
|
||||
exit "${status}"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -289,6 +289,15 @@ jobs:
|
||||
echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Selected CI profile: ${profile}"
|
||||
|
||||
# Deliberately gated on skip_ci ONLY, not on the CI profile: a PR that
|
||||
# touches nothing but .github/ classifies as `github_ci_only`, and that
|
||||
# is exactly the PR most likely to push a workflow file past GitHub's
|
||||
# 500 KB start-runs limit — the limit that silently killed the autofix
|
||||
# loop for a day on 2026-08-19. Costs one `wc -c` per workflow file.
|
||||
- name: 'Check workflow file size'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||||
run: '.github/scripts/check-workflow-size.sh'
|
||||
|
||||
- name: 'Docs-only CI'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'docs_only' }}"
|
||||
run: 'echo "Docs-only change; full CI skipped."'
|
||||
@@ -581,6 +590,7 @@ jobs:
|
||||
QWEN_DEFAULT_AUTH_TYPE: ''
|
||||
run: |-
|
||||
mkdir -p "${HOME}" "${QWEN_HOME}"
|
||||
npx tsc -p integration-tests/tsconfig.json --pretty false
|
||||
npm run test:integration:no-ak:sandbox:none
|
||||
|
||||
- name: 'Publish Test Report (for non-forks)'
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+237
-1279
File diff suppressed because it is too large.
Load diff
@@ -553,53 +553,34 @@ jobs:
|
||||
run: |-
|
||||
set -uo pipefail
|
||||
# Pool wipe idiom (serve-ab.yml, qwen-triage.yml): empties the
|
||||
# workspace but keeps the directory itself for the retry checkout,
|
||||
# so no cd-escape and no recreation are needed. The sudo leg only
|
||||
# helps on pool members WITH passwordless sudo; on the rest a
|
||||
# root-owned poisoning degrades to warn-and-retry — the heal chain
|
||||
# must never fail, so the retry still runs against survivors.
|
||||
# workspace but keeps the directory itself for the retry checkout.
|
||||
# GITHUB_WORKSPACE is set by actions/runner (always
|
||||
# <runner workspace>/<repo>/<repo>), so the `:?` guard is the only
|
||||
# STRING check this needs. The wipe also validates the filesystem
|
||||
# OBJECT at $WS against its resolved path: `[ -L ]`/`[ -d ]` see
|
||||
# only the FINAL path component, so a symlinked INTERMEDIATE
|
||||
# component would pass both and let find delete content OUTSIDE
|
||||
# the runner workspace through the redirection, then run the
|
||||
# secret-bearing review step there — refuse any path that does not
|
||||
# resolve to itself instead. A legitimate workspace is always a
|
||||
# runner-created plain directory with no symlink components, so
|
||||
# refusal costs nothing. The sudo leg only helps on pool members
|
||||
# WITH passwordless sudo; on the rest a root-owned poisoning
|
||||
# degrades to warn-and-retry — the heal chain must never fail, so
|
||||
# the retry still runs against survivors.
|
||||
WS="${GITHUB_WORKSPACE:?}"
|
||||
# Canonicalize before matching: the kernel resolves non-canonical
|
||||
# spellings to the guarded roots (`/home/.` -> /home, `//usr` ->
|
||||
# /usr), so a raw string match lets them slip past the case arms.
|
||||
# `-m` is GNU-only — a BSD realpath exits 1 on it and the fallback
|
||||
# silently keeps the raw spelling. Safe here (this pool is
|
||||
# Linux-only), and off-GNU the strip loop and the allowlist below
|
||||
# are what still hold; the suite gates its GNU-only assertion on a
|
||||
# host probe rather than assuming this line ran.
|
||||
WS="$(realpath -m -- "$WS" 2>/dev/null || printf '%s' "$WS")"
|
||||
# Trailing slashes slip past the exact-match case arms below
|
||||
# (`/home/` would pass the guard and reach the rm); realpath strips
|
||||
# them too, but this keeps the guard whole when realpath is absent.
|
||||
while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done
|
||||
case "$WS" in
|
||||
/|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: $WS"; exit 1 ;;
|
||||
esac
|
||||
# A denylist can only enumerate known roots — the allowlist closes
|
||||
# every other one (/tmp, /opt, ...): only a directory inside the
|
||||
# runner workspace may be wiped.
|
||||
RWS="${RUNNER_WORKSPACE:?}"
|
||||
RWS="$(realpath -m -- "$RWS" 2>/dev/null || printf '%s' "$RWS")"
|
||||
# Mirror the WS strip: without realpath, a trailing slash would
|
||||
# turn the allowlist pattern into "$RWS"//* and refuse the real
|
||||
# workspace; "/" stripped empty would match every path instead.
|
||||
while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done
|
||||
if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi
|
||||
case "$WS" in
|
||||
"$RWS"/*) ;;
|
||||
*) echo "::error::refusing to wipe workspace outside the runner workspace: $WS"; exit 1 ;;
|
||||
esac
|
||||
if find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || sudo -n find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf {} +; then
|
||||
if [ "$(realpath -- "$WS")" != "$WS" ] || [ -L "$WS" ] || [ ! -d "$WS" ]; then
|
||||
echo "::error::workspace is not a plain directory or resolves through symlinks: $WS"
|
||||
exit 1
|
||||
fi
|
||||
if find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf {} + || sudo -n find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf {} +; then
|
||||
echo "::warning::first checkout failed; wiped the workspace for a clean retry"
|
||||
else
|
||||
echo "::warning::could not wipe the workspace; the retry checkout may fail again"
|
||||
fi
|
||||
# Triage counts survivors and exits 1; here the chain must stay
|
||||
# alive for the retry, so survivors only get a signal.
|
||||
remaining="$( (find "$WS" -mindepth 1 -maxdepth 1 2>/dev/null || true) | wc -l | tr -d ' ')"
|
||||
if [ "$remaining" != '0' ]; then
|
||||
survivors="$( (find "$WS" -mindepth 1 -maxdepth 1 2>/dev/null || true) | tr '\n' ' ' | cut -c1-500)"
|
||||
echo "::warning::${remaining} entries survived the workspace wipe: ${survivors}; the retry checkout runs against them"
|
||||
survivors="$( (find "$WS" -mindepth 1 -maxdepth 1 2>/dev/null || true) | tr '\n' ' ' | cut -c1-500)"
|
||||
if [ -n "$survivors" ]; then
|
||||
echo "::warning::workspace wipe left survivors: ${survivors}; the retry checkout runs against them"
|
||||
fi
|
||||
|
||||
- name: 'Checkout base branch (retry)'
|
||||
@@ -1326,6 +1307,7 @@ jobs:
|
||||
KIND=''
|
||||
run_review_once() {
|
||||
local attempt_timeout="$1"
|
||||
local attempt_prompt="$2"
|
||||
OUTCOME='fatal'
|
||||
REASON=''
|
||||
KIND=''
|
||||
@@ -1382,7 +1364,7 @@ jobs:
|
||||
--auth-type openai \
|
||||
--approval-mode yolo \
|
||||
"${MODEL_ARGS[@]}" \
|
||||
--prompt "$PROMPT" \
|
||||
--prompt "$attempt_prompt" \
|
||||
--output-format stream-json \
|
||||
| tee "$LOG_PATH"
|
||||
local ps=("${PIPESTATUS[@]}")
|
||||
@@ -1478,16 +1460,17 @@ jobs:
|
||||
|
||||
# Retry budget: all attempts SHARE QWEN_TIMEOUT, so two tries can never
|
||||
# exceed the single-review budget (nor the job timeout), and that
|
||||
# shared budget is the only thing that needs to bound them. A retry
|
||||
# re-runs the whole review from scratch rather than resuming the failed
|
||||
# one, so on a large PR it spends minutes re-fetching, re-chunking and
|
||||
# re-launching agents before the first finding exists — a short retry
|
||||
# cap makes the retry die on the clock instead of clearing the
|
||||
# transient it was meant to clear. Every attempt therefore gets the
|
||||
# whole remaining budget. Retry only a `retryable` outcome, only once,
|
||||
# and only when enough budget is left for the retry to plausibly
|
||||
# finish; below that, report the transient failure so the next run
|
||||
# starts over with a full budget.
|
||||
# shared budget is the only thing that needs to bound them. Each retry
|
||||
# runs FRESH — a retry re-runs the whole review from scratch rather
|
||||
# than resuming the failed one. `--resume` is a local convenience
|
||||
# only: on CI the review runs no-sandbox on the reviewed PR's own
|
||||
# code, and `runNonInteractiveCli`'s cleanup deletes the attempt's
|
||||
# worktree the moment it exits, so there is no interrupted state on
|
||||
# disk for a next attempt to continue — a resume would refuse
|
||||
# `worktree-gone` and start over anyway. Retry only a `retryable`
|
||||
# outcome, only once, and only when enough budget is left for the
|
||||
# retry to plausibly finish; below that, report the transient failure
|
||||
# so the next run starts over with a full budget.
|
||||
BUDGET_SECONDS=$(( QWEN_TIMEOUT * 60 ))
|
||||
RETRY_BACKOFF_SECONDS=60
|
||||
RETRY_MIN_SECONDS=600
|
||||
@@ -1499,7 +1482,7 @@ jobs:
|
||||
if [ "$attempt_timeout" -lt 30 ]; then
|
||||
fail "${REASON:-Qwen review ran out of time budget before it could complete.}" 1 "$KIND"
|
||||
fi
|
||||
run_review_once "$attempt_timeout"
|
||||
run_review_once "$attempt_timeout" "$PROMPT"
|
||||
if [ "$OUTCOME" = "success" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
+1406
-22
File diff suppressed because it is too large.
Load diff
@@ -188,6 +188,18 @@ dispositions, changed files, checks actually run, and remaining blocker.
|
||||
handoff comments embed a byte-truncated excerpt of them, and a severed
|
||||
`<details>` tag would swallow the rest of the comment when rendered.
|
||||
|
||||
Instead, whenever you write `<workdir>/failure.md`, ALSO write
|
||||
`<workdir>/failure.zh.md` — a complete paragraph-by-paragraph Chinese
|
||||
translation of it. The workflow wraps `failure.zh.md` in its OWN collapsed
|
||||
`<details><summary>中文说明</summary>` block when posting the handoff
|
||||
comment, so Chinese maintainers can act on the escalation without reading
|
||||
the English body. Constraints on `failure.zh.md`, because the workflow
|
||||
byte-truncates it inside that wrapper: plain Markdown only; NO HTML tags at
|
||||
all (no `<details>`, `<summary>`, or any `<…>`); no `<!--` sequences. A
|
||||
missing `failure.zh.md` degrades the comment to the headline translation
|
||||
alone, so write it even when the stop is a single paragraph. Translate the
|
||||
whole of `failure.md`, section by section; do not summarize or omit.
|
||||
|
||||
- Never ask the user a question in this headless workflow. Write
|
||||
`<workdir>/failure.md` and stop only when a required runnable check remains
|
||||
failing after attempted fixes; tracing the exact evidence through its source,
|
||||
|
||||
@@ -82,6 +82,20 @@ Runs the CLI via `tsx` with `DEV=true`. Changes to `packages/core` or
|
||||
Tests must be run from within the specific package directory, not the project
|
||||
root.
|
||||
|
||||
**Fresh clone or new worktree:** `packages/cli` unit tests import workspace
|
||||
packages (`@qwen-code/acp-bridge`, `@qwen-code/web-templates`,
|
||||
`packages/channels/*`, ...) through their built `dist/` output, and
|
||||
`packages/core` tests import the package's own entry
|
||||
(`@qwen-code/qwen-code-core`), which also resolves into `dist/`. A plain
|
||||
`npm ci` already builds them via the `prepare` script, but a worktree that
|
||||
shares the main checkout's `node_modules` (or a deep-cleaned copy) does not
|
||||
have them. If any prerequisite is missing, a vitest `globalSetup` guard stops
|
||||
the run and names the fix; build once from the repository root:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Run individual test files** (always preferred):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
**Related issues:** #3761, #3759
|
||||
**Related PRs:** #3814, #3866
|
||||
|
||||
> **Updated 2026-08-08:** The UserQuery consume point now waits for at most
|
||||
> 100 ms. If the recall settles inside that budget it is delivered initially;
|
||||
> if it does not, a deterministic fast result is delivered instead of nothing,
|
||||
> and the pending model-selected result is preserved for ToolResult delivery
|
||||
> with the fast-delivered documents excluded. The zero-wait UserQuery
|
||||
> statements and behavior-table rows below describe the original design and are
|
||||
> superseded by `2026-08-08-native-memory-recall-reliability.md`.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# Native Memory Recall Reliability
|
||||
|
||||
## Problem
|
||||
|
||||
Managed-memory recall starts asynchronously for each user query. The initial
|
||||
request originally performed a zero-wait consume, so a useful selector result
|
||||
could miss the first prompt. If the turn has no tool call, that result has no
|
||||
later safe delivery point and is discarded.
|
||||
|
||||
A fixed 100 ms initial budget was the first attempt at a fix. Measurement
|
||||
showed it is not sufficient on its own. Recall awaits the model selector
|
||||
whenever a `Config` is present — the normal case — and that selector is a
|
||||
network side query whose abort ceiling is 30 s. The budget is therefore
|
||||
dominated by round-trip time, not by the incidental scheduler timing it was
|
||||
sized for, so it expires on the common path. Delivery then falls through to the
|
||||
ToolResult point, which a tool-free turn never reaches. Tool-free turns are
|
||||
exactly the ones where user-level memory matters most: short questions answered
|
||||
from context rather than from the repository.
|
||||
|
||||
The model selector remains the normal precision gate. Its failure fallback had
|
||||
two independent correctness problems: it tokenized only ASCII text and gave
|
||||
every non-empty document a positive score even without a lexical match.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep a single recall lifecycle and model-primary selection. Add one
|
||||
deterministic delivery stage in front of it — not the two-stage shared-scan
|
||||
Fast/Refined architecture originally proposed in RFC #7040.
|
||||
|
||||
- Give user-query recall a 100 ms initial wait **ceiling**, not a fixed cost.
|
||||
The wait ends on whichever comes first: recall settling, the deterministic
|
||||
result being published, cancellation, or the ceiling.
|
||||
- Deliver a result that settles inside the budget in the initial prompt.
|
||||
- If the budget expires and the deterministic candidate pass found relevant
|
||||
documents, deliver that bounded result instead of nothing.
|
||||
`selectModelCandidateDocuments` already computes lexically ranked candidates
|
||||
in order to build the model manifest, so the fast result reuses them and
|
||||
costs no extra scan or I/O. It is capped at two documents
|
||||
(`MAX_FAST_RECALL_DOCS`), well below the five-document prompt limit, because
|
||||
it carries no model judgement.
|
||||
- Leave recall pending after a fast delivery so the model-selected result still
|
||||
lands at the existing same-query ToolResult delivery point.
|
||||
- Exclude documents the fast phase already delivered from that later delivery,
|
||||
rebuilding the prompt from what remains. Both results come from one scan, so
|
||||
the selector never saw the fast documents as excluded and can legitimately
|
||||
re-select them. When every selected document was already delivered, record
|
||||
`already_delivered`; when the selector returned no documents at all, record
|
||||
`no_relevant_results`.
|
||||
- Do not abort recall merely because the initial budget expires.
|
||||
- Preserve the existing cancellation and exactly-once terminal telemetry paths.
|
||||
A cancelled turn delivers no fast result.
|
||||
|
||||
The 100 ms budget stays internal, per RFC #7040's direction of a small fixed
|
||||
internal budget determined by benchmark rather than exposed as public
|
||||
configuration; telemetry can show whether a later change is justified.
|
||||
|
||||
### The budget is a ceiling because the scan, not the selector, decides
|
||||
|
||||
The fast result is published once recall has enumerated, read, and parsed the
|
||||
memory tree — and this design removed the 200-document cap for recall, so that
|
||||
scan grows with the tree. `recall-scan-latency.test.ts` measures the wall-clock
|
||||
time from the recall call to that publication against a real temporary tree:
|
||||
|
||||
| topics | median | share of the 100 ms budget |
|
||||
| ------ | ------- | -------------------------- |
|
||||
| 200 | ~29 ms | ~29% |
|
||||
| 500 | ~70 ms | ~70% |
|
||||
| 1000 | ~130 ms | ~130% |
|
||||
|
||||
Two conclusions follow, and neither is visible in the deterministic _scoring_
|
||||
cost, which is microseconds.
|
||||
|
||||
First, for any tree small enough to scan in time — which is the ordinary case,
|
||||
where a user holds tens of topics rather than hundreds — the fast result is in
|
||||
hand long before the ceiling. Spending the remainder waits for a model selector
|
||||
that this design already assumes will miss the budget, so it is close to pure
|
||||
added latency on every user turn. The wait therefore ends on the fast result.
|
||||
|
||||
This has a consequence the code does not make obvious, so state it directly:
|
||||
**on the initial turn, once the deterministic scorer matches anything, the fast
|
||||
result is what gets delivered — regardless of how fast the selector is.**
|
||||
`onFastResult` is published before recall issues the selector request at all,
|
||||
so the recall promise is necessarily unsettled when the wait ends on it, and
|
||||
the "prefer a settled recall" branch is reached only when no fast result
|
||||
exists: no `Config`, or nothing matched lexically.
|
||||
|
||||
That is the intended trade, not an oversight. A model side query does not
|
||||
complete inside a 100 ms ceiling in production, so arbitrating between the two
|
||||
would spend the remainder of the budget on every turn to win a race that does
|
||||
not occur. Local verification against a loopback selector settling in 15 ms
|
||||
confirms the behaviour and its bound: the fast result is delivered and the
|
||||
model's picks are discarded — while the pre-change build delivered nothing at
|
||||
all on that same turn. The selector's judgement still reaches the model, at the
|
||||
ToolResult delivery point, with the fast documents excluded.
|
||||
|
||||
Second, on a slow enough machine the scan alone exceeds the ceiling. The table
|
||||
above is the conservative measurement; an independent run on faster hardware
|
||||
recorded 9 ms / 21 ms / 46 ms for the same three sizes, all inside the ceiling.
|
||||
The crossover is therefore a property of the machine, not a fixed topic count —
|
||||
somewhere between roughly one thousand topics and never, depending on I/O
|
||||
speed. Past it, a turn spends the whole budget and still delivers nothing,
|
||||
which is worse than the zero-wait behaviour this design replaced. Ending the wait early does not fix
|
||||
that case; it bounds it and removes the cost everywhere else. A persistent
|
||||
catalog is the actual fix and remains out of scope, per
|
||||
`2026-08-09-bounded-memory-recall-candidates.md`.
|
||||
|
||||
### `MAX_RELEVANT_DOCS` is per delivery, not per turn
|
||||
|
||||
`MAX_RELEVANT_DOCS = 5` bounds one prompt. It does not bound a turn. A turn
|
||||
that fast-delivers two documents and then, at ToolResult, delivers five
|
||||
documents the fast phase did not include puts **seven** documents in front of
|
||||
the model. Deduplication removes repeats, not the sum.
|
||||
|
||||
This is a deliberate consequence of dropping combined fast/refined budget
|
||||
accounting, which RFC #7040 originally specified as a fill-to-five limit
|
||||
across both phases. Keeping the combined limit means carrying a cross-phase
|
||||
document budget through the delivery path — the same bookkeeping this design
|
||||
declined for the duplicate-injection risk it introduces. Both prompts stay
|
||||
individually bounded, each document body is still truncated to
|
||||
`MAX_DOC_BODY_CHARS`, and the fast phase is capped at two, so the worst case
|
||||
is bounded and small; it is simply not five.
|
||||
|
||||
Should the aggregate ever need a hard ceiling, the cheap version is to pass
|
||||
`limit - fastDeliveredPaths.size` as the refined limit rather than to
|
||||
reintroduce a second budget.
|
||||
|
||||
### Why not the original Fast/Refined architecture
|
||||
|
||||
RFC #7040 specified two results produced from one shared scan, with the refined
|
||||
pass excluding already-delivered fast documents and filling up to a combined
|
||||
five-document limit. The delivery guarantee that design existed to provide is
|
||||
worth having; its machinery is not. A second selection pathway needs its own
|
||||
scan plumbing, its own budget accounting, and cross-phase document bookkeeping —
|
||||
and that bookkeeping is the source of the duplicate-injection class of bug the
|
||||
RFC itself warned about. Reusing the candidates the selector was already going
|
||||
to score gets the same guarantee from one added callback and one exclusion set.
|
||||
|
||||
### Telemetry: `phase` and `strategy` are orthogonal
|
||||
|
||||
`phase` is the **delivery stage**: `fast` for a deterministic result injected
|
||||
at budget expiry, `refined` for the model-selected result. `strategy` is the
|
||||
**selection method**: `none`, `heuristic`, or `model`. They are not redundant
|
||||
and neither subsumes the other. A `fast` delivery is always `heuristic`, but a
|
||||
`refined` delivery is `model` normally and `heuristic` when the selector failed
|
||||
and the fallback ran. Reading delivery-stage behaviour off `strategy` alone
|
||||
would silently merge "the deterministic result arrived first" with "the model
|
||||
selector broke".
|
||||
|
||||
Improve the deterministic scorer, which now serves both the fast path and the
|
||||
selector-failure fallback:
|
||||
|
||||
- normalize query and document text with Unicode NFKC;
|
||||
- keep runs of at least three non-CJK letters, marks, and digits as whole
|
||||
tokens. `\p{L}`-based rather than `[a-z0-9]`, so Cyrillic, Greek, Arabic,
|
||||
and accented Latin produce tokens instead of none. CJK is excluded per
|
||||
character rather than by alternation order, because `\p{L}` also matches
|
||||
Han and a Latin-initial run would otherwise swallow the CJK after it and
|
||||
turn `abc漢字` into a single token;
|
||||
- generate Unicode code-point bigrams for Han, Hiragana, Katakana, and Hangul
|
||||
runs;
|
||||
- ignore isolated CJK characters;
|
||||
- bound fallback query tokens while retaining tokens from both ends;
|
||||
- score only the body window that can be surfaced in the prompt;
|
||||
- require a title, description, or body lexical match before applying a type
|
||||
boost;
|
||||
- weight each title and description token match above a body token match;
|
||||
- break score ties by recency, then by input order, never by document type.
|
||||
An alphabetical type comparison orders `feedback` before `project` before
|
||||
`reference` before `user`, and `MAX_FAST_RECALL_DOCS` takes only the top
|
||||
two, so a type tie-break would systematically drop user-level memory from
|
||||
the fast result — the exact case the fast path exists to serve. Input order
|
||||
as the final key keeps the project-before-user precedence, because recall
|
||||
concatenates project documents ahead of user ones.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No second scan, second selector, or separate fast/refined budget accounting.
|
||||
- No public recall timing or retrieval-mode setting.
|
||||
- No new tokenizer or retrieval dependency.
|
||||
- No change to memory writes, scopes, extraction, DREAM, forget, or compaction.
|
||||
- No removal of the shared scanner's 200-document cap for non-recall callers.
|
||||
Recall alone uses the bounded broad-candidate design documented in
|
||||
`2026-08-09-bounded-memory-recall-candidates.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
Recall quality is measured in `packages/core/src/memory/recall-eval.test.ts`
|
||||
against a 51-case, 25-document labeled corpus, scored both by the shipped
|
||||
scorer and by a frozen copy of the pre-change one so "no regression" is
|
||||
reproducible rather than asserted. Delivery is measured separately in
|
||||
`recall-delivery-eval.test.ts`, because a correct selection that never reaches
|
||||
the model is worth nothing, and scan latency in `recall-scan-latency.test.ts`,
|
||||
because a correct selection that is not ready in time reaches nothing either.
|
||||
|
||||
The eval prints the corpus size and the Recall@5 a query-blind random scorer
|
||||
would achieve on it (5 of 25 documents, so 20%), and a test keeps that floor
|
||||
at or below 25% with the measured result well clear of it. A small corpus
|
||||
flatters every design; the floor is what makes the headline readable.
|
||||
|
||||
- Recall settling inside the budget is delivered initially.
|
||||
- A budget expiry with deterministic candidates delivers that bounded result
|
||||
rather than nothing, and leaves recall alive for later ToolResult delivery.
|
||||
- The later delivery never repeats a document the fast phase already sent.
|
||||
- Cancellation ends the bounded wait and prevents stale delivery.
|
||||
- A fast result never crosses a query boundary.
|
||||
- No-result queries stay silent under both designs.
|
||||
- A labeled set covers Chinese, English, Japanese, Korean, mixed text,
|
||||
NFKC normalization, body-only matches, no-result queries, answerable
|
||||
queries that share no token with their document, and alphabetic scripts
|
||||
outside ASCII and CJK (Cyrillic, Greek, accented Latin).
|
||||
- The fast result is published inside the initial ceiling for tree sizes a
|
||||
user can plausibly reach, measured against a real temporary memory tree
|
||||
rather than modelled.
|
||||
- The initial wait ends as soon as the deterministic result is published and
|
||||
does not run to the ceiling; a wait with nothing to deliver still runs to
|
||||
the ceiling and then proceeds without memory.
|
||||
- The active-tool alias set is derived once per recall rather than once per
|
||||
scanned document.
|
||||
- Score ties are broken by recency rather than by document type, so a
|
||||
user-typed document is not pushed out of the two-document fast result by a
|
||||
tied feedback, project, or reference document.
|
||||
- A result whose every document the fast phase already delivered is recorded
|
||||
as `already_delivered` wherever it is discarded, not only at the ToolResult
|
||||
consume point. A tool-free turn that delivered everything must not be
|
||||
counted in the `no_safe_delivery_point` bucket; a partial overlap still is,
|
||||
because the documents outside the fast set genuinely had no delivery point.
|
||||
- Long CJK queries keep bounded scoring work and preserve both query ends.
|
||||
- Existing active-tool noise filtering remains unchanged on the deterministic
|
||||
candidate path. The model-selector failure fallback still triggers and
|
||||
returns at most five documents; its scoring quality intentionally improves
|
||||
per the scorer changes above (measured by the frozen-scorer comparison in
|
||||
`recall-eval.test.ts`).
|
||||
|
||||
### Known limitations
|
||||
|
||||
- The delivery evaluation models selector latency rather than measuring it; a
|
||||
network round trip cannot be timed in a unit test. Results are reported per
|
||||
latency scenario, and the structural claim — that a selector slower than the
|
||||
budget leaves a tool-free turn with no delivery point under the single-path
|
||||
design — holds for every scenario above the budget.
|
||||
- The fast result has no model judgement behind it. It is capped at two
|
||||
documents to bound the cost of being wrong, but on a tool-free turn where the
|
||||
selector never lands, a mis-ranked fast document is what the model sees.
|
||||
- Scoring is substring-based, so a query token can match inside a longer word
|
||||
("owner" inside "ownership"). The evaluation corpus records one such case
|
||||
rather than hiding it.
|
||||
- The fast path closes the timing gap, not the matching gap. A query that
|
||||
shares no token with its document produces no deterministic result, so a
|
||||
tool-free turn asking it still ends with nothing delivered; only the model
|
||||
selector can serve those, and on a tool-free turn it never lands. The
|
||||
`semantic-no-lexical` slice of the corpus measures this directly — the
|
||||
shipped scorer and the frozen pre-change scorer both score 0% Recall@5 on
|
||||
it, so requiring a lexical match did not create the gap, but it does keep
|
||||
the fast path silent there. This is why the headline tool-free delivery
|
||||
figure is 92.3% and not 100%: the residual 7.7% is exactly that slice.
|
||||
- On slow enough I/O the memory-tree scan exceeds the initial ceiling, and the
|
||||
turn then spends the whole budget and still delivers nothing. The crossover
|
||||
is machine-dependent: roughly a thousand topics on the slower of the two
|
||||
machines measured, and not reached at all on the faster one. Ending the wait
|
||||
on the fast result bounds this rather than removing it; the real fix is a
|
||||
persistent catalog, which is out of scope.
|
||||
- On the initial turn the model selector's judgement is not used when the
|
||||
deterministic scorer matched, whatever the selector's latency. See the
|
||||
ceiling section above; it reaches the model at ToolResult instead.
|
||||
- Scripts written without word separators and outside the CJK set — Thai,
|
||||
Khmer, Lao — now produce a token where they previously produced none, but
|
||||
the token is the whole run. That is not segmentation, and such a query will
|
||||
usually still match nothing.
|
||||
- Recall can see older documents outside the shared 200-document scanner cap,
|
||||
but non-recall callers, including Forget, keep the existing capped scanner.
|
||||
A broader manageability pass is separate from this recall-only change.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Bounded Memory Recall Candidates
|
||||
|
||||
## Problem
|
||||
|
||||
The project and user memory scanners enumerate, read, and parse every topic,
|
||||
then return only the 200 most recent documents. Recall uses those shared scanner
|
||||
APIs, so an older relevant document outside either 200-document window cannot
|
||||
reach the heuristic or model selector even though the expensive scan work has
|
||||
already happened. The truncation key is recency, applied per scope and before
|
||||
anything has looked at the query.
|
||||
|
||||
The same capped APIs are also used by Forget, Indexer, Status, and Extraction.
|
||||
Removing their limit globally would widen unrelated behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep the existing scanner APIs and their 200-document limit unchanged. Add
|
||||
explicit all-topic variants used only by recall.
|
||||
|
||||
Recall ranks the combined project and user pool before model selection:
|
||||
|
||||
- retain up to 180 documents with a lexical match using the existing scorer;
|
||||
- fill the remaining candidate slots by recency, preserving at least 20 recent
|
||||
opportunities when enough lexical matches exist;
|
||||
- interleave recent opportunities with lexical candidates so the manifest byte
|
||||
budget cannot systematically exclude the entire recent reserve;
|
||||
- send at most 200 candidates to the model selector;
|
||||
- append manifest entries only while their cumulative UTF-8 size remains at or
|
||||
below 25,000 bytes;
|
||||
- validate selector output only against documents actually present in that
|
||||
bounded manifest.
|
||||
|
||||
The heuristic fallback continues to score the complete recall pool and still
|
||||
returns at most five documents. Existing body and prompt limits remain
|
||||
unchanged.
|
||||
|
||||
### This is a change of truncation key, not a lifted ceiling
|
||||
|
||||
"Removes the 200-document cap" is the wrong summary, and reviewers should
|
||||
read the effect per pool size rather than as a uniform widening. What the
|
||||
change actually does is replace a per-scope, query-blind recency truncation
|
||||
with a global, query-aware one:
|
||||
|
||||
- **Pool at or under 200 documents.** No document is excluded by count under
|
||||
either design, but the 25,000-byte manifest budget is a ceiling the old path
|
||||
did not have, and it binds far earlier than the document count suggests
|
||||
(see below). The interleaving above exists so that truncation cannot fall
|
||||
entirely on the recent reserve.
|
||||
- **Pool between 200 and 400 documents, neither scope over 200.** The old
|
||||
path sent every document to the selector — up to 200 project plus 200 user.
|
||||
The new path sends at most 200, and in practice the byte budget cuts it
|
||||
further: a measured run with 150 project plus 150 user documents sent 300
|
||||
manifest lines under the old path and **94** under the new one. The
|
||||
candidates that survive are chosen by lexical relevance plus a recency
|
||||
reserve rather than by recency alone, which is the intended trade, but the
|
||||
reduction is larger than the document cap implies.
|
||||
- **Either scope over 200 documents.** This is the case the change is for.
|
||||
An old, lexically matching document that the recency cap made permanently
|
||||
invisible can now be selected. Measured: with 251 documents in one scope and
|
||||
the only lexical match the oldest, the old path produced a 200-line manifest
|
||||
without the target; the new path produced a 96-line manifest with the target
|
||||
first.
|
||||
|
||||
### The binding constraint is `MAX_MODEL_MANIFEST_BYTES`, not the document cap
|
||||
|
||||
`MAX_MODEL_CANDIDATE_DOCS = 200` reads like the limit but rarely is one. Each
|
||||
manifest line carries an absolute file path and an ISO-8601 timestamp before
|
||||
the description, so its fixed overhead is on the order of 150–250 bytes for an
|
||||
ordinary project path. Against a 25,000-byte budget that binds somewhere around
|
||||
90–150 documents, which is why both measurements above land in the nineties
|
||||
rather than at 200.
|
||||
|
||||
Two things follow. Deployments should read the byte budget, not the document
|
||||
cap, as the real candidate ceiling. And the recency reserve only survives
|
||||
truncation because it is interleaved with the lexical candidates rather than
|
||||
appended after them — at a cut in the nineties, an appended reserve would be
|
||||
discarded in full.
|
||||
|
||||
The manifest byte budget also packs rather than prefixes: a document whose
|
||||
line does not fit is skipped and later, shorter lines are still considered.
|
||||
A long-description document can therefore be dropped while a lower-ranked one
|
||||
is kept.
|
||||
|
||||
Forget, Indexer, Status, and Extraction keep the capped scanner. That preserves
|
||||
their current behavior but means an older document can become recallable before
|
||||
it becomes manageable by those non-recall flows.
|
||||
|
||||
## Failure and compatibility boundaries
|
||||
|
||||
Project scanning remains required. User scanning remains best-effort. Invalid
|
||||
or unreadable files keep the existing skip behavior. Empty candidate manifests
|
||||
return no model selection rather than sending an unbounded request.
|
||||
|
||||
There is no public setting, persistent index, new dependency, provider API, or
|
||||
second selection pathway. Each recall enumerates, reads, and parses the full
|
||||
project and user memory trees once, then performs O(n) local ranking and
|
||||
active-tool filtering over the parsed documents. The deterministic fast path
|
||||
described in `2026-08-08-native-memory-recall-reliability.md` reuses the
|
||||
candidates produced by that single pass, so it adds no scan, no ranking work,
|
||||
and no state machine — only an earlier delivery point for results already
|
||||
computed. The model candidate count and manifest are
|
||||
bounded, but the local I/O and filtering work grow with the memory tree; a
|
||||
persistent catalog requires separate measurement and evidence.
|
||||
|
||||
## Verification
|
||||
|
||||
- A deliberately old relevant topic beyond the regular 200-document result is
|
||||
recalled from a real temporary memory tree.
|
||||
- The regular scanner still returns 200 documents and omits that topic.
|
||||
- The model candidate set contains the lexical target and recent reserve while
|
||||
remaining at 200 documents.
|
||||
- A manifest built from large multibyte descriptions stays within 25,000 UTF-8
|
||||
bytes.
|
||||
- A real temporary memory-tree integration test verifies overflow-topic recall;
|
||||
client tests independently verify bounded initial waiting and later
|
||||
ToolResult delivery.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Phase 2: Aone Code read path for /review
|
||||
|
||||
> Status: draft. Parent: `docs/design/2026-08-13-review-platform-provider-abstraction.md`.
|
||||
> Phase 0+1 (GitHub provider) merged as #9096 (`dc7e234876`).
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 0+1 added the `ReviewPlatformReader` seam with one provider (GitHub).
|
||||
`/review` still cannot review a MaxCompute CR on Aone Code (`odps_src`), which
|
||||
is the motivating target. This phase adds the Aone read path so a local review
|
||||
of an Aone CR works end to end (diff + worktree + issue evidence + agent
|
||||
findings), and `--comment` on an Aone target refuses cleanly.
|
||||
|
||||
## Verified platform facts (re-confirmed 2026-08-15 against `maxcompute/odps_src`)
|
||||
|
||||
| Capability | a1 CLI / git | Notes |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MR metadata | `a1 repo mr view <global-id> --repo <g/p> -f json` → `mergeRequest{sourceBranch, targetBranch, title, description, detailUrl, author, state}` | `sourceBranch` is the head SHA (AGit-Flow); `targetBranch` is base; `detailUrl` = `https://code.alibaba-inc.com/<g>/<p>/codereview/<global-id>`; **no** additions/deletions/changedFiles (compute locally) |
|
||||
| Fetch ref | `git fetch <remote> refs/merge-requests/<global-id>/head:<ref>` | head SHA matches `sourceBranch`; merge-base vs `targetBranch` and `git diff` both computable (probe: MR 29295886 → 51 files, 2930+/114-) |
|
||||
| id/iid | `mr view`/`mr list` carry both `id` (global) and `iid` | refs/`mr view` key on the **global id**; Aone CR URL is `/codereview/<global-id>` |
|
||||
| Discussion | `a1 repo mr comment list --mr <id> --repo <g/p> -f json` → array with `id, note, path, line, author, closed, outdated, isAiComment` | **text is in `note`** (not `body`); `body` is empty |
|
||||
| Issue evidence | `a1 repo mr workitem list --mr <id>` → `[{id, subject, link, assigned_to}]`; `a1 project workitem get <id>` for body/comments | workitem id = the `#AONE_ID` in the commit title |
|
||||
| Diff listing | `a1 repo mr diff <id>` (file list) / `<id> <file>` (per-file) | only needed in lightweight mode; the primary path diffs via git after fetching the ref |
|
||||
| CI status | `a1 repo mr status <id>` | presubmit-equivalent (read-only) |
|
||||
|
||||
## Proposed scope (vertical slice — confirm before implementing)
|
||||
|
||||
**In scope (makes local Aone review work):**
|
||||
|
||||
1. `lib/platform/aone-client.ts` — a1 transport sibling to `lib/gh.ts`,
|
||||
replicating its contract: `execFileSync` without shell, transient retry only
|
||||
on idempotent reads, byte mode for diffs, actionable auth check
|
||||
(`a1 auth whoami`), and `--repo` threading (Aone has no `GH_HOST` analogue;
|
||||
the repo coordinate is passed per call).
|
||||
2. `lib/platform/aone.ts` — implements `ReviewPlatformReader`:
|
||||
`resolveRepo` (parse the clone's origin URL → `gitlab.alibaba-inc.com/<g>/<p>`),
|
||||
`getPrMeta` (mr view → sourceBranch/detailUrl), `getClosingIssues` (workitem
|
||||
list), `getIssue` (workitem get), `fetchDiff` (git-based after fetching the
|
||||
ref; a1 per-file diff only for lightweight mode), `getCommentBody` (read
|
||||
`note` from comment list).
|
||||
3. **Detection** in `registry.ts`: select the platform from (a) an explicit
|
||||
`--host` whose host is an Aone host, (b) an explicit `--remote` URL on an
|
||||
Aone host, else (c) the cwd clone's origin. An explicit NON-Aone
|
||||
host/remote beats the cwd probe (so an explicitly-GitHub subcommand run
|
||||
from an Aone clone is not hijacked). The four reader-backed subcommands
|
||||
(meta/issue-context/fetch-diff/comment-body) thread `--host` into
|
||||
detection; fetch-pr threads the remote URL. (An explicit `--platform`
|
||||
override is deferred — an explicit `--host` already serves as the
|
||||
practical override.)
|
||||
4. `fetch-pr.ts` — provider-aware refspec + metadata: Aone uses
|
||||
`refs/merge-requests/<global-id>/head` and mr-view metadata (no
|
||||
additions/deletions — compute from the fetched diff). This is the enabler
|
||||
for worktree mode, build/test, and the full agent review.
|
||||
|
||||
**Deferred to a follow-up (degrade gracefully in this phase):**
|
||||
|
||||
- `pr-context.ts` discussion rendering (inline threads, review summaries,
|
||||
ledger): Aone has comments but no GitHub review-summary model. For v1, Aone
|
||||
runs enter the existing **context-unavailable** mode (verdict caps at
|
||||
COMMENT; findings still generated). Note Agent 0 (issue fidelity) is gated
|
||||
on `pr-context` success, so it is SKIPPED on Aone too — `issue-context`
|
||||
works standalone for the workitem evidence but is not wired to Agent 0.
|
||||
- `comment-status.ts` anchor-status and `presubmit.ts` CI checks: skip for
|
||||
Aone v1 (the skill already handles their absence).
|
||||
|
||||
`--comment` on an Aone target refuses with a clear message (posting is Phase 3).
|
||||
|
||||
## Key design decisions
|
||||
|
||||
- **Transport is a sibling, not a refactor of gh.ts.** `isOwnerRepo`,
|
||||
`HOSTNAME_RE`, and the host-routing state (`setGhHost`/`resolveGhHost`/
|
||||
`getGhHost`) are currently gh.ts-owned. Aone has no global host routing
|
||||
(repo coordinate per call), so the shared surface is the _validators_
|
||||
(move `isOwnerRepo`/`HOSTNAME_RE` into `lib/platform/` or a shared
|
||||
`lib/validate.ts`) — not the gh host state.
|
||||
- **Detection is cheap and explicit.** Remote-URL host is already parsed by
|
||||
`match-remote`'s matcher; the reader threads `--host`/the remote URL into
|
||||
detection. An explicit non-Aone signal beats the cwd probe.
|
||||
- **Diff is git-based.** Fetching the MR ref makes Aone diffs identical to
|
||||
GitHub's (merge-base + unified diff), so `plan-diff`/chunking need no
|
||||
Aone branch. `a1 mr diff` is only the lightweight fallback.
|
||||
- **Global id is the review number.** Aone CR URLs carry the global id; the
|
||||
reader uses it for refs/`mr view`/comments/workitems.
|
||||
|
||||
## Files affected
|
||||
|
||||
- New: `lib/platform/aone-client.ts`, `lib/platform/aone.ts` (+ tests).
|
||||
- Modified: `lib/platform/types.ts` (add `'aone'` to `PlatformKind`),
|
||||
`lib/platform/registry.ts` (detection), `fetch-pr.ts` (provider routing),
|
||||
possibly a shared validators module, `parse-args.ts` (Aone CR URL grammar
|
||||
`/codereview/<id>`), `meta.ts`/`issue-context.ts`/`fetch-diff.ts`/
|
||||
`comment-body.ts` (platform selection + non-GitHub `--host` semantics),
|
||||
`SKILL.md` (Aone target handling + lightweight/detect guidance),
|
||||
`docs/users/features/code-review.md`.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Scope**: ship the minimal slice (reader + detection + fetch-pr;
|
||||
pr-context/comment-status/presubmit degrade), or also make `pr-context`
|
||||
render Aone comments (bigger lift)? Recommendation: minimal slice.
|
||||
2. Aone comment threading (`closed`, `outdated`) vs GitHub's
|
||||
`in_reply_to_id`/`line` model — only matters if `comment-status` joins.
|
||||
3. build/test (Agent 7) on a Bazel monorepo needs a repo-config escape hatch
|
||||
(already flagged out of scope in the parent doc); confirm it degrades
|
||||
cleanly rather than attempting a full `bazel build`.
|
||||
@@ -0,0 +1,398 @@
|
||||
# ACP Child Peak Old-Generation Measurement
|
||||
|
||||
Part 3b of #8091, first half. Closes the measurement prerequisite that #8182
|
||||
and `child-heap-policy.ts` both name as the blocker for enforcement.
|
||||
|
||||
## Context
|
||||
|
||||
The observation stack is complete and non-enforcing:
|
||||
|
||||
- #8245 resolved the budget denominator (`configuredBudgetMb`,
|
||||
`effectiveBudgetMb`, `childPoolMb`, `legacyChildCeilingMb`).
|
||||
- #8423 and #8462 added root pressure and an explicitly partial aggregate of
|
||||
active ACP child RSS.
|
||||
- #8508 added the fixed partition under `--child-heap-mode off | observe`,
|
||||
publishing `maxConcurrentChildren` and `perChildCeilingMb` without applying
|
||||
either.
|
||||
|
||||
`limits.memory.enforced` remains the required literal `false`. Every ACP child
|
||||
still receives `min(50% of host, 16 GB)` from `getAcpMemoryArgs()`, which is
|
||||
what #8182 reports.
|
||||
|
||||
The remaining blocker is not admission arithmetic. `child-heap-policy.ts`
|
||||
states the gap on `refusals`: a count of 0 says spawn _count_ stayed inside
|
||||
`maxConcurrentChildren`, not that any workload would survive
|
||||
`perChildCeilingMb`. Children run on the far larger host-derived ceiling while
|
||||
observing, so the partition has never been tested against a real workload.
|
||||
Enforcing on that signal switches a healthy daemon into an OOM loop.
|
||||
|
||||
This document designs the measurement that answers the question `refusals`
|
||||
cannot, and nothing else. It changes no spawn argument, refuses no work, and
|
||||
does not widen `enforced`.
|
||||
|
||||
## What the measurement must answer
|
||||
|
||||
One question: **would this child have survived `perChildCeilingMb`?**
|
||||
|
||||
Three candidate figures already exist and none of them answers it.
|
||||
|
||||
`children.rssBytes` is the wrong quantity. RSS covers Buffers, external and
|
||||
native allocations, the young generation, and mapped pages;
|
||||
`--max-old-space-size` bounds the old generation specifically. The two move
|
||||
independently.
|
||||
|
||||
`heapUsed` includes the new generation, so it charges scavenger-managed
|
||||
garbage against a limit that never sees it.
|
||||
|
||||
`refusals` counts admission pressure, as above.
|
||||
|
||||
## Evidence
|
||||
|
||||
Measured on Node v24.12.0, 48 GB host. The probe scripts live under
|
||||
git-ignored `.qwen/scripts/`, so each result below states the method it came
|
||||
from and is reproducible from that description alone.
|
||||
|
||||
### 1. `old_space` alone is not the bound, and reporting it would be dangerous
|
||||
|
||||
Method: allocate 2M-element arrays (~16 MiB each) in a loop under
|
||||
`--max-old-space-size=256`, sampling `getHeapSpaceStatistics()` each iteration,
|
||||
until the process dies.
|
||||
|
||||
```
|
||||
0 {"old_space":3.0,"large_object_space":0.3,"oldGenSumMB":4.6}
|
||||
5 {"old_space":3.5,"large_object_space":76.6,"oldGenSumMB":81.4}
|
||||
10 {"old_space":3.5,"large_object_space":122.4,"oldGenSumMB":127.2}
|
||||
15 {"old_space":3.0,"large_object_space":183.5,"oldGenSumMB":187.8}
|
||||
|
||||
[…] 80 ms: Mark-Compact (reduce) 263.1 (391.9) -> 263.1 (264.9) MB
|
||||
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
|
||||
```
|
||||
|
||||
The process died against a 256 MB ceiling while `old_space` read **3.0 MB**
|
||||
throughout. Every byte was in `large_object_space`, which is part of the old
|
||||
generation and counts against the flag.
|
||||
|
||||
This matters beyond pedantry. V8's large-object threshold measures at 128 KiB
|
||||
on this build — an array of 16,000 elements stays in `old_space` while one of
|
||||
32,000 lands in `large_object_space` — so transcripts, session journals, and
|
||||
replay rings are all comfortably over it. They are the daemon's most likely
|
||||
growth and the ones an `old_space`-only figure is blindest to. Such a
|
||||
measurement would report that every workload fits comfortably in 614 MB, right
|
||||
up to the instant enforcement killed it — reintroducing the failure #8508
|
||||
refused to ship, through the field choice instead of through the refusal count.
|
||||
|
||||
**The measured quantity is the old-generation total, not `old_space`.**
|
||||
|
||||
### 2. Peak _committed_ size is limit-dependent; the post-major-GC live set is not
|
||||
|
||||
Method: churn 120,000 small objects per round for six rounds, retaining a slice
|
||||
of each round so the live set genuinely grows, under two ceilings. These
|
||||
allocations are all small, so `old_space` is the space that moves and the
|
||||
figures below are `old_space` rather than the full old-generation sum —
|
||||
evidence 1 is what says the shipped measurement must sum.
|
||||
|
||||
| `--max-old-space-size` | peak committed `old_space` | peak post-major-GC `old_space` used |
|
||||
| ---------------------- | -------------------------- | ----------------------------------- |
|
||||
| 16384 | 125.0 MB | 32.2 MB |
|
||||
| 614 | 65.8 MB | 32.2 MB |
|
||||
|
||||
V8 grows the heap lazily and defers major GC in proportion to the limit it was
|
||||
given. A child observed under today's 16 GB ceiling therefore commits roughly
|
||||
twice what the same work needs under the modeled partition. Reading a peak of
|
||||
800 MB and concluding "does not fit in 614 MB" would be wrong.
|
||||
|
||||
The post-major-GC figure is **identical under both ceilings**, to the decimal.
|
||||
That is the one with a stable meaning: it is what the workload actually
|
||||
retains, independent of how much rope V8 was given.
|
||||
|
||||
So the two answer different halves and both ship:
|
||||
|
||||
- **live-set peak > ceiling → the child cannot survive it.** Its retained data
|
||||
does not fit, whatever V8 does about the garbage. A sound refusal, with one
|
||||
caveat recorded in the implementation: GC entries reach a
|
||||
`PerformanceObserver` asynchronously, so the used-size read happens at the
|
||||
first opportunity after the collection rather than at the instant it ends,
|
||||
and counts anything allocated in between. The figure is therefore an upper
|
||||
bound on the live set. The error runs upward, so it risks a false refusal
|
||||
rather than a missed one — the safe direction, but a consumer must not treat
|
||||
the number as exact.
|
||||
- **committed peak ≤ ceiling → the child almost certainly survives**, since
|
||||
that peak was measured with V8 at its laziest and a tighter limit collects
|
||||
earlier. Monotonicity of V8's growing heuristic in the limit is an
|
||||
assumption here, not a guarantee — which is why this side is used to admit
|
||||
and never to refuse.
|
||||
- Between them is the honest unknown: it would fit, at the cost of more major
|
||||
GCs. Which is why GC cost ships too, below.
|
||||
|
||||
### 3. Interval polling misses the peak entirely
|
||||
|
||||
Evidence 2's run also carried a 1-second sampler. It recorded a peak of
|
||||
**0 MB** while the GC-triggered path recorded 125 MB, because that workload
|
||||
finished inside a single tick. The anecdote does not prove a 1 s cadence always
|
||||
misses; it proves a peak can be wholly invisible to one, which is enough to
|
||||
disqualify cadence as the mechanism.
|
||||
|
||||
The daemon's existing child poll is worse than 1 s here in two independent
|
||||
ways. It runs at a 5 s cadence, and per `childRssCoverage` it is **gated on an
|
||||
active SSE/WS watcher** — with no client attached, `sampled` falls to 0 while
|
||||
children keep running. A peak sampled by that poll would describe only the
|
||||
intervals during which somebody happened to be watching. The gating alone
|
||||
settles it, independent of any cadence argument.
|
||||
|
||||
**The high-water mark accumulates inside the child. The poll only reads it.**
|
||||
|
||||
### 4. `heap_size_limit` is not the flag value
|
||||
|
||||
Method: read `getHeapStatistics().heap_size_limit` from a fresh process at each
|
||||
`--max-old-space-size` value.
|
||||
|
||||
| flag | `heap_size_limit` | delta |
|
||||
| ----- | ----------------- | ----- |
|
||||
| 256 | 448 MB | +192 |
|
||||
| 512 | 704 MB | +192 |
|
||||
| 614 | 806 MB | +192 |
|
||||
| 1024 | 1216 MB | +192 |
|
||||
| 4096 | 4288 MB | +192 |
|
||||
| 16384 | 16576 MB | +192 |
|
||||
|
||||
`heap_size_limit` adds the new-space allowance. Comparing a peak against it
|
||||
instead of against `perChildCeilingMb` would build in a fixed false margin.
|
||||
The offset is a V8 implementation detail, so it is neither hardcoded nor
|
||||
relied upon; it is recorded here only to justify comparing against the flag
|
||||
value.
|
||||
|
||||
It also means the existing guard in `getAcpMemoryArgs()` compares a
|
||||
max-old-space target against a `heap_size_limit`, two quantities that differ
|
||||
by this offset for the same setting. The guard therefore drops the flag in a
|
||||
narrow band where it would in fact have raised the ceiling. Out of scope here
|
||||
and not worth a behavioral change on its own; recorded so the enforcement PR,
|
||||
which must bypass this guard anyway, does not rediscover it.
|
||||
|
||||
## Design
|
||||
|
||||
### Where it runs
|
||||
|
||||
Inside the ACP child, gated on the existing exact-value daemon marker
|
||||
`QWEN_CODE_SERVE === '1'` (`config/acp-channel-fallback.ts`), which
|
||||
`spawnChannel.ts:451` already stamps. No new environment variable.
|
||||
|
||||
This keeps the interactive CLI, the IDE companion, direct-embed bridges, and
|
||||
standalone ACP untouched, as #8182 requires — by construction rather than by a
|
||||
second flag that could disagree with the first. The marker is not settable
|
||||
from workspace `.env` or `settings.env`, so a workspace cannot switch the
|
||||
probe on or off.
|
||||
|
||||
Channel workers carry the same marker
|
||||
(`channel-worker-supervisor.ts:386`), but the worker process does not serve the
|
||||
`qwen/status/workspace/resource` ext method, so installing the probe on the ACP
|
||||
agent's init path does not reach it. Worker heap stays unobserved, consistent
|
||||
with what `childRssCoverage` already documents, and Part 2b owns it.
|
||||
|
||||
The ACP children a worker spawns _do_ inherit the marker and are probed, which
|
||||
is correct — they are ACP children. Whether their readings reach the daemon's
|
||||
aggregate is decided by the `managedRuntimes` enumeration that #8462 already
|
||||
established; this change neither widens nor narrows that coverage.
|
||||
|
||||
### How the peak is captured
|
||||
|
||||
A `PerformanceObserver` on `'gc'` entries, plus an `unref()`ed low-frequency
|
||||
interval as a floor for long GC-free stretches.
|
||||
|
||||
They do not feed the same accumulators. The interval and every GC entry update
|
||||
the committed and total-heap marks; `peakLiveSetBytes` is updated **only** from
|
||||
major-GC entries, because a used-size read at any other moment includes
|
||||
uncollected garbage and would destroy the limit-independence that makes it the
|
||||
refusal figure.
|
||||
|
||||
The GC observer is the primary trigger because allocation growth is what
|
||||
_causes_ GC, so its entries are correlated with the peaks by construction in a
|
||||
way a fixed cadence is not (evidence 3).
|
||||
|
||||
One assumption here is worth naming rather than burying: the observer callback
|
||||
runs _after_ the collection, so it can only see the committed high-water if V8
|
||||
releases committed pages lazily rather than at the end of the GC that freed
|
||||
them. That is V8's documented behavior and matches evidence 2, where the
|
||||
committed peak survived to be read — but evidence 2 also sampled inline from
|
||||
the workload, so it does not isolate the observer. The PR pins the callback
|
||||
wiring with an injected observer: a delivered major-GC entry must move the
|
||||
live-set and GC figures and a minor entry must not, so a wrong `kind` check
|
||||
or a callback that never runs cannot stay green. The lazy-release assumption
|
||||
itself is accepted as V8's documented behavior; forcing a real major GC on
|
||||
demand needs flags the test runner does not guarantee. If it does not hold,
|
||||
the interval stops being a floor and becomes the primary trigger, at a
|
||||
cadence tight enough to matter.
|
||||
|
||||
Both sampling paths are best-effort. `getHeapSpaceStatistics()` is wrapped the
|
||||
way `workspaceResource` already wraps `memoryUsage()` and `cpuUsage()`: a throw
|
||||
in a restricted container leaves the accumulators at their last good values
|
||||
rather than failing the handler. A child whose every read throws reports no
|
||||
`heap` at all — the field stays absent until the first successful sample, so
|
||||
the absent-not-zeroed rule below holds at the child layer too, and an empty
|
||||
`unclassifiedSpaceNames` always means coverage was checked, never that nothing
|
||||
was measured.
|
||||
|
||||
### What is accumulated
|
||||
|
||||
Three high-water marks and two counters. None is ever reset: they describe the
|
||||
whole lifetime of the child process, which is the right scope for "did this
|
||||
child ever need more than the ceiling". A channel swap replaces the child, so
|
||||
lifetime scope and channel-generation scope coincide.
|
||||
|
||||
| field | source | meaning |
|
||||
| --------------------------- | -------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `peakOldGenerationBytes` | Σ old-gen `space_size` | committed high-water; the sound-admission figure |
|
||||
| `peakLiveSetBytes` | Σ old-gen `space_used_size`, sampled after major GC only | limit-independent retention; the sound-refusal figure |
|
||||
| `peakTotalHeapBytes` | `getHeapStatistics().total_heap_size` | name-free cross-check; see its limits below |
|
||||
| `majorGcCount`, `majorGcMs` | `'gc'` entries of major kind | the cost of a smaller ceiling, not just its safety |
|
||||
|
||||
`majorGcCount` and `majorGcMs` are not decoration. Lowering a ceiling trades
|
||||
memory for GC time, and evidence 2 already shows the count rising from 2 to 3
|
||||
on a trivial workload. Without them the enforcement PR could report that every
|
||||
child "fits" while making every child materially slower, and no field on the
|
||||
wire would say so.
|
||||
|
||||
### Old-generation space set, and admitting when it is wrong
|
||||
|
||||
The child holds **two** known sets, not one.
|
||||
|
||||
Summed as old generation (10): `old_space`, `large_object_space`, `code_space`,
|
||||
`code_large_object_space`, `trusted_space`, `trusted_large_object_space`,
|
||||
`shared_space`, `shared_large_object_space`, `shared_trusted_space`,
|
||||
`shared_trusted_large_object_space`.
|
||||
|
||||
Knowingly excluded (3): `new_space`, `new_large_object_space`,
|
||||
`read_only_space`.
|
||||
|
||||
Two sets rather than one because a single old-gen set with an "everything else
|
||||
is unknown" rule would report `new_space` and `read_only_space` as unclassified
|
||||
on every healthy child, and a field that is never empty carries no signal.
|
||||
|
||||
That the taxonomy really does move is not hypothetical. Node 22 reports 11
|
||||
spaces and Node 24 reports 13; `map_space` was removed and the `trusted_*`
|
||||
spaces were added. Both are supported here (Node >= 22), so two taxonomies are
|
||||
live in the field simultaneously. The sets above were checked against
|
||||
v22.19.0, v22.22.3, and v24.12.0 and leave nothing unclassified on any of
|
||||
them — which is also why the set must be built by verification rather than
|
||||
from memory, since an eight-member set omitting the two `shared_trusted_*`
|
||||
spaces classifies cleanly on Node 22 and silently under-reports on Node 24.
|
||||
|
||||
A name-based sum under-reports when a future V8 adds a space neither set knows,
|
||||
and under-reporting is the dangerous direction: it makes a child look like it
|
||||
fits.
|
||||
|
||||
So the child reports what it could not classify rather than hiding it:
|
||||
|
||||
- `unclassifiedSpaceNames: string[]` — names in neither set, empty on both
|
||||
taxonomies known today. This is the primary signal, and the only one that
|
||||
names the gap. A new V8 space lands here whether or not it belongs to the
|
||||
old generation, which is the conservative direction: the enforcement PR
|
||||
investigates rather than assuming.
|
||||
- `peakTotalHeapBytes` from `getHeapStatistics()`, which needs no space name at
|
||||
all. Its cross-check is deliberately weak and should not be oversold: it
|
||||
includes the new generation, so the gap between it and the old-gen sum is
|
||||
never zero and cannot by itself localise a missing space. It bounds how much
|
||||
the sum could be missing; `unclassifiedSpaceNames` says whether it is.
|
||||
|
||||
The enforcement PR must treat a non-empty `unclassifiedSpaceNames` as
|
||||
"coverage unknown" and decline to enforce on that child, the same way
|
||||
`childRssCoverage` and `children.sampled` already state partiality instead of
|
||||
implying a total.
|
||||
|
||||
### Transport
|
||||
|
||||
Extend the existing `qwen/status/workspace/resource` response — the child
|
||||
already answers it with `{ rssBytes, cpuPercent }` and the accumulators are
|
||||
read synchronously. No new ext method, no new poll, no new cadence.
|
||||
|
||||
The parent side (`bridge.ts` `refreshChildResource`) applies the same
|
||||
trust-boundary validation the existing fields get: `typeof === 'number'`
|
||||
**and** `Number.isFinite`, since `typeof NaN === 'number'`. Fields absent from
|
||||
an older child stay absent rather than being defaulted to 0 — a zero peak and
|
||||
an unreported peak are different claims, and the second must never read as
|
||||
"this child needs nothing".
|
||||
|
||||
### Status surface
|
||||
|
||||
Under `runtime.memory.children`, beside the existing `rssBytes` / `sampled` /
|
||||
`oldestReadingAgeMs`.
|
||||
|
||||
Every figure is a **maximum across sampled children, not a sum** — including
|
||||
the GC counters. The ceiling is per child, and the peaks were reached at
|
||||
different times, so a sum answers a question nobody asked. For the counters the
|
||||
argument is the same in a different direction: summed GC counts measure
|
||||
daemon-wide GC activity. Each field is an independent maximum, not a portrait
|
||||
of one child — the committed peak and the live peak may come from different
|
||||
children, and a per-child ceiling is judged against each axis on its own.
|
||||
|
||||
`null`, never `0`, when no child reported — matching `oldestReadingAgeMs`,
|
||||
which already distinguishes these. A daemon with children that predate the
|
||||
fields, or with the sampler closed because no client is watching, must not
|
||||
publish a `0` peak that reads as "no child needs any heap". This is the same
|
||||
distinction the transport section makes for an absent field, carried one layer
|
||||
up so it cannot be lost in aggregation.
|
||||
|
||||
`limits.memory.enforced` stays the required literal `false`. `ChildHeapMode`
|
||||
stays `off | observe`. Nothing in this change makes `enforce` representable.
|
||||
|
||||
## Compatibility
|
||||
|
||||
No child spawn argument changes, so child GC and OOM behavior are byte-for-byte
|
||||
what they are today. This is the property that keeps the change reporting-only
|
||||
in the sense #8182 demands — the issue is explicit that anything applied to
|
||||
`--max-old-space-size` is a compatibility change _even without refusals_.
|
||||
|
||||
Additive status fields only. Non-daemon spawn paths are untouched.
|
||||
|
||||
The one real cost is the probe itself: a GC observer and an unref'ed interval
|
||||
in every daemon-spawned ACP child. Bounded by the marker gate above, and
|
||||
measured in the verification plan below rather than assumed negligible.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No `enforce` mode, no derived ceiling, no spawn refusal.
|
||||
- No channel-worker or MCP-descendant heap coverage — Part 2b.
|
||||
- No change to `MAX_DAEMON_WORKSPACES`, session caps, or the budget resolver.
|
||||
- No fix to the `getAcpMemoryArgs()` guard offset in evidence 4.
|
||||
|
||||
## Exit criteria for the enforcement PR
|
||||
|
||||
Stated here because "we will have data" is not a criterion.
|
||||
|
||||
1. For representative workloads — a long session with a large transcript, a
|
||||
wide repo scan, a multi-MCP configuration — `peakLiveSetBytes` sits below
|
||||
`perChildCeilingMb` on both an 8 GB and a 32 GB modeled partition, with
|
||||
`unclassifiedSpaceNames` empty on every supported Node major.
|
||||
2. The major-GC cost delta between the legacy ceiling and the modeled ceiling
|
||||
is quantified for those workloads, not merely observed to be non-fatal.
|
||||
3. Enforcement ships opt-in. The default stays `observe` until an operator can
|
||||
read their own daemon's figures and decide, which is the entire purpose of
|
||||
this change.
|
||||
|
||||
If criterion 1 fails, the answer is a different partition, not a louder
|
||||
warning.
|
||||
|
||||
## Verification plan
|
||||
|
||||
Unit, collocated:
|
||||
|
||||
- `child-heap-probe.test.ts` — accumulators are monotonic; a throwing
|
||||
`getHeapSpaceStatistics` preserves the last good values; an injected unknown
|
||||
space name lands in `unclassifiedSpaceNames`; major and minor GC entries are
|
||||
distinguished. One case must run against the **real**
|
||||
`getHeapSpaceStatistics()` of the Node executing the suite and assert
|
||||
`unclassifiedSpaceNames` is empty: a fixture can only contain space names
|
||||
somebody already thought of, so only the live call catches a taxonomy this
|
||||
repo supports and the sets do not.
|
||||
- `acpAgent.test.ts` — the `workspaceResource` handler returns the new fields
|
||||
under the daemon marker and omits them without it.
|
||||
- `bridge.test.ts` — `NaN`, `Infinity`, missing, and wrong-typed fields are
|
||||
rejected at the boundary; absent stays absent.
|
||||
- `daemon-status.test.ts` — maximum-not-sum across children; partial coverage
|
||||
reported as partial; `enforced` still the literal `false`.
|
||||
|
||||
Integration: a real daemon E2E asserting the fields appear for a live ACP
|
||||
child and that spawn arguments are unchanged — the second assertion is the one
|
||||
that keeps this PR honest.
|
||||
|
||||
Cost: measure daemon-child startup and a scripted prompt turn with the probe
|
||||
on and off, and record the delta in the PR rather than claiming it is free.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Bilingual autofix failure-path handoff comments
|
||||
|
||||
Date: 2026-08-18
|
||||
Status: draft — awaiting maintainer sign-off
|
||||
|
||||
## Motivation
|
||||
|
||||
The autofix loop's failure-path handoff comments (e.g. the round-6
|
||||
growth-brake escalation on PR #9262, comment 5321766307) are English-only.
|
||||
Every other static comment the workflow posts — takeover acks, re-arm,
|
||||
dispatch refusal, milestone, base-updated, review-deferred — is already
|
||||
bilingual (English first, Chinese in a collapsed
|
||||
`<details><summary>中文说明</summary>` block). The handoff comments are the
|
||||
ones that most need Chinese: they stop the loop and ask a maintainer to make
|
||||
a decision (split / redesign / accept-residuals), and a Chinese-speaking
|
||||
maintainer should be able to act on them without translating a wall of
|
||||
English first.
|
||||
|
||||
## How the comment is assembled today
|
||||
|
||||
`qwen-autofix.yml`, failure path of the `Address review feedback` job
|
||||
(~L6660–7050), builds `<workdir>/report.md` and posts it with `gh pr comment`:
|
||||
|
||||
1. `HEADLINE` — one of ~9 bash-generated English templates:
|
||||
- API-error / timeout / crash / gate-crash, retry and terminal forms
|
||||
(CAUSE × 4, LAST_FIX × 5, composed into two sentence frames);
|
||||
- stale-base auto-update retry;
|
||||
- needs-a-human handoff ("Could not produce a passing fix…") with
|
||||
GATE_CLAUSE × 5 (none / gate rejected / pre-existing × 3 compare
|
||||
states);
|
||||
- could-not-start (setup failure retry; round cap);
|
||||
- terminal crash before reading feedback;
|
||||
- consecutive-failure breaker;
|
||||
- cumulative-timeout breaker (IDLE_CLAUSE and REMEDY variants).
|
||||
2. Optional excerpt: `**What I found before stopping:**` (or the NOT-pushed
|
||||
warning) + the first 1500 bytes of `DETAIL_FILE` — the first non-empty of
|
||||
`failure.md`, `handoff.md`, `address-summary.md`, `no-action.md` —
|
||||
passed through `iconv -c` and `<!--` escaping.
|
||||
3. Optional gate-rejection section: `**Why it was not pushed:**` + up to
|
||||
3900 bytes of `gate-rejection.md` (written by `reject_fix()` in
|
||||
`.github/scripts/run-autofix-review-verification.sh`; static reason
|
||||
sentence + untranslatable log evidence), fenced by
|
||||
`<!-- autofix-gate-rejection-start/end -->`.
|
||||
4. Footer: Run log link, `🧠 Handled by Qwen Code` signature, and the
|
||||
`autofix-eval` / `autofix-growth-now` / `autofix-redcheck` markers. The
|
||||
next scan parses these markers out of the raw comment body.
|
||||
|
||||
Agent-written `failure.md` is the content of the decision-relevant part
|
||||
(options, recommendation). SKILL.md currently mandates `failure.md` and
|
||||
`handoff.md` stay English-only WITHOUT a details block, because the comment
|
||||
embeds a byte-truncated excerpt: a severed `<details>` tag would swallow the
|
||||
rest of the rendered comment.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. New agent output: `failure.zh.md`
|
||||
|
||||
Whenever the agent writes `<workdir>/failure.md`, it must also write
|
||||
`<workdir>/failure.zh.md` — a complete paragraph-by-paragraph Chinese
|
||||
translation. Constraints encoded in SKILL.md's GitHub Actions Rules:
|
||||
|
||||
- plain Markdown; no HTML tags at all (no `<details>`, no `<summary>`);
|
||||
- no `<!--` sequences;
|
||||
- the workflow, not the agent, owns the surrounding `<details>` wrapper.
|
||||
|
||||
`failure.md` itself stays English-only (excerpt-safety unchanged).
|
||||
`handoff.md` remains run-agent.mjs-owned and English-only.
|
||||
|
||||
### 2. Parallel Chinese headline variables
|
||||
|
||||
Every `HEADLINE=` assignment gains a `HEADLINE_ZH=` sibling (~23 static
|
||||
strings total, including the CAUSE / LAST_FIX / GATE_CLAUSE clause
|
||||
variants). Same composition shape as the English side, e.g. the needs-human
|
||||
path:
|
||||
|
||||
```
|
||||
HEADLINE_ZH="🤖 未能为该反馈产生通过验证的修复(第 ${MARK_ROUND}/${MAX_ROUNDS} 轮)${GATE_CLAUSE_ZH}。此项现在需要人工处理;循环保持在线,仍会拾取新反馈与 base 冲突,但不会自行重试此项。"
|
||||
```
|
||||
|
||||
Precedent for parallel `_ZH` variables already exists in the workflow
|
||||
(`WIN_DESC_ZH` in the milestone comment).
|
||||
|
||||
Section labels inside the report block also gain Chinese handling inside
|
||||
the details block: the two branch-selected labels that open the excerpt
|
||||
(`**What I found before stopping:**`, the NOT-pushed warning) are
|
||||
translated. `**Why it was not pushed:**` and the stale-base note are NOT —
|
||||
they open the gate section whose body stays English, and a pointer sentence
|
||||
inside the details block tells the zh reader the evidence is above. The
|
||||
no-detail fallback sentence has no counterpart by construction: when
|
||||
`DETAIL_FILE` is empty `failure.zh.md` cannot exist (it is only written
|
||||
alongside `failure.md`, itself a `DETAIL_FILE` candidate), so the details
|
||||
block degrades to the headline translation alone, which already carries
|
||||
that state (see §5).
|
||||
|
||||
### 3. Report layout
|
||||
|
||||
English body unchanged. Immediately before the `Run log:` line, insert:
|
||||
|
||||
```markdown
|
||||
<details>
|
||||
<summary>中文说明</summary>
|
||||
|
||||
${HEADLINE_ZH}
|
||||
|
||||
**停止前我了解到的情况:**
|
||||
[excerpt of failure.zh.md — only when the file exists]
|
||||
|
||||
[one line: gate-rejection evidence stays English, see above — only when
|
||||
gate-rejection.md is present]
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
Markers stay last and ASCII-untouched, so the next scan's parsing is
|
||||
unaffected. The wrapper tags are emitted by the workflow, so a truncated
|
||||
translation can lose content but can never swallow the comment tail.
|
||||
|
||||
### 4. Truncation and sanitization of the Chinese excerpt
|
||||
|
||||
`head -c 3000` (Chinese is ~3 bytes/char, so ≈1000 chars — roughly the same
|
||||
information as the 1500-byte English excerpt), then `iconv -f utf-8 -t
|
||||
utf-8 -c`, then sed escaping: `<!--` (as today) plus `<details`,
|
||||
`</details`, `<summary` → full-width `<` forms, so a pathological
|
||||
translation quoting markup cannot break the wrapper. `<workdir>` style
|
||||
angle-bracket prose stays intact.
|
||||
|
||||
Budget knobs live next to the existing 1500/3900 constants with a comment.
|
||||
|
||||
### 5. Degradation
|
||||
|
||||
- `failure.zh.md` missing (run-agent.mjs wrote `failure.md` itself on crash
|
||||
/ loop-guard / missing-output paths; or the agent skipped it) → the
|
||||
details block still renders `HEADLINE_ZH` alone. Never fail the round
|
||||
over a missing translation.
|
||||
- `DETAIL_FILE` = `address-summary.md` / `no-action.md` (already bilingual
|
||||
by SKILL contract) → those files have no `failure.zh.md` sibling;
|
||||
headline-only details block is correct for them too. (Their mandated
|
||||
`<details>` tail can still be severed by the 1500-byte cut — the excerpt
|
||||
site neutralizes the tag forms so the severed opener is inert.)
|
||||
- A byte-truncated excerpt can sever a fenced code block: the 1500/3000-byte
|
||||
cut lands mid-line, so a fence opened before the cut and closed after it
|
||||
renders the excerpt as an unterminated code block and everything appended
|
||||
after it (the 中文说明 wrapper) as literal monospace text inside it.
|
||||
ACCEPTED without fence balancing: render-only (the loop markers parse from
|
||||
the raw body), the full uncut report still reaches the run-log dumps and
|
||||
step summary, and a balancing heuristic stays wrong when the cut lands
|
||||
mid-closer. The class covers every byte-truncated excerpt site — the
|
||||
English failure.md / DETAIL_FILE excerpts and both zh excerpts.
|
||||
- Issue-lane withdraw comment: the 中文说明 block renders UNCONDITIONALLY
|
||||
with a translated `REASON` (`REASON_ZH` sibling per branch), mirroring
|
||||
the PR-lane headline floor; the sanitized failure.zh.md excerpt joins the
|
||||
block only when present. Crash shapes (run-agent.mjs wrote failure.md
|
||||
itself, no companion) therefore still show the Chinese withdraw sentence.
|
||||
|
||||
### 6. SKILL.md changes
|
||||
|
||||
In GitHub Actions Rules, extend the bilingual-outputs rule: keep the
|
||||
English-only requirement for `failure.md`/`handoff.md`, add the
|
||||
`failure.zh.md` companion requirement with the constraints above. The
|
||||
growth-brake handoff directive (workflow feedback.md text and SKILL's
|
||||
defer-to-human item) relies on the general rule — no per-mode repetition.
|
||||
|
||||
### 7. Contract tests (`scripts/tests/qwen-autofix-workflow.test.js`)
|
||||
|
||||
Extend the existing handoff-comment describe block (~L13086):
|
||||
|
||||
- needs-human report contains the `中文说明` details block and the Chinese
|
||||
headline;
|
||||
- every HEADLINE assignment in the workflow has a sibling HEADLINE_ZH
|
||||
(static structural pin, same style as the existing headline pins);
|
||||
- SKILL.md pin: `failure.zh.md` companion rule present; `failure.md`
|
||||
remains English-only.
|
||||
|
||||
## Out of scope (follow-ups)
|
||||
|
||||
- `gate-rejection.md` body translation — evidence is log output; the
|
||||
translated GATE_CLAUSE already tells a Chinese reader the gate rejected
|
||||
the attempt. (Maintainer default; veto before sign-off if wanted.)
|
||||
- `qwen-fleet-shepherd.yml` escalation comments — separate workflow.
|
||||
- run-agent.mjs's own `handoff.md` preface lines (rare fallback path).
|
||||
|
||||
## Risks
|
||||
|
||||
- ~23 new static Chinese strings in a 7k-line workflow: review burden is
|
||||
real but mechanical; contract tests pin presence, not prose quality.
|
||||
- Agent may skip `failure.zh.md` under budget pressure → degraded but
|
||||
functional (headline-only details). No round outcome changes.
|
||||
- Comment size grows by ≤ ~3.5 KB; far under GitHub's 65 KB limit.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Daemon HTTP inbound trace context
|
||||
|
||||
## Motivation
|
||||
|
||||
The daemon already _propagates_ trace context outbound: prompt requests carry a
|
||||
`traceparent` inside JSON-RPC `_meta`, and the daemon extracts it to parent its
|
||||
bridge spans (`extractDaemonTraceContext`). The HTTP surface, however, only
|
||||
_records_ request spans — every `qwen-code.daemon.request` span starts a new
|
||||
trace. An HTTP caller that forwards the standard W3C `traceparent` header
|
||||
(corporate proxies, OTel-instrumented clients, ACP gateways) gets no linkage:
|
||||
the server-side span cannot be joined back to the caller's trace, so
|
||||
cross-service debugging falls back to timestamps.
|
||||
|
||||
W3C Trace Context extraction at the HTTP server edge is standard
|
||||
`SpanKind.SERVER`-adjacent behavior per the OTel HTTP semantic conventions, and
|
||||
the plumbing already exists: `withDaemonSpan` accepts an explicit
|
||||
`parentContext`.
|
||||
|
||||
## Design
|
||||
|
||||
1. **Core** (`daemon-tracing.ts`): the existing `_meta` extraction logic
|
||||
(global `propagation.extract` first, then a direct
|
||||
`W3CTraceContextPropagator` instance as fallback, so acceptance rules —
|
||||
future traceparent versions, `tracestate`, all-zero ids — are identical
|
||||
with and without a registered global propagator) moves into a shared
|
||||
`contextFromTraceparentValues` helper. A new
|
||||
`extractDaemonHttpTraceContext(headers)` reads `traceparent`/`tracestate`
|
||||
from a Node-style (lowercased) header object and reuses that helper.
|
||||
`DaemonRequestSpanOptions` gains an optional `parentContext` passed straight
|
||||
through to `withDaemonSpan`.
|
||||
2. **Serve middleware**: `daemonTelemetryMiddleware` extracts from
|
||||
`req.headers` per request (fail-closed to `undefined`, telemetry never
|
||||
affects handling) and passes the context only when extraction succeeded —
|
||||
requests without a valid header keep the exact current span shape.
|
||||
Span-context extraction is gated on `isTelemetrySdkInitialized()`, so
|
||||
telemetry-off deployments pay no OTel machinery on the hot path — only the
|
||||
single-regex trace-id capture described under Log correlation — and a
|
||||
present-but-invalid header emits a debug daemon log
|
||||
(`qwen-code.daemon.traceparent.invalid`) so a rejected header is
|
||||
diagnosable from daemon logs alone.
|
||||
|
||||
Note the whole subtree relocates with the request span, not just
|
||||
`daemon.request` itself: session-subprocess spans reached via `_meta`
|
||||
(prompt / model / tool) also join the caller's trace, so anything
|
||||
aggregating or alerting by `traceId` sees session-side spans change
|
||||
ownership too.
|
||||
|
||||
## Log correlation
|
||||
|
||||
Daemon log lines already carry a `[trace_id=… span_id=…]` prefix when written
|
||||
inside an active recording span (`getActiveTraceContext`, #9084). Telemetry
|
||||
on, this PR closes the loop end to end: the request span parents to the
|
||||
caller's trace, so every daemon log line for that request — including the
|
||||
access log's `request completed` — is prefixed with the caller's trace id.
|
||||
(The access log's `finish` listener reads the active span through the same
|
||||
AsyncLocalStorage propagation as the logger, so ordering of the two `finish`
|
||||
listeners is irrelevant for the prefix.)
|
||||
|
||||
Telemetry off — the default — there is no span, so the prefix never fires.
|
||||
A separate lightweight path keeps the log-based join alive with no telemetry
|
||||
config and no trace backend: the middleware parses the `traceparent` header
|
||||
with a plain regex (`extractInboundTraceId`, acceptance mirroring the vendored
|
||||
W3C propagator — same shape/all-zero/`ff` rejections, single optional
|
||||
leading/trailing whitespace, trailing extension fields above version `00` —
|
||||
so a header either joins on both paths or neither; `tracestate` stays in the
|
||||
propagator path since a log line only needs a plausible trace id) and stores
|
||||
the trace id on the per-response telemetry context (the same
|
||||
symbol the workspace hash uses). The access log's `finish` callback reads it
|
||||
back and emits it as the camelCase `traceId` field of `request completed` —
|
||||
distinct from the logger's reserved snake_case `trace_id` prefix keys, so a
|
||||
caller cannot spoof the span-derived prefix. The camelCase field is captured
|
||||
in both telemetry modes whenever a valid header parses, so one log query
|
||||
shape works for every deployment; with telemetry on the snake_case span
|
||||
prefix carries the same id redundantly. The field is omitted, not
|
||||
empty, when no valid header is present, and every step stays fail-closed
|
||||
(telemetry must not affect request handling).
|
||||
|
||||
## Sampling policy
|
||||
|
||||
The caller's `sampled` bit is not adopted verbatim on the HTTP path. Under
|
||||
the default `parentbased_always_on` sampler (the daemon SDK configures no
|
||||
sampler), a remote unsampled parent delegates to `AlwaysOffSampler` and would
|
||||
silently delete the request span, everything under `next()`, and — via the
|
||||
`_meta` forwarding — the session-subprocess spans. `sampled=0` is simply the
|
||||
caller's head-based ratio sampling, so inbound HTTP parents force
|
||||
`TraceFlags.SAMPLED` through the same `shouldForceSampled()` decision matrix
|
||||
as the synthetic session root: `parentbased_*` defaults and `always_on` force
|
||||
sampling; `parentbased_always_off` honors the operator's opt-out;
|
||||
non-parentbased samplers (e.g. `traceidratio`) keep the caller's flags and
|
||||
decide per span. The `_meta` path applies the same forcing: for the
|
||||
in-process bridge (daemon → subprocess) it is a no-op — that parent is our
|
||||
own span, already SAMPLED under this policy — while direct ACP clients can
|
||||
also attach `_meta` with a caller-controlled `sampled=0`, which is external
|
||||
input exactly like the HTTP header and gets the same protection.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new span kinds or attributes: existing `qwen-code.daemon.request` spans
|
||||
stay `SpanKind.INTERNAL` with the same attributes; only the parent link
|
||||
changes when a valid header is present.
|
||||
- No `traceparent` _response_ injection and no W3C `tracingresponse` support.
|
||||
- No new sampling configuration surface: the inbound policy reuses the
|
||||
existing `shouldForceSampled()` matrix (see above); the SDK's own sampler
|
||||
remains the only sampler authority.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Changing the request span to `SpanKind.SERVER` per HTTP semconv: **known
|
||||
gap** — `qwen-code.daemon.request` is the daemon's only SERVER-adjacent
|
||||
span (`HttpInstrumentation` never patches the server side here because the
|
||||
SDK loads lazily), so backends deriving service topology / RED metrics
|
||||
from SERVER spans (Tempo service-graph, ARMS) will not recognize the
|
||||
daemon as a service inside the caller's trace. Switching would mutate the
|
||||
shape of every existing daemon.request span and can shift backend
|
||||
grouping; deferred as a follow-up.
|
||||
- Extracting inside the core `withDaemonRequestSpan` from a raw header bag:
|
||||
rejected because core request-span options are transport-primitive today;
|
||||
the middleware is the only place that knows the carrier is HTTP headers.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: header extraction (valid / absent / malformed / all-zero ids / array
|
||||
value / version `ff` / version `00` with extension field / future version
|
||||
`01` / inbound `tracestate`), request-span parenting through
|
||||
`withDaemonRequestSpan`, the sampled-flag decision matrix (default forced,
|
||||
`parentbased_always_off` and `traceidratio` verbatim, `_meta` path forced
|
||||
under the default sampler and verbatim on opt-out), middleware pass-through
|
||||
(present vs omitted key, telemetry-off
|
||||
skip, rejected-header debug log), and a type-level guard keeping
|
||||
`parentContext` on `DaemonRequestSpanOptions`.
|
||||
- Unit (telemetry-off log join): `extractInboundTraceId` (valid / absent /
|
||||
malformed / all-zero ids / version `ff` / array value / future version,
|
||||
plus no-propagator-needed in the fresh-module state), middleware capture
|
||||
(telemetry off with and without a header, telemetry on emitting the
|
||||
camelCase field alongside the span prefix),
|
||||
handler-resolved context initialization not clobbering the stored id), and
|
||||
the access log emitting / omitting the `traceId` field.
|
||||
- Dry run: `serve` with `QWEN_TELEMETRY_OUTFILE`, one curl with a fixed
|
||||
`traceparent` — exported span must share the header's traceId and parent to
|
||||
its spanId; a control request without the header must stay on its own trace.
|
||||
With telemetry disabled, the same curl must still log
|
||||
`request completed` with `traceId` matching the header.
|
||||
@@ -0,0 +1,695 @@
|
||||
# Workspace Session Live-state Activity Timestamp
|
||||
|
||||
## Status
|
||||
|
||||
Proposed follow-up to the workspace session live-state protocol. This document
|
||||
defines the server and TypeScript SDK contract only. Web Shell consumption is a
|
||||
separate implementation change.
|
||||
|
||||
## Summary
|
||||
|
||||
Add an optional `updatedAt` field to each live session returned by
|
||||
`GET /workspaces/:workspace/sessions/live-state`. The value is a strictly
|
||||
monotonic, bridge-local activity timestamp advanced when a prompt that reached
|
||||
the running state publishes its formal terminal outcome.
|
||||
|
||||
The field lets clients update the recency of catalog rows already in memory
|
||||
without reloading the persisted session catalog after each completed turn. It
|
||||
does not change the catalog version, prove transcript durability, or make the
|
||||
live-state route perform storage or ACP reads.
|
||||
|
||||
The response remains schema version `v: 1`, and the existing
|
||||
`workspace_session_live_state` capability remains the only endpoint capability.
|
||||
The field is wire-optional so old clients ignore it and new clients can retain
|
||||
their current catalog-refresh fallback when it is absent.
|
||||
|
||||
## Relationship to the Existing Protocol
|
||||
|
||||
The original [workspace session live-state protocol](./2026-08-16-workspace-session-live-state.md)
|
||||
separates two concerns:
|
||||
|
||||
1. Volatile live-session status is returned by a cheap memory-only endpoint.
|
||||
2. Catalog changes advance the `generation + revision` version.
|
||||
|
||||
Ordinary turn completion intentionally does not advance the catalog revision.
|
||||
The first Web Shell consumer therefore retained a rate-limited full catalog
|
||||
reconciliation after a turn completes so persisted `updatedAt` values and
|
||||
session ordering do not remain frozen.
|
||||
|
||||
That fallback is bounded, but it still reconnects an ordinary turn lifecycle
|
||||
event to the expensive operation the live-state endpoint was introduced to
|
||||
avoid. A live activity timestamp closes that remaining gap without widening the
|
||||
catalog clock.
|
||||
|
||||
## Current Behavior and Evidence
|
||||
|
||||
The current implementation already contains most of the semantic path needed
|
||||
for this extension:
|
||||
|
||||
- `BridgeSessionSummary` and `DaemonSessionSummary` already define
|
||||
`updatedAt?: string`.
|
||||
- Persisted catalog summaries derive `updatedAt` from the transcript JSONL file
|
||||
modification time.
|
||||
- The live/persisted merge helper already treats a live summary's `updatedAt` as
|
||||
the fresher value and all activity-based catalog comparators read
|
||||
`updatedAt ?? createdAt`.
|
||||
- The bridge funnels successful, failed, cancelled, deadline, teardown, and
|
||||
transport terminal paths through `publishPromptTerminal`.
|
||||
- `publishPromptTerminal` has a per-prompt `terminalPublished` latch and
|
||||
distinguishes a running prompt from a prompt that never left the queue.
|
||||
- The Web Shell live-state consumer polls every two seconds but currently asks
|
||||
for one rate-limited catalog reconciliation after turn completion because the
|
||||
live overlay has no activity timestamp.
|
||||
|
||||
The missing piece is an activity timestamp on `SessionEntry` and its projection
|
||||
through the existing summary and live-state surfaces.
|
||||
|
||||
## Goals
|
||||
|
||||
- Let a client refresh the recency of an already-loaded live session using only
|
||||
the memory-only live-state response.
|
||||
- Establish a causal guarantee: after a client observes the formal terminal for
|
||||
a prompt that reached the running state, a subsequently started live-state
|
||||
request that still returns the same entry in the same bridge generation sees
|
||||
the terminal's activity timestamp.
|
||||
- Advance once for every prompt that reached the running state and published its
|
||||
first formal terminal, including success, error, cancellation, and deadline.
|
||||
- Keep the value strictly increasing for a live session even when multiple
|
||||
terminals occur in one wall-clock millisecond.
|
||||
- Preserve the existing catalog-version boundary: turn activity changes the
|
||||
live overlay, not `generation + revision`.
|
||||
- Preserve route ownership, trust, cache, status code, timeout, and
|
||||
memory-only behavior.
|
||||
- Remain compatible in every old/new server and client combination.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Proving that every transcript record has reached durable storage.
|
||||
- Returning the exact JSONL file modification time from the live-state route.
|
||||
- Reading or statting session files in the live-state route.
|
||||
- Seeding the activity timestamp by scanning persisted state during session
|
||||
creation or restore.
|
||||
- Advancing catalog revision for prompt admission, streaming, or completion.
|
||||
- Adding a new capability, readiness feature, schema version, query parameter,
|
||||
ETag, event stream, or feature gate.
|
||||
- Adding static metadata such as source, title, organization, branch, or
|
||||
worktree information to live-state.
|
||||
- Guaranteeing that an activity-updated session missing from a client's current
|
||||
catalog page can be inserted without a full catalog reconciliation.
|
||||
- Changing the first catalog load, persisted scan implementation, or existing
|
||||
session-list deadline.
|
||||
- Implementing the Web Shell consumer in the server protocol PR.
|
||||
|
||||
## Terminology and Authority
|
||||
|
||||
`updatedAt` on the live-state item is a **daemon-observed live activity
|
||||
watermark**. It is not a persistence acknowledgement.
|
||||
|
||||
The authoritative transition is the first formal terminal published for a
|
||||
prompt whose pending entry is in the `running` state. This point is preferable
|
||||
to adjacent signals:
|
||||
|
||||
- Prompt admission is too early; the request may remain queued and never run.
|
||||
- Prompt start would move the row before the turn has settled and would not
|
||||
replace the current completion-driven behavior.
|
||||
- Individual streamed updates are too frequent and do not form a stable
|
||||
ordering boundary.
|
||||
- `sessionLastSeenAt` is a liveness watermark also advanced by client
|
||||
heartbeats, so exposing it would continually reorder an idle but connected
|
||||
session.
|
||||
- JSONL mtime is the persisted authority but requires filesystem work and is not
|
||||
synchronously available at the daemon terminal boundary.
|
||||
|
||||
The ACP child enqueues its turn-result record before its prompt call settles,
|
||||
but the recording service uses a serialized asynchronous writer. The daemon can
|
||||
therefore know that a running turn reached its formal terminal without claiming
|
||||
that the final queued record has been flushed. The protocol names that fact
|
||||
explicitly rather than turning a best-effort activity signal into a false
|
||||
durability guarantee.
|
||||
|
||||
## Public REST Contract
|
||||
|
||||
### Request
|
||||
|
||||
The request is unchanged:
|
||||
|
||||
```http
|
||||
GET /workspaces/:workspace/sessions/live-state
|
||||
```
|
||||
|
||||
It retains the existing selected-runtime, workspace-scoped, trusted-only
|
||||
ownership and has no query parameters.
|
||||
|
||||
### Success response
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"catalogVersion": {
|
||||
"generation": "7eca3164-bce1-4f50-94d8-c842c480f213",
|
||||
"revision": 17
|
||||
},
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": "session-123",
|
||||
"clientCount": 1,
|
||||
"hasActivePrompt": false,
|
||||
"isWaitingForPermission": false,
|
||||
"isWaitingForUserQuestion": false,
|
||||
"updatedAt": "2026-08-18T08:12:30.123Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`Cache-Control: no-store` remains required.
|
||||
|
||||
### Field contract
|
||||
|
||||
```ts
|
||||
export interface DaemonSessionLiveState {
|
||||
sessionId: string;
|
||||
clientCount: number;
|
||||
hasActivePrompt: boolean;
|
||||
isWaitingForPermission: boolean;
|
||||
isWaitingForUserQuestion: boolean;
|
||||
updatedAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
When present, `updatedAt`:
|
||||
|
||||
- is a valid ISO 8601 timestamp;
|
||||
- identifies the newest running-prompt terminal observed by the current bridge
|
||||
for that session;
|
||||
- is strictly increasing within the lifetime of that live `SessionEntry`;
|
||||
- is written before the corresponding formal terminal event becomes visible;
|
||||
- may be absent before the first running turn settles in the current bridge
|
||||
generation;
|
||||
- may be lost when a daemon or workspace runtime replaces the bridge; and
|
||||
- must not be interpreted as proof that a transcript flush succeeded.
|
||||
|
||||
The response remains `v: 1`. Adding an optional object property is wire-additive
|
||||
and does not change how existing fields are decoded. Absence is not a reliable
|
||||
global support probe because a supporting daemon legitimately omits the field
|
||||
for a live entry with no observed terminal in the current generation.
|
||||
|
||||
## Transition Matrix
|
||||
|
||||
| Event | `updatedAt` behavior | Catalog version behavior |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------- |
|
||||
| Running prompt completes successfully | Advance once | No change |
|
||||
| Running prompt returns a structured or transport error | Advance once | No change |
|
||||
| Running prompt is cancelled | Advance once | No change |
|
||||
| Running prompt reaches the daemon deadline | Advance once | No change |
|
||||
| Running prompt is flushed during close, kill, crash, or shutdown | Advance once if that prompt's terminal latch has not already been won | Membership removal advances separately |
|
||||
| Queued prompt is removed, cancelled, or expires before dispatch | No change | No change |
|
||||
| A late result attempts to publish after an earlier deadline terminal | No change; duplicate latch wins | No change |
|
||||
| Prompt admission, queue wait, start, or streamed update | No change | No change |
|
||||
| Attach, detach, heartbeat, permission wait, or user-question wait | No change | No change |
|
||||
| Live session registration or restore before a new terminal | Field absent | Membership revision advances |
|
||||
| Bridge or runtime replacement | Field starts absent | New generation |
|
||||
|
||||
An error or deadline may advance live activity even when transcript persistence
|
||||
is degraded or the child did not finish normally. This is deliberate: the
|
||||
field represents the daemon-observed running attempt and formal terminal, not a
|
||||
durability outcome.
|
||||
|
||||
## Monotonicity and Causal Ordering
|
||||
|
||||
`SessionEntry` stores the watermark as epoch milliseconds rather than a string:
|
||||
|
||||
```ts
|
||||
lastTurnEndedAtMs?: number;
|
||||
```
|
||||
|
||||
The first accepted running terminal advances it with:
|
||||
|
||||
```ts
|
||||
const createdAtMs = Date.parse(entry.createdAt);
|
||||
const floor = Number.isFinite(createdAtMs)
|
||||
? createdAtMs
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const next = Math.max(Date.now(), (entry.lastTurnEndedAtMs ?? floor) + 1);
|
||||
entry.lastTurnEndedAtMs = next;
|
||||
```
|
||||
|
||||
The exact implementation may avoid `Number.NEGATIVE_INFINITY` for readability,
|
||||
but must preserve the same behavior. The additional millisecond is a logical
|
||||
tie-breaker when wall time has not advanced; it is not a duration measurement.
|
||||
The `createdAt` floor on the first advance matters because rows without a
|
||||
watermark are keyed by `createdAt` and the live-only cursor carries no
|
||||
emitted-identity list: a first watermark behind `createdAt` — a wall-clock
|
||||
rollback between creation and the first terminal — would move an
|
||||
already-emitted row's key backward mid-pass and let the strictly-older filter
|
||||
return it twice.
|
||||
|
||||
`publishPromptTerminal` must determine whether the pending prompt was running,
|
||||
pass the duplicate latch, advance the watermark, remember the terminal status,
|
||||
and only then broadcast `turn_complete` or `turn_error`.
|
||||
|
||||
This establishes the required happens-before relationship:
|
||||
|
||||
```text
|
||||
terminal latch wins
|
||||
-> live activity watermark advances
|
||||
-> formal terminal is published
|
||||
-> client observes terminal
|
||||
-> client starts live-state request
|
||||
-> live-state includes the new watermark
|
||||
```
|
||||
|
||||
The final step applies only while that live entry remains present in the same
|
||||
bridge generation. A close or runtime replacement may instead make the row
|
||||
disappear or return a new generation; the protocol never carries the retired
|
||||
bridge-local watermark across that lifecycle boundary.
|
||||
|
||||
The ownership gate on `settleActivePromptState` and the terminal latch continue
|
||||
to protect the next prompt from a late result. The timestamp update must not add
|
||||
a second independent settle path.
|
||||
|
||||
## Catalog Version Contract
|
||||
|
||||
`updatedAt` does not participate in `generation + revision`.
|
||||
|
||||
The version remains an equality token for catalog membership and static
|
||||
metadata. Advancing it on every turn would make the two-second live-state poll
|
||||
observe a mismatch and request the same full catalog scan this extension is
|
||||
designed to remove.
|
||||
|
||||
Clients merge `updatedAt` from the snapshot directly, just as they merge
|
||||
`hasActivePrompt` and waiting flags. A change in `updatedAt` with an unchanged
|
||||
catalog version is valid and expected.
|
||||
|
||||
## Persistence and Full-catalog Merge
|
||||
|
||||
Persisted session summaries continue to derive `updatedAt` from JSONL mtime.
|
||||
The live timestamp and persisted timestamp have different authorities:
|
||||
|
||||
- persisted mtime describes the latest modification visible in storage;
|
||||
- live `updatedAt` describes the latest running-turn terminal visible to the
|
||||
bridge.
|
||||
|
||||
When a live session is merged with a persisted summary, the response should use
|
||||
the later valid timestamp rather than blindly preferring either source. This
|
||||
prevents a terminal timestamp from moving a row backward when an asynchronous
|
||||
transcript write obtains an mtime a few milliseconds later.
|
||||
|
||||
The comparison helper must preserve a valid value when the other candidate is
|
||||
absent or invalid. No filesystem read is added: both candidate strings are
|
||||
already present in memory in the existing merge path.
|
||||
|
||||
Every surface that holds both candidates applies the same rule, including the
|
||||
Live Task read and wait paths, which read the bridge summary directly rather
|
||||
than through the list merge. Otherwise one task would report two different
|
||||
recencies depending on the tool that asked, and a wait cursor keyed on the live
|
||||
value alone would not change when only the transcript advanced.
|
||||
|
||||
While the session remains live, activity-based full catalog responses and
|
||||
live-only session lists can therefore use the same effective recency. Once the
|
||||
live entry disappears, persisted mtime becomes the only authority again and may
|
||||
be older than the former live watermark. That can happen after a crash or
|
||||
degraded write, but also after a normal close when the logical `+1ms` tie-breaker
|
||||
or a wall-clock rollback placed the in-memory watermark ahead of storage. The
|
||||
protocol does not manufacture durability or preserve a retired bridge-local
|
||||
watermark.
|
||||
|
||||
Existing cursor routes are not snapshot-isolated: concurrent session activity
|
||||
can already change the collection while a caller pages through it. Populating
|
||||
live `updatedAt` makes that movement observable in activity-based ordering but
|
||||
does not introduce a stronger pagination guarantee. Clients that require a
|
||||
fresh first page reload it after an activity change.
|
||||
|
||||
One movement mode is new to the activity dimension. Before this change the
|
||||
activity component of a cursor key came from transcript mtime alone and could
|
||||
only advance; the organized key's pin dimension could already flip mid-pass
|
||||
(the organization snapshot is re-read per page request), so an emitted
|
||||
persisted row could already repeat after an unpin, but the activity component
|
||||
itself never moved backward. A live watermark that leads mtime is not durable:
|
||||
when the live entry retires mid-pass the row's key falls back to mtime, so a
|
||||
page whose cursor was encoded from the higher watermark would admit that row
|
||||
again. The same regression applies to a live-only row that persists mid-pass,
|
||||
because its emitted key was the watermark while its persisted key is the first
|
||||
flush's mtime.
|
||||
|
||||
The activity cursor therefore carries the identities of rows already emitted at
|
||||
a live-derived key, and the after-cursor filter excludes them for the rest of
|
||||
the pass, so live-derived key movement returns a session at most once per pass.
|
||||
The carry covers only rows the mechanism saw: a persisted-only row emitted
|
||||
before its pin state changes is never carried, so an unpin between fetches
|
||||
keeps the pre-existing organized-view repetition mode, and callers that
|
||||
accumulate pages key rows by `sessionId`. The third activity-keyed cursor, the
|
||||
live-only list, needs no carry: the first watermark advance floors at
|
||||
`createdAt`, so a live key never moves backward. The list stays bounded and
|
||||
self-pruning: an identity absent from the filtered collection is retained —
|
||||
absence can be transient under the short-TTL persisted snapshot or mid-pass
|
||||
group movement — while an identity whose row is present is dropped once its
|
||||
persisted key alone can no longer pass the strictly-older filter under either
|
||||
pin state. Past a 64-identity cap the identities with the highest persisted
|
||||
keys are dropped first — they leave the re-admission window soonest — and a
|
||||
dropped identity degrades to an at-most-once duplicate instead of failing the
|
||||
pass. Cursors minted before the field existed remain
|
||||
valid, and the field is omitted when empty, so the cursor shape is unchanged
|
||||
whenever no live-derived key was emitted. Cross-pass guarantees are unchanged:
|
||||
a new pass is a new snapshot, and clients that require a fresh view reload from
|
||||
the first page.
|
||||
|
||||
## Bridge and Route Implementation
|
||||
|
||||
The server implementation should make the smallest possible change:
|
||||
|
||||
1. Add `lastTurnEndedAtMs?: number` to `SessionEntry`.
|
||||
2. Add one internal helper that advances the field monotonically, flooring
|
||||
the first advance at the entry's `createdAt`.
|
||||
3. In `publishPromptTerminal`, compute the existing running-state gate before
|
||||
broadcasting and advance only after the duplicate latch succeeds.
|
||||
4. Project `new Date(lastTurnEndedAtMs).toISOString()` from
|
||||
`toSessionSummary` as the already-declared `BridgeSessionSummary.updatedAt`.
|
||||
5. Project the optional summary field from the workspace live-state route.
|
||||
6. Change live/persisted merge selection to keep the later valid timestamp.
|
||||
|
||||
No new bridge method is needed. `listWorkspaceSessions` and
|
||||
`getSessionSummary` already return `BridgeSessionSummary`, and its public type
|
||||
already carries the optional field.
|
||||
|
||||
The live-state route continues to:
|
||||
|
||||
- resolve the selected workspace runtime without primary fallback;
|
||||
- reject untrusted or unavailable runtimes under existing semantics;
|
||||
- read only the bridge version and in-memory summaries;
|
||||
- invalidate persisted catalog caches only when exposing a new catalog version;
|
||||
- make no session-storage, settings, external-command, or ACP request; and
|
||||
- return `Cache-Control: no-store`.
|
||||
|
||||
An activity-only change must not invalidate the persisted-list cache from the
|
||||
live-state route. Cached snapshots contain persisted facts; the request's
|
||||
existing live merge applies the current in-memory timestamp when the catalog is
|
||||
served.
|
||||
|
||||
## Downstream Consumer Audit
|
||||
|
||||
Populating the existing `BridgeSessionSummary.updatedAt` field has a wider but
|
||||
intentional additive effect than projecting a route-local value.
|
||||
|
||||
| Consumer | Effect |
|
||||
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Workspace live-state | Returns optional activity watermark for the intended client optimization |
|
||||
| Full workspace session lists | Live/persisted merge and activity ordering can reflect the latest running terminal |
|
||||
| Live-only session list and cursor | Live rows are ordered by the populated activity timestamp instead of creation time alone |
|
||||
| `GET /session/:id/status` | Adds the already-typed bridge-local field directly, without a persisted merge |
|
||||
| Live Task thread summaries and cursors | A running terminal advances the live task's `updatedAt`/change cursor; read/wait apply the same later-valid rule as the list |
|
||||
| Goals routes | Ignore the new field; behavior is unchanged |
|
||||
| Session-owner resolution and admission | Read identity/ownership fields only; behavior is unchanged |
|
||||
| Create-sub-session and live-session coordinators | Read caller or interaction state only; behavior is unchanged |
|
||||
|
||||
The implementation PR must update exact-object tests for the affected public
|
||||
summary surfaces while retaining tests that prove unrelated ownership and
|
||||
status decisions do not start depending on the timestamp.
|
||||
|
||||
`GET /session/:id/status` returns the bridge summary directly. Its `updatedAt`
|
||||
may therefore be earlier than a merged list response that selected a later
|
||||
persisted mtime; equality across those response surfaces is not guaranteed.
|
||||
|
||||
## TypeScript SDK and Capability
|
||||
|
||||
The TypeScript SDK change is limited to the optional field on
|
||||
`DaemonSessionLiveState`. `DaemonSessionSummary.updatedAt` already exists.
|
||||
|
||||
The SDK transport remains a typed native REST call and does not add a runtime
|
||||
schema transformation or a capability request per poll.
|
||||
|
||||
No capability is added or changed:
|
||||
|
||||
```ts
|
||||
workspace_session_live_state: {
|
||||
since: 'v1';
|
||||
}
|
||||
```
|
||||
|
||||
That capability continues to mean that the endpoint exists. It does not promise
|
||||
that every live item has `updatedAt`, because the value is lifecycle-dependent.
|
||||
|
||||
No Java SDK or ACP method is added.
|
||||
|
||||
## Compatibility
|
||||
|
||||
| Server | Client | Behavior |
|
||||
| ------ | -------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Old | Old | Existing live-state and rate-limited catalog refresh behavior |
|
||||
| New | Old | Extra JSON property is ignored; existing behavior remains |
|
||||
| Old | New follow-up client | Missing field retains the current rate-limited full-catalog fallback |
|
||||
| New | New follow-up client | Known catalog rows update recency from live-state; fallback remains for uncovered rows |
|
||||
|
||||
The response schema remains `v: 1`. A new schema version would force clients to
|
||||
branch on an otherwise additive property and would not solve the legitimate
|
||||
absence of a timestamp before the first terminal in a new generation.
|
||||
|
||||
## Follow-up Web Shell Consumption Contract
|
||||
|
||||
The server PR does not change Web Shell behavior, but the protocol is designed
|
||||
for a follow-up consumer with the following rules:
|
||||
|
||||
1. `turnCompleted` carries both workspace cwd and session id.
|
||||
2. The client records a per-session completion sequence only for a turn known
|
||||
to have reached the running state. A queued-only terminal requires no
|
||||
recency reconciliation.
|
||||
3. A live-state request snapshots pending sequences when the request starts.
|
||||
4. Only a response from a request started after that sequence was recorded may
|
||||
satisfy it.
|
||||
5. A valid `updatedAt` on a matching row patches recency and reorders the loaded
|
||||
active page using the server comparator.
|
||||
6. Archived pages never accept a live activity timestamp.
|
||||
7. A missing/invalid timestamp or a target row absent from the loaded active
|
||||
catalog retains the existing version-fenced, rate-limited catalog fallback.
|
||||
|
||||
The sequence is required because a second turn can complete while a live-state
|
||||
request is in flight. A set of session ids would let that older response clear
|
||||
the newer completion.
|
||||
|
||||
A consumer that cannot distinguish a queued-only terminal may retain the
|
||||
existing rate-limited catalog fallback conservatively, but the protocol does
|
||||
not require a fallback for an event that produced no activity watermark.
|
||||
|
||||
The client should keep the later of its catalog and live timestamps, and should
|
||||
only reorder when the effective value changes. It must not insert an unknown
|
||||
live session into source-, group-, or archive-filtered pages because live-state
|
||||
does not carry the static metadata needed to prove membership.
|
||||
|
||||
This keeps the common case scan-free while preserving correctness for old
|
||||
servers, pagination boundaries, and sessions missing from the current page.
|
||||
|
||||
## Failure Semantics
|
||||
|
||||
The route's HTTP failure semantics do not change.
|
||||
|
||||
| Condition | Result |
|
||||
| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| No running terminal in current live generation | Session item omits `updatedAt` |
|
||||
| Wall clock does not advance between terminals | Logical `+1ms` preserves strict monotonicity |
|
||||
| Wall clock moves backward | Per-entry monotonicity prevents a live timestamp regression; the first advance floors at `createdAt` |
|
||||
| Wall clock jumps forward and is later corrected | The watermark may remain in the future until time catches up or the live entry disappears |
|
||||
| Recorder is degraded or later flush fails | Watermark still represents terminal activity; no durability claim |
|
||||
| Duplicate or late terminal | Existing latch suppresses another timestamp advance |
|
||||
| Queued prompt never runs | No timestamp advance or required recency confirmation |
|
||||
| Session disappears after terminal | Live row disappears; persisted catalog remains authoritative |
|
||||
| Daemon/runtime replacement | New catalog generation and initially absent live watermark |
|
||||
| Client awaiting running-turn recency receives an invalid or missing field | Ignore it and retain existing fallback behavior |
|
||||
|
||||
No new logging is required on the high-frequency route. Existing route latency
|
||||
and request-count telemetry is sufficient. The implementation may add no
|
||||
per-terminal success log; doing so would add high-cardinality noise to an
|
||||
ordinary lifecycle path.
|
||||
|
||||
A forward clock jump is accepted bridge-local watermark behavior. Correcting
|
||||
the value downward would violate strict monotonicity; while the entry remains
|
||||
live, the later-valid merge may therefore keep the future watermark ahead of a
|
||||
correct persisted mtime. Entry removal or bridge replacement ends that scope.
|
||||
|
||||
## Implementation PR Boundaries
|
||||
|
||||
The server protocol implementation should be one atomic feature PR containing:
|
||||
|
||||
- the bridge-local monotonic activity watermark;
|
||||
- summary projection and the existing full-list merge correction;
|
||||
- workspace live-state route projection;
|
||||
- TypeScript SDK type update;
|
||||
- protocol and SDK documentation updates; and
|
||||
- bridge, route, list-merge, status, and SDK tests.
|
||||
|
||||
It intentionally excludes all Web Shell changes. Shipping the additive server
|
||||
field first lets old clients exercise the unchanged path and gives the later
|
||||
client PR a deployed compatibility target.
|
||||
|
||||
The implementation crosses ACP bridge, CLI serve, and TypeScript SDK packages.
|
||||
It is a small feature rather than a refactor, but repository policy requires
|
||||
maintainer awareness for cross-package architectural changes. The PR should
|
||||
name the complete downstream consumer audit above in its risk section.
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Bridge unit tests
|
||||
|
||||
- A newly created or restored live entry omits `updatedAt` before its first
|
||||
running terminal.
|
||||
- A successful running terminal produces a valid ISO timestamp.
|
||||
- Running success, structured error, transport error, cancellation, and deadline
|
||||
terminal paths each advance exactly once. The teardown paths (session close,
|
||||
kill, channel exit, daemon shutdown) advance through the same helper, but each
|
||||
removes the entry from live state in the same operation, so the advanced value
|
||||
has no reader and no test asserts it.
|
||||
- Queued removal/cancellation/deadline does not advance.
|
||||
- The deadline path publishes its terminal twice — once from the expiry and
|
||||
once from the raced rejection reaching the settle handler — and still
|
||||
advances exactly once. An agent result arriving later lands after the raced
|
||||
promise settled, so it adds neither a terminal nor an advance.
|
||||
- Two running terminals under a fixed `Date.now()` produce strictly increasing
|
||||
values.
|
||||
- A forward clock jump followed by a correction never decreases the watermark;
|
||||
the bridge-local value resets only with entry or bridge replacement.
|
||||
- Heartbeat, attach/detach, interaction waiting, and streamed updates do not
|
||||
change the value.
|
||||
- The summary already contains the new timestamp when a terminal subscriber is
|
||||
synchronously notified.
|
||||
- Turn activity leaves catalog generation and revision unchanged.
|
||||
|
||||
### Route and ownership tests
|
||||
|
||||
- The workspace live-state response includes `updatedAt` only when the bridge
|
||||
summary supplies it.
|
||||
- Existing five required live-state fields remain present.
|
||||
- `v`, `catalogVersion`, `Cache-Control`, selector, trust, and runtime-generation
|
||||
behavior remain unchanged.
|
||||
- The route still performs no filesystem, settings, command, or ACP-child work.
|
||||
- Primary and secondary runtime tests prove the timestamp comes only from the
|
||||
selected bridge.
|
||||
|
||||
### Full-list and downstream tests
|
||||
|
||||
- A live timestamp newer than persisted mtime wins and changes activity order.
|
||||
- A persisted timestamp newer than the live terminal value wins, preventing a
|
||||
regression.
|
||||
- Invalid or absent candidates preserve the other valid value.
|
||||
- Live-only list ordering and cursor keys use the populated timestamp.
|
||||
- Session status exposes the optional field without changing unrelated status
|
||||
values.
|
||||
- Live Task cursor changes after a running terminal and remains stable across a
|
||||
heartbeat.
|
||||
- Goals, owner resolution, and admission tests remain unchanged or explicitly
|
||||
ignore the new field.
|
||||
|
||||
### SDK and compatibility tests
|
||||
|
||||
- Public daemon exports include the optional field.
|
||||
- Existing live-state method performs exactly one native REST request and no
|
||||
capability probe.
|
||||
- An old-shaped response without `updatedAt` remains assignable and usable.
|
||||
- Exact request URL, timeout, client identity, and error behavior remain
|
||||
unchanged.
|
||||
|
||||
### E2E and fault injection
|
||||
|
||||
1. Start a real daemon and create a session.
|
||||
2. Read live-state before a prompt terminal and allow `updatedAt` to be absent.
|
||||
3. Complete a prompt and assert a subsequent read returns a valid timestamp
|
||||
while `catalogVersion` is unchanged.
|
||||
4. Complete a second prompt with minimal delay and assert the timestamp is
|
||||
greater.
|
||||
5. Send heartbeats and assert the timestamp does not change.
|
||||
6. Exercise a running cancellation or deadline and assert it advances once.
|
||||
7. Restart the daemon and assert the catalog generation changes while the live
|
||||
timestamp is absent until another terminal.
|
||||
8. Block or enlarge persisted catalog scanning and confirm direct live-state
|
||||
latency remains independent of it.
|
||||
|
||||
The later Web Shell PR adds request-count E2E coverage proving repeated turns on
|
||||
an already-loaded row issue no additional full catalog requests.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A running-prompt terminal event observed by a client causally precedes the
|
||||
watermark in a subsequently started live-state response whenever the same
|
||||
live entry remains present in the same bridge generation.
|
||||
- The watermark advances exactly once per running prompt terminal and never for
|
||||
a queued-only terminal or heartbeat.
|
||||
- Multiple terminals in one wall-clock millisecond remain strictly ordered.
|
||||
- Turn activity does not change catalog generation or revision and does not
|
||||
invalidate persisted-list caches.
|
||||
- Direct live-state work remains independent of persisted session count and
|
||||
transcript size.
|
||||
- When a live and persisted summary for the same session are merged, ordering
|
||||
uses the later valid activity time without moving that row backward while the
|
||||
live entry exists.
|
||||
- Existing status, trust, runtime ownership, capability, and error contracts are
|
||||
preserved.
|
||||
- Old clients ignore the field, and a missing field remains valid for new
|
||||
clients.
|
||||
|
||||
## Rollout
|
||||
|
||||
The server field is additive and ships through the normal daemon release path
|
||||
without a feature flag. Canary telemetry should compare:
|
||||
|
||||
- live-state latency and error rate before and after the change;
|
||||
- catalog revision stability across ordinary prompt terminals; and
|
||||
- unexpected changes in live-session list or Live Task ordering.
|
||||
|
||||
Targeted canary smoke tests should separately verify one distinct timestamp
|
||||
advance per running terminal. That verification reads the endpoint directly and
|
||||
does not require a new per-terminal production log or metric.
|
||||
|
||||
The protocol PR alone does not reduce Web Shell catalog request volume. That
|
||||
metric changes only after the follow-up consumer ships. Rollback is the ordinary
|
||||
server binary rollback; there is no configuration, data, or migration state.
|
||||
|
||||
## Rejected Alternatives
|
||||
|
||||
### Reuse `sessionLastSeenAt`
|
||||
|
||||
Heartbeats advance it, so connected idle sessions would continually appear
|
||||
recent. Liveness and conversation activity are different signals.
|
||||
|
||||
### Stat the transcript from the live-state route
|
||||
|
||||
This would return persisted authority but restore filesystem work to a
|
||||
high-frequency endpoint whose defining contract is memory-only cost.
|
||||
|
||||
### Flush the recorder before publishing every terminal
|
||||
|
||||
This would turn UI recency into a synchronous durability barrier on the prompt
|
||||
response path, increasing latency and coupling bridge settlement to storage
|
||||
health. The timestamp does not require that guarantee.
|
||||
|
||||
### Advance catalog revision on every terminal
|
||||
|
||||
Every live-state client would observe a version mismatch and reload the full
|
||||
catalog, preserving the current scan rather than eliminating it.
|
||||
|
||||
### Use the browser's `Date.now()`
|
||||
|
||||
It would not cover turns completed by another controller, would disagree across
|
||||
clients, and would provide no server-side ordering authority.
|
||||
|
||||
### Add `lastTurnCompletedAt` beside `updatedAt`
|
||||
|
||||
Existing summary and catalog comparators already use `updatedAt` as the activity
|
||||
time. A second public timestamp would require another merge policy while the
|
||||
desired consumer ultimately maps it back onto the same catalog field.
|
||||
|
||||
### Add an envelope support flag, new capability, or `v: 2`
|
||||
|
||||
The property is additive, and a supporting daemon can legitimately omit it
|
||||
before the first running terminal. The follow-up client can safely fall back on
|
||||
absence without another negotiation surface.
|
||||
|
||||
### Include source and organization metadata so unknown rows can be inserted
|
||||
|
||||
That would evolve live-state into a second catalog protocol with duplicate
|
||||
filtering, invalidation, and compatibility rules. A full catalog reconciliation
|
||||
is the correct uncommon fallback when the target row is not already loaded.
|
||||
|
||||
### Publish activity through SSE only
|
||||
|
||||
The terminal already arrives through the session stream, but a route-level
|
||||
watermark also updates other tabs and controllers polling workspace state. It
|
||||
keeps reconnect and missed-event recovery stateless and within the existing
|
||||
live-state protocol.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Duplicate provider tool-call id guard: args-aware replay detection and visible daemon stop
|
||||
|
||||
- **Status**: approved
|
||||
- **Date**: 2026-08-19
|
||||
- **Related**: issue #5014, PR #5038 (first-level suppression), PR #5657
|
||||
(repeated-duplicate circuit breaker)
|
||||
|
||||
## Background
|
||||
|
||||
PR #5038 made provider-supplied tool-call ids idempotent: when a provider
|
||||
replays an already-handled id, the duplicate is answered with a synthetic
|
||||
error response instead of executing again (issue #5014 showed the same shell
|
||||
command executing hundreds of times). PR #5657 added a circuit breaker on
|
||||
top: once a synthetic duplicate response has been sent for a provider id,
|
||||
seeing that id again drops the whole tool batch and terminates the turn.
|
||||
The breaker is shared by four entry points via
|
||||
`findRepeatedDuplicateProviderToolCall` (`packages/core/src/core/turn.ts`):
|
||||
AgentCore, the TUI stream hook, the non-interactive CLI, and the ACP daemon
|
||||
`Session`.
|
||||
|
||||
## Problems
|
||||
|
||||
### P1: id-only matching misfires on per-response id schemes
|
||||
|
||||
Both guard levels key on the provider id **alone**. Some models emit
|
||||
tool-call ids that are only unique within a single response — e.g. Kimi K3
|
||||
emits `functions.{name}:{index}` which surfaces as `run_shell_command_0`,
|
||||
and the index can restart at 0 on any round. A fresh call (different
|
||||
command) that happens to reuse an old id is then treated as a replay:
|
||||
|
||||
- round 1: `run_shell_command_0` (cmd A) → executes;
|
||||
- round 2: `run_shell_command_0` (cmd B) → synthetic duplicate error;
|
||||
- round 3: `run_shell_command_0` (cmd C) → circuit breaker, batch dropped,
|
||||
turn ends.
|
||||
|
||||
Once the model is in this mode every turn dies by round 3. Observed live in
|
||||
daemon sessions `e73e9c10` and `0c4ebdb4` (the latter looped through the
|
||||
two-round failure cycle on three consecutive prompts after the model's
|
||||
counter reset).
|
||||
|
||||
The original #5014 replay — and the regression fixtures that guard it — are
|
||||
**same id and same arguments**. Id collisions with different arguments are a
|
||||
distinct, benign case that the guard should let through.
|
||||
|
||||
### P2: the daemon drop is silent
|
||||
|
||||
PR #5657 required user-visible termination and delivered it on three of the
|
||||
four paths: AgentCore terminates with `LOOP_DETECTED`, the TUI sets its
|
||||
loop-detected flag and posts the loop message, the non-interactive CLI emits
|
||||
a `LoopType.GLOBAL_TOOL_CALL_DUPLICATE` loop result. The ACP daemon
|
||||
`Session` is the outlier: `runToolCalls` returns an empty-parts result whose
|
||||
only consumer returns `message: null`, so the turn ends as a normal
|
||||
`end_turn` with nothing in the transcript, no telemetry, and only a
|
||||
`debugLogger.debug` line. To the user the session looks hung.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 (P2): route the daemon breaker through the loop-detected machinery
|
||||
|
||||
In `Session.runToolCalls`, when `findRepeatedDuplicateProviderToolCall`
|
||||
fires, call `recordDaemonLoopDetected(config, promptId,
|
||||
LoopType.GLOBAL_TOOL_CALL_DUPLICATE, message, toolLoopState)` and return
|
||||
`loopDetected: true` instead of the bespoke
|
||||
`repeatedDuplicateProviderToolCall` flag. Every `runToolCalls` caller
|
||||
already handles `loopDetected`: it preserves the
|
||||
`LOOP_DETECTED_CONTEXT_MESSAGE` into unsent history, suspends the todo stop
|
||||
guard, and — on foreground turns — fails the prompt with the
|
||||
`LOOP_DETECTED` turn error (`loopType` carried in the error data), which the
|
||||
client renders. Cron and background-notification turns keep their graceful
|
||||
end-turn semantics, matching how daemon loop detection already behaves.
|
||||
|
||||
The `repeatedDuplicateProviderToolCall` field on `RunToolResult` and its
|
||||
dead consumer branch are removed. `GLOBAL_TOOL_CALL_DUPLICATE` is the same
|
||||
loop type the non-interactive CLI reports for this breaker, and
|
||||
`recordDaemonLoopDetected` is idempotent per turn and emits the
|
||||
`LoopDetectedEvent` telemetry the silent path was missing.
|
||||
|
||||
### D2 (P1): a duplicate is a replay only if name+args match the original
|
||||
|
||||
Replace the id-membership test with a fingerprint test at all four entry
|
||||
points, both guard levels:
|
||||
|
||||
- **fingerprint** = tool name + canonical JSON of `args` (sorted keys,
|
||||
stable serialization).
|
||||
- A handled-id map replaces the handled-id set: provider id → fingerprint
|
||||
of the call that first executed under that id. History-derived entries
|
||||
come from a new `GeminiChat.getHistoryToolCallFingerprints()` (model-turn
|
||||
`functionCall`s whose id has a matching user-turn `functionResponse`),
|
||||
replacing `getHistoryFunctionResponseIds()` at the dedup sites. In-flight
|
||||
tracking (per-turn refs/sets at each entry point) records the same
|
||||
mapping when a call is admitted for execution.
|
||||
- Incoming call with a handled id **and equal fingerprint** → replay:
|
||||
synthetic duplicate response first, circuit breaker on recurrence
|
||||
(unchanged #5038/#5657 behavior).
|
||||
- Incoming call with a handled id but a **different fingerprint** → not a
|
||||
replay: it executes under the suffixed unique id that
|
||||
`normalizeModelToolCallIds` already assigns (`__qwen_dup_N`). The map keeps
|
||||
the first occurrence's fingerprint (first-occurrence semantics — the raw
|
||||
id keeps naming its original call; suffixed executions live in history
|
||||
under their suffixed ids).
|
||||
- `duplicateProviderToolCallResponseIds` (ids already answered with a
|
||||
synthetic response) stays id-keyed; since synthetic responses are now only
|
||||
sent for true replays, the breaker condition becomes "incoming item is a
|
||||
replay AND (a synthetic response was already sent for that id OR the same
|
||||
replay appears more than once in the batch)".
|
||||
- The same-response duplicate drop inside `normalizeModelToolCallIds`
|
||||
(`rawIdsInCurrentTurn`) is unchanged: two identical raw ids inside one
|
||||
streamed response remain pathological (Kimi emits distinct indices for
|
||||
parallel calls in one response).
|
||||
|
||||
Residual risks, accepted:
|
||||
|
||||
- A replay of a _suffixed_ execution (same raw id, args equal to a later
|
||||
collision call rather than the first) executes again; unbounded repetition
|
||||
is stopped by the id-independent guards (consecutive-identical threshold
|
||||
5, global same-(tool,args) threshold 6, turn tool-call cap).
|
||||
- A legitimate re-run whose args exactly equal the raw id's first execution
|
||||
is still suppressed once and circuit-breaks on recurrence — now visibly
|
||||
(D1), and with a recovery hint (D3).
|
||||
|
||||
### D3: make the synthetic duplicate message actionable
|
||||
|
||||
Append guidance to `duplicateProviderToolCallMessage` telling the model
|
||||
that, if a new invocation was intended, it must re-issue the call with a
|
||||
fresh tool-call id (or explicitly different arguments). Kimi-class models
|
||||
generate the index token themselves and can comply. Existing assertions use
|
||||
`toContain` on the leading sentence and remain valid.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **Scope provider ids to a single response** (always execute cross-round
|
||||
collisions): reverts #5014 — a true replay would execute up to the loop
|
||||
thresholds (≤5 times), unacceptable for side-effect tools.
|
||||
- **Adapter-level id rewriting for `^{tool}_\d+$`-shaped ids**: fragile
|
||||
provider-specific heuristic; disables replay protection wholesale for
|
||||
matching providers.
|
||||
- **Family-wide fingerprint matching** (compare against the raw id _and_
|
||||
all its suffixed executions): strictly stronger replay suppression, but
|
||||
blocks legitimate identical re-runs (build/test/status loops) for
|
||||
broken-numbering models — the dominant workflow for the model class this
|
||||
fix targets.
|
||||
- **Waiting for an upstream provider fix**: id uniqueness is outside our
|
||||
control; the client must be robust regardless.
|
||||
|
||||
## Delivery
|
||||
|
||||
Two PRs sharing this doc:
|
||||
|
||||
1. **Visible daemon stop (D1)** — `Session.ts` only, plus tests. Ships
|
||||
first: turns the silent hang into a diagnosable loop-detected stop even
|
||||
before the root cause lands.
|
||||
2. **Args-aware replay detection (D2 + D3)** — core helpers
|
||||
(`turn.ts`, `toolCallIdUtils.ts`, `geminiChat.ts`, `client.ts`) and the
|
||||
four entry points, plus tests.
|
||||
|
||||
## Test matrix
|
||||
|
||||
| Case | Expected |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Same id, same args, second occurrence | synthetic duplicate response, no execution (unchanged) |
|
||||
| Same id, same args, third occurrence | batch dropped; visible loop-detected stop on every path, `GLOBAL_TOOL_CALL_DUPLICATE` telemetry (daemon: new) |
|
||||
| Same id, different args, any round | executes under `__qwen_dup_N` id; no synthetic response; turn continues |
|
||||
| Same raw id twice within one response | second call dropped by `normalizeModelToolCallIds` (unchanged) |
|
||||
| Id without history collision | executes (unchanged) |
|
||||
| Daemon foreground turn hits breaker | prompt fails with `LOOP_DETECTED` turn error; context message preserved for next turn |
|
||||
| Daemon cron/background turn hits breaker | graceful end-turn; context message preserved (parity with existing daemon loop stops) |
|
||||
|
||||
Regression suites: `packages/core` `turn.test.ts`,
|
||||
`toolCallIdUtils.test.ts`, `agent-headless.test.ts`; `packages/cli`
|
||||
`useGeminiStream.test.tsx`, `nonInteractiveCli.test.ts`,
|
||||
`acp-integration/session/Session.test.ts`. E2E: mock OpenAI-compatible
|
||||
provider replaying `{name}_0`-style ids with differing args per round
|
||||
(must complete all rounds), and with identical args (must stop visibly).
|
||||
@@ -350,35 +350,58 @@ flowchart TD
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[resolveRelevantAutoMemoryPromptForQuery] --> B[scanAutoMemoryTopicDocuments\n扫描所有主题文件]
|
||||
B --> C[filterExcludedAutoMemoryDocuments\n过滤本轮已写入的文件]
|
||||
A[resolveRelevantAutoMemoryPromptForQuery] --> B[scanAllAutoMemoryTopicDocuments +\nscanAllUserAutoMemoryTopicDocuments\n扫描项目级与用户级全部主题文件]
|
||||
B --> C[filterExcludedAutoMemoryDocuments\n合并作用域并过滤排除列表中的文件]
|
||||
C --> D{query 为空\n或 docs 为空\n或 limit <= 0?}
|
||||
D -- 是 --> E[返回空 prompt\nstrategy: none]
|
||||
D -- 否 --> F{是否配置了 Config?}
|
||||
F -- 是 --> G[selectRelevantAutoMemoryDocumentsByModel\n发起 side query 请求模型选择]
|
||||
G --> H{模型返回结果?}
|
||||
H -- 有文档 --> I[strategy: model]
|
||||
H -- 无文档 --> J[strategy: none\n仍然返回空]
|
||||
G -- "失败/异常" --> K[回退到启发式选择]
|
||||
F -- 否 --> K
|
||||
K --> L[tokenize query\n提取 ≥3 字符的 token]
|
||||
L --> M[scoreDocument 打分\n关键词匹配 +2 / 类型关键词 +1 / 有内容 +1]
|
||||
M --> N[过滤 score=0 的文档\n按分数降序排列,取 Top 5]
|
||||
N --> O{有得分文档?}
|
||||
O -- 是 --> P[strategy: heuristic]
|
||||
O -- 否 --> J
|
||||
I --> Q[buildRelevantAutoMemoryPrompt\n构建 Relevant Memory 区块]
|
||||
P --> Q
|
||||
Q --> R[返回注入主系统提示的 prompt 片段]
|
||||
F -- 是 --> G[selectModelCandidateDocuments\n词法候选 + recent reserve\n最多 200 篇且交错排列]
|
||||
G --> H[selectRelevantAutoMemoryDocumentsByModel\n构建最多 25 KB manifest\n发起 side query 请求模型选择]
|
||||
H --> I{模型返回结果?}
|
||||
I -- 有文档 --> J[strategy: model]
|
||||
I -- 无文档 --> K[strategy: none\n仍然返回空]
|
||||
H -- "失败/异常" --> L[复用已计算的启发式排序]
|
||||
F -- 否 --> M[tokenize query\nNFKC + 非 CJK 字母整串 + CJK bigram\n最多 64 个 token]
|
||||
M --> N[scoreDocument 打分\ntitle +4 / description +3 / body +1\n词法命中后类型加成最多 +2]
|
||||
N --> O[过滤 score=0 的文档\n按分数降序、mtime 降序、输入顺序排列\n取 Top 5]
|
||||
L --> O
|
||||
O --> P{有得分文档?}
|
||||
P -- 是 --> Q[strategy: heuristic]
|
||||
P -- 否 --> K
|
||||
J --> R[buildRelevantAutoMemoryPrompt\n构建 Relevant Memory 区块]
|
||||
Q --> R
|
||||
R --> S[返回注入主系统提示的 prompt 片段]
|
||||
```
|
||||
|
||||
> **关于 200 上限:这是换了截断依据,不是抬高了天花板。** 旧路径按 scope 各自
|
||||
> 保留最近 200 篇,截断发生在看 query 之前;新路径先扫全量,再按词法相关性 +
|
||||
> recency reserve 选出最多 200 篇候选。因此效果分三档:总量 ≤200 时两者都不按
|
||||
> 数量丢弃(但新增了 25 KB manifest 上限,长 description 场景可能被截);总量
|
||||
> 在 200–400 且单个 scope 不超 200 时,旧路径会把全部(最多 400 篇)送给
|
||||
> Selector,新路径最多 200 篇,且实测中 25 KB 的 manifest 预算会先于数量上限
|
||||
> 生效——150+150 篇的实测里旧路径送 300 行、新路径只送 94 行,**候选变少的幅度
|
||||
> 比数量上限暗示的更大**;只有单个 scope 超过 200 时,
|
||||
> 才是这次真正要解决的场景——旧的 recency 上限会让老而相关的文档永久不可见。
|
||||
> 详见 `docs/design/2026-08-09-bounded-memory-recall-candidates.md`。
|
||||
|
||||
**评分规则(启发式)**:
|
||||
|
||||
| 条件 | 加分 |
|
||||
| -------------------------------- | ---------------- |
|
||||
| query token 出现在文档内容中 | +2(每个 token) |
|
||||
| query token 是该类型的特征关键词 | +1(每个 token) |
|
||||
| 文档 body 非空 | +1 |
|
||||
| 条件 | 加分 |
|
||||
| ------------------------------------------ | ------------------- |
|
||||
| query token 出现在 title | +4(每个 token) |
|
||||
| query token 出现在 description | +3(每个 token) |
|
||||
| query token 出现在 body 前 1200 字符 | +1(每个 token) |
|
||||
| 至少一次词法命中后,token 是类型特征关键词 | +1,整篇文档最多 +2 |
|
||||
|
||||
> **Tokenize 规则**:NFKC 归一化并转小写后,Han/Hiragana/Katakana/Hangul 连续片段
|
||||
> 按 code point bigram 切分(单字不产生 token);其余至少 3 个字母、组合符或数字的
|
||||
> 连续片段整串保留。后者基于 `\p{L}` 而非 `[a-z0-9]`,因此西里尔、希腊、阿拉伯和
|
||||
> 带重音拉丁文都能产生 token。CJK 是**逐字符**排除的,不能只依赖正则分支顺序——
|
||||
> `\p{L}` 也匹配 Han,否则 `abc漢字` 会被并成一个 token。Thai/Khmer/Lao 这类
|
||||
> 无分词符又不在 CJK 集合内的文字,会整段变成一个 token:比之前完全没有 token 强,
|
||||
> 但不是分词。
|
||||
>
|
||||
> **同分排序**:按 mtime 降序,再按输入顺序(稳定排序),**不按 type**。
|
||||
|
||||
**每种类型的特征关键词**:
|
||||
|
||||
@@ -389,11 +412,77 @@ flowchart TD
|
||||
|
||||
**Prompt 构建规则**:
|
||||
|
||||
- 最多注入 5 篇文档(`MAX_RELEVANT_DOCS`)
|
||||
- 单次注入最多 5 篇文档(`MAX_RELEVANT_DOCS`)
|
||||
- 每篇文档 body 截断至 1200 字符(`MAX_DOC_BODY_CHARS`)
|
||||
- 超出截断时追加提示:"NOTE: Relevant memory truncated for prompt budget."
|
||||
- 包含文档的新鲜度信息(基于文件 mtime)
|
||||
|
||||
> **`MAX_RELEVANT_DOCS` 限制的是单次注入,不是单轮总量。** Fast 阶段投递 2 篇、
|
||||
> ToolResult 阶段又投递 5 篇全新文档时,本轮进入模型的是 **7 篇**——去重只消除
|
||||
> 重复,不压缩总和。这是放弃跨阶段预算核算的有意结果(见
|
||||
> `2026-08-08-native-memory-recall-reliability.md`):两次 Prompt 各自有界,
|
||||
> 每篇 body 仍截断到 1200 字符,Fast 上限为 2,因此最坏情况有界且不大,只是不等于 5。
|
||||
|
||||
### 投递时机(Delivery)
|
||||
|
||||
"选中了 Memory" 不等于 "主模型看到了 Memory"。Recall 在 UserQuery 到达时异步启动,
|
||||
投递发生在两个时机:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[UserQuery 到达\n启动 Recall Prefetch] --> B{等待结束\n以先到者为准:\nRecall 完成 / Fast 就绪 /\n取消 / 100 ms 上限}
|
||||
B --> B1{Recall 是否完成?}
|
||||
B1 -- 是 --> C{选中结果非空?}
|
||||
C -- 是 --> C1[注入首轮 Prompt\nphase: refined]
|
||||
C -- 否 --> C0[丢弃\nno_relevant_results]
|
||||
B1 -- 否 --> D{是否有确定性\nFast 结果?}
|
||||
D -- 是 --> E[注入首轮 Prompt\nphase: fast\n最多 2 篇 MAX_FAST_RECALL_DOCS]
|
||||
D -- 否 --> F[首轮不注入]
|
||||
E --> G[Recall 继续运行]
|
||||
F --> G
|
||||
G --> H{本轮是否有\nToolResult?}
|
||||
H -- 是 --> I{Recall 是否已完成?}
|
||||
I -- 是 --> J[排除 Fast 已投递文档\n按剩余文档重建 Prompt]
|
||||
I -- 否 --> M
|
||||
J --> J1{还有剩余文档?}
|
||||
J1 -- 是 --> K[注入 ToolResult\nphase: refined]
|
||||
J1 -- 否 --> L{Recall 选中了文档?}
|
||||
L -- 是 --> L1[丢弃\nalready_delivered]
|
||||
L -- 否 --> L2[丢弃\nno_relevant_results]
|
||||
H -- 否 --> M{选中文档是否\n全部已被 Fast 投递?}
|
||||
M -- 是 --> L1
|
||||
M -- 否 --> M1[丢弃\nno_safe_delivery_point]
|
||||
```
|
||||
|
||||
**为什么需要 Fast 阶段**:当存在 Config 时 Recall 会等待 Model Selector,
|
||||
而它是一次网络 Side Query(中止上限 30 秒),因此 100 ms 预算通常会超时。
|
||||
若没有 Fast 阶段,**没有工具调用的轮次将完全拿不到 Memory**——而这正是
|
||||
用户级 Memory 最重要的场景。Fast 结果复用 `selectModelCandidateDocuments`
|
||||
为 Model Manifest 已经算好的候选,不产生额外扫描或 I/O。
|
||||
|
||||
**100 ms 是上限而不是固定开销**:Fast 结果在 Recall 扫完 Memory 树之后才发布,
|
||||
所以真正决定它能否赶上的是**扫描耗时**,不是打分耗时(后者是微秒级)。
|
||||
`recall-scan-latency.test.ts` 在真实临时 Memory 树上实测:200 篇约 29 ms、
|
||||
500 篇约 70 ms、1000 篇约 130 ms。对能在预算内扫完的树(普通用户的常见情况),
|
||||
Fast 就绪后继续等待只是在等一个本设计已经假定赶不上的 Model Selector,
|
||||
因此等待会在 Fast 就绪时立即结束。超过约 1000 篇时扫描本身就超预算,
|
||||
该轮会付满 100 ms 且什么都投不到——提前结束等待只能把这种情况**限制住**,
|
||||
消除不了它。
|
||||
|
||||
**Fast 阶段的边界**:Fast 结果就是确定性结果,因此它只能解决**时机**问题,
|
||||
解决不了**匹配**问题。与文档没有任何词面重叠的 Query 产生不了 Fast 结果,
|
||||
这类 Query 在无工具回合仍然拿不到 Memory——只有 Model Selector 能覆盖它们,
|
||||
而无工具回合等不到 Selector。语料中的 `semantic-no-lexical` 分片专门测量这一点。
|
||||
|
||||
**去重**:两个阶段来自同一次扫描,Model Selector 并未把 Fast 文档视为已排除,
|
||||
因此 ToolResult 投递前必须过滤掉 Fast 已投递的 `filePath` 并重建 Prompt。
|
||||
|
||||
**丢弃口径**:同一条规则也适用于取消路径。若最终选中的文档已被 Fast 阶段
|
||||
全部投递,无论本轮是因为无工具调用、New Query、Reset、Abort 还是 Shutdown
|
||||
结束,都记为 `already_delivered` 而不是对应的取消原因——否则「Memory 从未
|
||||
到达模型」这一桶会被实际已送达的回合灌水。只有部分重叠时仍记取消原因,
|
||||
因为不在 Fast 集合里的那些文档确实没有投递点。
|
||||
|
||||
---
|
||||
|
||||
## Forget — 遗忘
|
||||
@@ -489,6 +578,25 @@ flowchart TD
|
||||
| `strategy` | `'none'` \| `'heuristic'` \| `'model'` | 选择策略 |
|
||||
| `duration_ms` | number | 总耗时(毫秒) |
|
||||
|
||||
### Recall Delivery 遥测
|
||||
|
||||
记录选中的 Memory 是否真的送达主模型(Selection 事件无法回答这个问题)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `phase` | `'fast'` \| `'refined'` | **投递阶段** |
|
||||
| `delivery_point` | `'initial'` \| `'tool_result'` \| `'discarded'` | 投递位置 |
|
||||
| `discard_reason` | `'no_safe_delivery_point'` \| `'new_query'` \| `'reset'` \| `'abort'` \| `'shutdown'` \| `'no_relevant_results'` \| `'already_delivered'` | 丢弃原因 |
|
||||
| `strategy` | `'none'` \| `'heuristic'` \| `'model'` | **选择方式** |
|
||||
| `docs_selected` | number | 结果文档数(投递事件为实际投递数;discarded 事件为选中数) |
|
||||
| `latency_ms` | number | 自发起的耗时 |
|
||||
|
||||
> **`phase` 与 `strategy` 正交,互不替代。** `phase` 描述**何时**送达:`fast` 是预算
|
||||
> 超时后注入的确定性结果,`refined` 是 Model Selector 选出的结果。`strategy` 描述
|
||||
> **如何**选出。`fast` 投递必然是 `heuristic`;`refined` 投递常规为 `model`,
|
||||
> 在 Selector 失败走 Fallback 时为 `heuristic`。仅凭 `strategy` 判断阶段,
|
||||
> 会把"确定性结果先到"与"Selector 故障"混为一谈。
|
||||
|
||||
---
|
||||
|
||||
## 相关源文件索引
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Daemon Extension Batch Activation
|
||||
|
||||
## Context
|
||||
|
||||
Extension Management V2 separates global default activation from exact
|
||||
workspace overrides. Its singular global route writes `defaultActivation` by
|
||||
stable Extension id and refreshes every runtime. Its singular workspace routes
|
||||
write or clear an override for a selected trusted runtime and refresh only that
|
||||
runtime.
|
||||
|
||||
Remote clients currently repeat those singular operations when toggling several
|
||||
Extensions. The legacy `/workspace/extensions/*` compatibility surface has
|
||||
different semantics: user scope writes a home-level path rule and workspace
|
||||
scope is bound to the primary workspace. A batch route on that surface alone
|
||||
therefore cannot optimize V2 clients.
|
||||
|
||||
## V2 contract
|
||||
|
||||
Add `extension_batch_activation_v2` as an independent capability so clients can
|
||||
distinguish older `extension_management_v2` daemons. It exposes two queued
|
||||
operations:
|
||||
|
||||
```text
|
||||
PUT /extensions/activation
|
||||
PUT /workspaces/:workspace/extensions/activation
|
||||
```
|
||||
|
||||
Both accept 1–100 Extension names in `extensionNames`, deduplicate them
|
||||
case-insensitively in request order, and return one Extension operation id. The
|
||||
public target is name-keyed because Extension loading and installation enforce
|
||||
name uniqueness, while a client declaring activation before installation cannot
|
||||
derive Qwen's source-dependent internal id. The global
|
||||
body accepts `state` as `enabled` or `disabled` and writes every target's
|
||||
`defaultActivation`. The workspace body also accepts `inherit`; it clears each
|
||||
target's exact override using the same legacy-rule masking semantics as the
|
||||
singular DELETE route. `inherit` does not create a declaration for a name with
|
||||
no existing policy; an all-unknown clear is a no-op.
|
||||
|
||||
Malformed names or state reject the request
|
||||
before queueing. Batch operations intentionally do not require an installed
|
||||
artifact: setting `enabled` or `disabled` for an unknown identity creates a
|
||||
declaration policy so clients can set desired activation before installation.
|
||||
Successful global results report the
|
||||
resulting default activation. Successful workspace results report the exact
|
||||
override (`null` for inherit) and effective activation. Singular activation
|
||||
routes remain installed-only and id-addressed.
|
||||
|
||||
## Persistence and ownership
|
||||
|
||||
All targets are written under one Extension Store lock, producing one
|
||||
generation. Existing policies and new declarations share the same atomic
|
||||
validation. A declaration uses a deterministic provisional id plus the regular
|
||||
V2 policy shape and a `declarationOnly` marker. Installing or discovering that
|
||||
name re-keys the declaration to the artifact's real id, removes only the marker,
|
||||
records the artifact generation, and preserves the declared global and
|
||||
workspace activation. V1 projection entries whose identities are not known yet
|
||||
remain carried by the V2 snapshot so later declaration, discovery, or artifact
|
||||
transactions cannot erase them. The manager applies the committed snapshot and
|
||||
refreshes its tool cache once.
|
||||
|
||||
The global batch is process-global: it refreshes all registered runtimes. The
|
||||
workspace batch is selected-runtime scoped: it resolves the exact workspace id
|
||||
or canonical path, requires that runtime to be trusted and open, writes only its
|
||||
canonical workspace override, and refreshes only that runtime. It never falls
|
||||
back to the primary runtime.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Authenticated HTTPS Git extension installs
|
||||
|
||||
## Status
|
||||
|
||||
Implemented for the daemon, Core extension manager, and TypeScript SDK. WebShell
|
||||
selection UI is intentionally deferred.
|
||||
|
||||
## Problem
|
||||
|
||||
The daemon rejects every extension source URL that contains HTTPS userinfo.
|
||||
That prevents users from installing a private repository with a narrowly scoped
|
||||
personal access token, even when the token is limited to one repository. Passing
|
||||
the credential through the source URL without additional handling would be
|
||||
unsafe: Git can persist the URL in `.git/config`, process arguments can expose
|
||||
it, and extension metadata, operation history, logs, or telemetry can retain it.
|
||||
|
||||
## Goals
|
||||
|
||||
- Accept generic HTTPS Git sources whose userinfo contains a username and/or
|
||||
token.
|
||||
- Default old clients to a safe one-time install when they omit a persistence
|
||||
choice.
|
||||
- Offer an explicit stored mode that remains updatable across daemon restarts.
|
||||
- Keep credentials out of URLs after request validation and out of Git argv,
|
||||
remote configuration, artifacts, metadata, logs, operation history, and
|
||||
telemetry.
|
||||
- Preserve identity and update behavior for every existing installation and
|
||||
every new installation without URL credentials.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Add the WebShell confirmation UI. A follow-up can use the
|
||||
`extension_git_credentials` capability to offer stored, one-time, or cancel.
|
||||
- Accept credentials for npm, archives, SSH Git, or local sources.
|
||||
- Migrate existing extension artifacts or Agent Plugin data directories.
|
||||
- Revoke, rotate, or validate the repository scope of a user-provided token.
|
||||
|
||||
## Protocol
|
||||
|
||||
Both daemon install endpoints accept:
|
||||
|
||||
```ts
|
||||
credentialPersistence?: 'stored' | 'one_time';
|
||||
```
|
||||
|
||||
The field is valid only when `source` is an HTTPS URL with userinfo. Omission in
|
||||
that case means `one_time`; supplying the field without userinfo is a `400`.
|
||||
Credentialed sources must parse as Git after the existing public-network source
|
||||
policy is applied. GitHub credentialed URLs bypass release downloads and use
|
||||
Git clone directly.
|
||||
|
||||
The route decodes and validates userinfo before the operation is queued. Empty
|
||||
userinfo, malformed encoding, control characters, NUL, CR/LF, usernames over
|
||||
256 UTF-8 bytes, and passwords over 4096 UTF-8 bytes are rejected. The route
|
||||
then removes userinfo. Only the clean URL and an in-memory credential object can
|
||||
cross into Core.
|
||||
|
||||
One-time operation history does not include the source. Successful results
|
||||
expose only `credentialPersistence`; stored results may additionally expose the
|
||||
clean source and `credentialStorage` (`keychain` or `encrypted_file`). No
|
||||
response contains a credential or authorization header.
|
||||
|
||||
## Git authentication
|
||||
|
||||
Clone, fetch, and remote listing always receive the clean repository URL. The
|
||||
credential is supplied only in the Git child environment with Git's counted
|
||||
configuration variables:
|
||||
|
||||
```text
|
||||
GIT_CONFIG_KEY_0=http.<clean-repository-url>.extraHeader
|
||||
GIT_CONFIG_VALUE_0=Authorization: Basic <base64(username:password)>
|
||||
```
|
||||
|
||||
The key is scoped to the exact clean repository URL. Public Git operations keep
|
||||
the existing system/global Git configuration isolation, redirect and proxy
|
||||
disablement, and DNS/IP pinning. `GITHUB_TOKEN` uses the same header mechanism
|
||||
instead of being inserted into a clone URL. Newly cloned remote extensions do
|
||||
not copy the root `.git` directory into the installed artifact.
|
||||
|
||||
The child environment necessarily contains the short-lived header while Git is
|
||||
running. The design protects durable product state and process arguments; it
|
||||
does not claim to protect against an already-compromised same-user process that
|
||||
can inspect another process's environment or system keychain.
|
||||
|
||||
## Stored credential lifecycle
|
||||
|
||||
Stored mode uses the existing hybrid secret storage. The system keychain is
|
||||
preferred; when unavailable, the existing host/user-bound encrypted file is
|
||||
used. The staged extension contains a mode-`0600` selector with only a version,
|
||||
backend, and random secret key. The secret value is a JSON object containing
|
||||
the username and password and never enters the artifact.
|
||||
|
||||
Preparation writes the secret and selector. An artifact commit activates the
|
||||
selector; failed preparation and disposal delete an unselected secret. Update
|
||||
resolves the selector before any network access and copies a newly controlled
|
||||
selector into the replacement artifact. Missing, malformed, forged, or
|
||||
unreadable managed selectors fail with `extension_credential_unavailable`
|
||||
without modifying the installed artifact. A repository-provided selector is
|
||||
always removed before the managed selector is written.
|
||||
|
||||
Uninstall commits artifact removal first and then best-effort deletes the
|
||||
secret. Cleanup failure does not restore the artifact; it returns an
|
||||
`extension_credential_cleanup_failed` warning so an operator can remove the
|
||||
orphaned secret.
|
||||
|
||||
## One-time snapshots
|
||||
|
||||
After a one-time clone succeeds, durable install metadata is converted to the
|
||||
new `snapshot` type. Snapshot metadata contains no repository source, ref,
|
||||
commit, update flag, or credential. Catalog and status projections omit source,
|
||||
report `credentialPersistence: one_time`, and report `not updatable`. An update
|
||||
request fails with `extension_not_updatable`.
|
||||
|
||||
Telemetry uses the generic snapshot category rather than the repository URL.
|
||||
This deliberately trades updateability for the absence of a durable repository
|
||||
locator and credential.
|
||||
|
||||
## Identity compatibility
|
||||
|
||||
Each credentialed install generates a random 64-character lowercase hexadecimal
|
||||
`installId`. Stored updates retain it; one-time snapshots reload it from install
|
||||
metadata, so restart does not change activation or Agent Plugin data identity.
|
||||
Uninstall followed by reinstall creates a new id.
|
||||
|
||||
Existing metadata without `installId` continues to use the current source/name
|
||||
formula. Non-credentialed installs also keep that formula. No migration or data
|
||||
directory movement is performed.
|
||||
|
||||
## Rollout
|
||||
|
||||
The daemon advertises `extension_git_credentials`. A later WebShell change can
|
||||
gate its three-way confirmation on that capability: store and update, install
|
||||
once without updates, or cancel before sending a request. Older daemons remain
|
||||
detectable because they lack the capability and continue rejecting userinfo.
|
||||
@@ -86,6 +86,7 @@ The global surface is:
|
||||
|
||||
```text
|
||||
GET /extensions
|
||||
PUT /extensions/activation
|
||||
POST /extensions/install
|
||||
POST /extensions/check-updates
|
||||
POST /extensions/:extensionId/update
|
||||
@@ -113,6 +114,7 @@ The workspace projection is:
|
||||
|
||||
```text
|
||||
GET /workspaces/:workspace/extensions
|
||||
PUT /workspaces/:workspace/extensions/activation
|
||||
PUT /workspaces/:workspace/extensions/:extensionId/activation
|
||||
DELETE /workspaces/:workspace/extensions/:extensionId/activation
|
||||
POST /workspaces/:workspace/extensions/refresh
|
||||
@@ -211,7 +213,9 @@ runtime. The legacy operation endpoint maps V2 warning completion back to the
|
||||
published legacy refresh-error status.
|
||||
|
||||
Clients must check `extension_management_v2`; neither daemon mode nor another
|
||||
workspace capability implies this API. The abandoned
|
||||
workspace capability implies this API. Batch activation additionally requires
|
||||
`extension_batch_activation_v2`, because older V2 daemons expose only singular
|
||||
activation routes. The abandoned
|
||||
`workspace_qualified_extensions` proposal is not part of the protocol.
|
||||
|
||||
## Non-goals
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Design: readable posted reviews — plain prose; markers follow `review.attribution`
|
||||
|
||||
## Problem statement
|
||||
|
||||
A review posted by `/review --comment` is written in a template voice rather
|
||||
than a reviewer's voice. The worst offender is the inline comment body format
|
||||
dictated by the skill: a `— Failure scenario: <trigger> → <wrong outcome>`
|
||||
clause with a label and arrow notation no human would type. Template
|
||||
scaffolding costs every reader time — the failure scenario is information,
|
||||
but the label and the arrows around it are not.
|
||||
|
||||
Two other artifacts look like tells but are not: `LGTM! ✅` and the `⚠️`
|
||||
glyph are things human reviewers type constantly, and they aid scanning.
|
||||
Readability — not concealment — is the criterion, and by that criterion they
|
||||
stay.
|
||||
|
||||
## Decision: plain prose unconditionally; machine-readable markers follow `review.attribution`
|
||||
|
||||
The posted text splits into two layers, and they get different treatment:
|
||||
|
||||
- **Phrasing — the `Failure scenario:` label, the `<trigger> → <wrong
|
||||
outcome>` arrow notation, the section-header voice** — is template
|
||||
scaffolding. Plain sentences carry the same information more readably for
|
||||
_every_ audience, including the openly-attributed posts on this
|
||||
repository's own PRs. Phrasing goes plain **unconditionally**, in both
|
||||
attribution modes. No setting, no register branch: the model writes one
|
||||
style. The evidence rule is unchanged — the concrete trigger and wrong
|
||||
outcome must be in the sentences; the scaffolding is gone, the evidence
|
||||
is not.
|
||||
- **Markers — the `**[Critical]**`/`**[Suggestion]**` prefixes and the
|
||||
footer** — are machine-readable signals, not prose style:
|
||||
`qwen-autofix.yml`'s Critical-only mode greps posted bodies for
|
||||
`contains("**[Critical]**")` in a dozen places, and the prefix lets a
|
||||
human triage blockers at a glance. They stay when attribution is on and
|
||||
are stripped when it is off — attribution already decides whether the
|
||||
post identifies itself, so it decides whether the post carries the
|
||||
machine contract too.
|
||||
|
||||
No new setting. `review.attribution: false` (#8994) now means "post without
|
||||
VISIBLE AI attribution": no footer, no visible severity markers. The
|
||||
machine contract moves to an invisible severity marker
|
||||
(`<!-- qwen-review critical|suggestion -->`) that every unattributed comment
|
||||
carries — presubmit dedup and the blocker re-promotion read it — so the
|
||||
mode is not signal-free, and that is load-bearing, not an oversight.
|
||||
|
||||
Rationale over a separate `review.tone`: two registers would double the
|
||||
prompt and test surface for a phrasing that is strictly worse; the only
|
||||
honest axis is whether the post carries machine-readable markers, and that
|
||||
is exactly what attribution already governs.
|
||||
|
||||
## Current state
|
||||
|
||||
| Layer | What shapes the posted text | File |
|
||||
| ----------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Finding fields (internal state) | `FINDING_FORMAT` — `File/Anchor/Issue/Failure scenario/…` | `packages/cli/src/commands/review/agent-prompt.ts` |
|
||||
| Inline comment body (model-written) | Body format spec | `packages/core/src/skills/bundled/review/SKILL.md` (Step 7) |
|
||||
| Comment normalization at post time | Strips forged footers; appends canonical footer (attribution on) | `packages/cli/src/commands/review/submit.ts` |
|
||||
| Severity counting | `submit` counts `**[Critical]**` / `**[Suggestion]**` prefixes off the attached comments | `packages/cli/src/commands/review/lib/inline-counts.ts` |
|
||||
| Review body (deterministic) | Fixed bilingual copy, `<details>` fold | `packages/cli/src/commands/review/compose-review.ts` |
|
||||
| Settings resolution | `operatorReviewSettings()` — operator scopes only | `packages/cli/src/commands/review/lib/review-settings.ts` (#8994) |
|
||||
|
||||
Two constraints discovered during investigation:
|
||||
|
||||
- **The severity prefix is load-bearing inside the pipeline, not just for
|
||||
autofix.** `submit` derives the Critical/Suggestion counts from the comment
|
||||
prefixes (the skill forbids the caller from supplying the counts).
|
||||
De-prefixing must happen _after_ counting, at the final post transform.
|
||||
- **`agent-prompt.ts` needs no change.** Its structured format is internal
|
||||
state; only the orchestrator-composed comment bodies and the composed
|
||||
review body reach GitHub.
|
||||
|
||||
## Proposed changes
|
||||
|
||||
`attribution` already flows into `submit` and `compose-review` (#8994 wires
|
||||
it). The markers key off that same boolean; the phrasing stops being a
|
||||
template at all — no new plumbing anywhere.
|
||||
|
||||
### Deterministic (code, unit-tested)
|
||||
|
||||
1. **`submit.ts`** — when attribution is off, the posted comment bodies lose
|
||||
the leading `**[Critical]**` / `**[Suggestion]**` prefix. The strip
|
||||
happens in the final `post` object only: the payload keeps its canonical
|
||||
marked shape, so severity counting, the unmarked-comment gate, and the
|
||||
ledger all ran on the marked comments before the transform.
|
||||
2. **`compose-review.ts`** — body Criticals and the cannot-tell list keep
|
||||
their `**[Critical]**` marker when attribution is on (autofix greps it)
|
||||
and lose it when off. All other fixed copy is unchanged — `LGTM! ✅`
|
||||
and the `⚠️` clauses stay in both modes.
|
||||
|
||||
### Known tradeoffs (disclosed, accepted)
|
||||
|
||||
- The HTML-comment ledger marker (`<!-- qwen-review-ledger … -->`) still
|
||||
rides posted review bodies — invisible when rendered, but present in the
|
||||
markdown source. It is how the next review round recovers this round's
|
||||
findings; dropping it would break multi-round re-reviews. It stays.
|
||||
- Attribution-off inline comments carry an invisible severity marker
|
||||
(`<!-- qwen-review critical -->` / `<!-- qwen-review suggestion -->`) for
|
||||
the same reason: it is the one signal that survives the prefix strip and
|
||||
the footer removal. `presubmit`'s duplicate detection matches it only
|
||||
together with authorship by the reviewing account — the string is public
|
||||
and renders invisibly, so an ungated match would let a PR author plant it
|
||||
on a line they expect a blocker on and have the next round silently
|
||||
withhold that blocker. The "other accounts escape dedup" limitation from
|
||||
#8994 therefore stands. `pr-context`'s blocker promotion reads the
|
||||
marker's severity, so an unresolved Critical re-enters the re-check
|
||||
section every round even without the visible prefix.
|
||||
- `qwen-autofix`'s Critical-only mode (engaged after round 5, or earlier when a counting window's diff-growth budget trips) greps posted bodies
|
||||
for `**[Critical]**`; attribution-off findings no longer match and are
|
||||
deferred as non-Critical. Disclosed in the setting's description. A fix
|
||||
(the workflow parsing the severity marker instead) is possible follow-up,
|
||||
not this PR.
|
||||
|
||||
### Prompt layer (SKILL.md, dogfooded)
|
||||
|
||||
3. Step 7's comment-body paragraph drops the labelled template **as the
|
||||
only register**: write each description as plain reviewer prose in the
|
||||
PR's language — no `Failure scenario:` label, no `→` notation; state the
|
||||
problem, when it bites, and the fix in ordinary sentences. The evidence
|
||||
rule is unchanged (the concrete trigger and wrong outcome must be in the
|
||||
sentences). ` ```suggestion ` blocks stay (human reviewers use them).
|
||||
**The payload still carries the canonical prefixed shape** — the prefix
|
||||
is the pipeline's counting signal and stripping it is the code's job —
|
||||
so the machine-checkable contract is identical in both modes, and a
|
||||
model that ignores the prose instruction degrades to a prefixed comment,
|
||||
not a miscounted verdict.
|
||||
|
||||
## What does not change
|
||||
|
||||
- Verdict semantics, severity definitions, exclusion criteria, the reverse
|
||||
audit, presubmit, authorization. Presentation only.
|
||||
- The fixed review-body copy: `LGTM! ✅`, the `⚠️` clauses, the bilingual
|
||||
`<details>中文说明</details>` fold — humans type the first two, and the
|
||||
fold is language policy.
|
||||
- ` ```suggestion ` blocks.
|
||||
- qwen-code's own autofix: `qwen-autofix.yml` keys off prefix + footer, and
|
||||
this repository's CI reviews run with attribution on, so every string the
|
||||
workflow greps for still appears in its posts.
|
||||
- The `parse-args` verdict shape: prose style is not conditional, so the
|
||||
orchestrator has nothing to branch on.
|
||||
|
||||
## Files affected
|
||||
|
||||
| File | Change |
|
||||
| ---------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `packages/cli/src/commands/review/submit.ts` | Prefix strip in the attribution-off post transform |
|
||||
| `packages/cli/src/commands/review/compose-review.ts` | Body-list markers follow attribution |
|
||||
| `packages/cli/src/commands/review/lib/inline-counts.ts` | `stripSeverityPrefix` beside `severityOf` |
|
||||
| `packages/core/src/skills/bundled/review/SKILL.md` | Plain-prose body format as the only register |
|
||||
| `docs/users/configuration/settings.md`, `docs/users/features/code-review.md` | Widen `review.attribution` description |
|
||||
| `packages/cli/src/config/settingsSchema.ts` + regenerated IDE schema | Attribution description widened (no new key) |
|
||||
| Collocated `*.test.ts` | Pin both modes; fixed copy identical in each |
|
||||
|
||||
Base branch: `pr-8994` (the setting this couples to exists only there).
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
- No change to finding _content_ policy — only to how posted text reads.
|
||||
- No new settings key (the existing `review.attribution` description is
|
||||
widened); no settingsSchema shape change.
|
||||
- Attribution-off posts from other accounts remain undetectable to
|
||||
presubmit dedup (already documented in #8994); prefix stripping does not
|
||||
change that.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None blocking.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Web Shell backend-authoritative queue display
|
||||
|
||||
## Problem
|
||||
|
||||
The Web Shell keeps a local `unknown` queue row when a pending-prompt or
|
||||
mid-turn admission request may have reached the daemon but its response was
|
||||
lost. A later daemon refresh can then add the authoritative row beside the
|
||||
local copy, producing duplicate-looking entries and UI such as “delivery
|
||||
unknown” or “local copy discarded”.
|
||||
|
||||
## Design
|
||||
|
||||
Local rows exist only while an admission request is in flight. After the
|
||||
request settles, both admission paths query the daemon and render its queue
|
||||
snapshot. If the request or the follow-up query fails, the local row is
|
||||
removed; a later reconnect or queue event can restore it only if the daemon
|
||||
reports it.
|
||||
|
||||
Failures after dispatch follow the same rule and do not restore the payload
|
||||
into the editor. Failures before either admission request is dispatched, such
|
||||
as media upload failure, restore the draft because the daemon could not have
|
||||
accepted it.
|
||||
|
||||
The obsolete unknown-admission row state and its restore/discard UI are
|
||||
removed.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Web Shell streaming render performance
|
||||
|
||||
## Problem
|
||||
|
||||
Thinking and assistant deltas currently wake the transcript on every animation
|
||||
frame. Each accepted snapshot runs transcript projection and downstream list
|
||||
work, while the growing Markdown document is parsed again at every streaming
|
||||
flush. Although `ChatEditor` is memoized, this main-thread work still competes
|
||||
with editor input and becomes increasingly expensive as the active response
|
||||
grows.
|
||||
|
||||
## Evidence
|
||||
|
||||
The transcript projector is linear, but browser profiling with 50,000 retained
|
||||
messages attributes only 2.5% of sampled time to projection. The dominant
|
||||
104 ms long task spends 52.1 ms in `applyTurnCollapse`; repeated full-history
|
||||
derivation in `MessageList` also includes final-answer collection, agent
|
||||
grouping, pinning, and display-index generation.
|
||||
|
||||
After the tail-only path, two CPU samples reduced `applyTurnCollapse` from
|
||||
467.8 ms total self time to 26.7–51.5 ms, final-answer collection from 247.2 ms
|
||||
to 11.4–26.9 ms, grouping from 54 ms to 2.4–7.6 ms, and display-index
|
||||
generation from 67.5 ms to 3.7–12 ms. The mock SSE disconnected after replay
|
||||
in that rerun, so these samples establish hotspot reduction but are not used as
|
||||
end-to-end completion or long-task acceptance evidence.
|
||||
|
||||
Markdown has the opposite shape: every streamed append changes the complete
|
||||
source string and reparses the complete growing document. Throttling bounds how
|
||||
often that happens but not the cost of each parse.
|
||||
|
||||
## Design
|
||||
|
||||
1. Batch provider transcript events into a 16 ms macrotask window, with
|
||||
synchronous flushes before control and terminal events and when the stream
|
||||
ends. Downstream, coalesce transcript notifications and admit at most one
|
||||
snapshot every 50 ms.
|
||||
2. Defer transcript snapshots with session and block-index identities. Urgent
|
||||
editor work can commit against the previous snapshot, while session switches
|
||||
and same-session store resets immediately reject stale deferred blocks.
|
||||
3. Preserve normalized tool-content references with a `WeakMap`, allowing the
|
||||
existing row comparator's JSON cache to avoid reserializing unchanged
|
||||
historical tool output.
|
||||
4. Keep the thinking elapsed timer alive across streamed content appends.
|
||||
5. Keep live Markdown for short responses so closed charts and ordinary
|
||||
formatting retain their existing behavior. Once a streaming document
|
||||
exceeds a fixed parse budget, render its throttled source as escaped plain
|
||||
text with preserved whitespace. When streaming ends, render the complete
|
||||
Markdown once. This bounds repeated parsing while only delaying formatting
|
||||
for responses large enough to cause the observed problem.
|
||||
6. Preserve projected history object identity when every prior transcript block
|
||||
is unchanged and only the final ordinary streaming text block grows. Reuse
|
||||
completed-history `MessageList` derivations under the same narrow condition,
|
||||
replacing only the rendered tail row. Any earlier block change, terminal
|
||||
transition, tool/background update, usage change, translation change, or
|
||||
view-option change takes the existing full calculation path.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No general incremental transcript projector. Projection is not the measured
|
||||
bottleneck, and the narrow tail path avoids new invalidation machinery.
|
||||
- No incremental Markdown AST or Web Worker. Plain streaming text removes the
|
||||
repeated parse with less code and no cross-thread serialization.
|
||||
- No changes to daemon event ordering, transcript persistence, or public block
|
||||
shapes.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests cover notification coalescing, the 50 ms window, cancellation,
|
||||
session switching, stable projection identity, streamed-tail rendering and
|
||||
invalidation, stable tool normalization, timer reuse, and the
|
||||
streaming-text-to-settled-Markdown transition.
|
||||
- `npm run test:e2e:perf --workspace=@qwen-code/web-shell` deterministically
|
||||
replays 5,000 historical turns, streams 400 Markdown-heavy chunks while
|
||||
typing, verifies the final output and composer contents, and records input
|
||||
latency and browser long-task metrics in the Playwright report.
|
||||
@@ -0,0 +1,838 @@
|
||||
# Web Shell、VS Code、Desktop 与 HTML Export 统一 Chat Transcript 总体设计
|
||||
|
||||
> 文档地位:本方案的唯一规范性设计文档
|
||||
> 实施方式:两个 MR 按顺序合入
|
||||
> 当前状态:MR1 契约预验证已在当前分支准备;MR2 生产迁移尚未进入当前分支
|
||||
> 当前门禁:`overall: "fail"`,`selectedVscodePath: null`
|
||||
|
||||
## 0. 文档治理
|
||||
|
||||
本文档同时定义最终目标架构、公共契约、安全约束、两个 MR 的实施边界和退出门禁。代码虽然拆成两个 MR,但不会为 MR2 新建另一份设计文档。
|
||||
|
||||
后续规则如下:
|
||||
|
||||
1. MR1 和 MR2 的设计变更都回写本文档;
|
||||
2. fixture schema、Export JSON Schema、capability matrix 和测试报告是本文档的契约附件,不是第二份设计文档;
|
||||
3. 实施计划、E2E 记录和发布报告可以单独保存,但不能在其中重新定义本方案的模型、identity 或安全语义;
|
||||
4. 若代码与本文档冲突,以未完成设计评审处理,不能通过修改 snapshot 将冲突掩盖;
|
||||
5. 旧的统一 ChatPanel 背景方案和拆分前实现只作为历史上下文,本文档取代它们成为唯一规范来源。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
本方案不会新建独立 ChatPanel 包,也不会在第一版发布新的跨宿主消息模型。四端共享的最小运行时语义继续建立在现有 `DaemonTranscriptBlock[]` 上;`ChatTranscriptModel` 是本文档对该只读边界的逻辑名称,不要求 MR1 新增生产类型。
|
||||
|
||||
最终数据关系是:
|
||||
|
||||
```text
|
||||
native source
|
||||
→ source adapter / canonical projector
|
||||
→ ChatTranscriptModel (readonly DaemonTranscriptBlock[])
|
||||
→ live/readonly renderer
|
||||
|
||||
ChatTranscriptModel
|
||||
→ document/export allowlist projector
|
||||
→ ExportTranscriptDocumentV1
|
||||
→ WebShellTranscript document mode
|
||||
→ version-bound HTML
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- Web/Qwen Server 与 Qwen Tauri Desktop 已经使用完整 WebShell,保持现状;
|
||||
- VS Code 在 direct-daemon 与 ACP 薄转换两条路径都通过稳定 identity 探针后选择生产路径,只迁移聊天时间线;
|
||||
- HTML Export 从 `ChatRecord[]` 复用规范投影得到 `ChatTranscriptModel`,再单向转换为安全的 `ExportTranscriptDocumentV1`;
|
||||
- composer、活动权限响应、会话管理、传输、持久化和宿主副作用始终由宿主持有;
|
||||
- 默认 interactive/readonly adapter 的 `rawInput`、`rawOutput` 和现有 Turn Output 语义不得改变;
|
||||
- typed `preview`/`resultPreview` 的安全消费只在 document/export 路径启用;
|
||||
- Mermaid 的额外预算、超时和降级规则只在 document mode 启用。
|
||||
|
||||
整个工作按顺序拆成两个 MR:
|
||||
|
||||
1. **MR1:契约预验证 MR**——只落地可重复证据,允许门禁如实 FAIL;
|
||||
2. **MR2:VS Code 迁移 + HTML Export MR**——由真实消费者驱动生产改动,并在全部门禁通过后把结果翻转为 PASS。
|
||||
|
||||
## 2. 当前仓库事实与实施状态
|
||||
|
||||
### 2.1 当前生产边界
|
||||
|
||||
| 消费端 | 当前事实 | 本方案处理 |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Web/Qwen Server | daemon state 经 SDK reducer 产生 `DaemonTranscriptBlock[]`,完整 WebShell 渲染 | 保持生产路径不变;作为语义和兼容基线 |
|
||||
| Qwen Tauri Desktop | 构建并复制同一 WebShell 产物 | 不增加 Desktop adapter;MR1 不认证安装产物行为 |
|
||||
| VS Code | `QwenAgentManager` 仍以 ACP、自有消息状态和现有 Webview 时间线为主;仓库存在 daemon connection spike | MR2 选择 direct-daemon 或 ACP 薄转换,只替换时间线 |
|
||||
| HTML Export | 产品和 integration runner 仍有独立 HTML/ChatViewer 路径 | MR2 收敛到版本绑定的 `WebShellTranscript` document mode |
|
||||
| OpenWork/Craft Electron | 独立聊天实现 | 本方案范围外 |
|
||||
|
||||
当前 `WebShellTranscript`:
|
||||
|
||||
- 公开接收 `readonly DaemonTranscriptBlock[]`;
|
||||
- 固定运行在 `readonly` render mode;
|
||||
- 不连接 daemon、不提供 composer、不响应权限、不修改 session;
|
||||
- 默认 adapter 仍从 runtime block 的 raw 字段恢复完整工具展示和 Turn Output 语义;
|
||||
- 尚无 `document` render mode。
|
||||
|
||||
### 2.2 两个 MR 的状态
|
||||
|
||||
| 范围 | 当前状态 | 结论 |
|
||||
| ------------------------------------------------ | -------------------------- | ----------------------- |
|
||||
| MR1 fixtures/schema/hash/capability matrix | 当前分支已准备 | PASS |
|
||||
| ChatRecord → SDK → Web Shell 默认 adapter 等价性 | 当前分支已准备 | PASS |
|
||||
| `write_file` → Turn Output 完整 diff 回归 | 当前分支已准备 | PASS |
|
||||
| direct-daemon stable identity | partial-prepend 可重复失败 | FAIL |
|
||||
| ACP stable identity | partial-prepend 可重复失败 | FAIL |
|
||||
| VS Code 路径选择 | 前置 identity 未通过 | BLOCKED |
|
||||
| Export document schema | V1 目标 schema 已冻结 | DEFERRED implementation |
|
||||
| Export builder/document mode/HTML wiring | 当前分支无生产代码 | DEFERRED to MR2 |
|
||||
|
||||
“MR1 测试通过”表示当前事实和 FAIL blocker 能稳定复现,不表示迁移门禁已经通过。
|
||||
|
||||
## 3. 目标与非目标
|
||||
|
||||
### 3.1 目标
|
||||
|
||||
1. 冻结四端共享的最小只读 transcript 语义;
|
||||
2. 复用现有 SDK reducer 和 `projectChatRecordsToDaemonTranscript()`,不复制 replay 规则;
|
||||
3. 为 block、renderer item 和宿主动作建立可审计的稳定 identity;
|
||||
4. 让 VS Code 复用 WebShell 聊天时间线,同时保留其 composer、权限、会话和原生操作;
|
||||
5. 让 HTML Export 使用版本化、安全、资源有界且不主动联网的文档输入;
|
||||
6. 保证 Web Shell interactive/readonly 和 Tauri Desktop 不发生功能回归;
|
||||
7. 通过 fixture、hash、capability matrix 和自动化门禁使每个架构结论可重复验证;
|
||||
8. 允许 VS Code 与 HTML 两条消费路径在 MR2 内分别灰度、观察和回滚。
|
||||
|
||||
### 3.2 非目标
|
||||
|
||||
- 不新增 `@qwen-code/web-shell/chat-panel` 或新的通用 ChatPanel framework;
|
||||
- 不统一 composer、草稿、附件、队列、权限交互、会话列表或宿主导航;
|
||||
- 不要求 Web/Qwen Server 或 Tauri Desktop 新增生产 adapter;
|
||||
- 不迁移 OpenWork/Craft Electron;
|
||||
- 不把 PDF、artifact viewer 或任意宿主 overlay 纳入 transcript model;
|
||||
- 不把 action callback、transport、credential 或 session service 放入 transcript block;
|
||||
- 不让 HTML Export 获得工具执行、权限响应或会话修改能力;
|
||||
- 不以内容 hash、时间、随机数、数组下标、React key 或 DOM 位置伪造稳定 identity。
|
||||
|
||||
## 4. 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
DE["daemon events"] --> DR["SDK normalizer/reducer"]
|
||||
ACP["ACP session/update"] --> AA["ACP thin source adapter"]
|
||||
CR["ChatRecord[]"] --> RP["record export policy"]
|
||||
RP --> CP["canonical ChatRecord projector"]
|
||||
|
||||
DR --> MODEL["ChatTranscriptModel\nreadonly DaemonTranscriptBlock[]"]
|
||||
AA --> MODEL
|
||||
CP --> MODEL
|
||||
|
||||
MODEL --> WEB["Web/Qwen full WebShell"]
|
||||
MODEL --> DESKTOP["Tauri packaged WebShell"]
|
||||
MODEL --> VST["VS Code WebShellTranscript timeline"]
|
||||
MODEL --> EP["document/export allowlist projector"]
|
||||
|
||||
EP --> EDOC["ExportTranscriptDocumentV1"]
|
||||
EDOC --> VALIDATE["schema + budget validation"]
|
||||
VALIDATE --> DOC["WebShellTranscript document mode"]
|
||||
DOC --> HTML["version-bound HTML export"]
|
||||
|
||||
VSHOST["VS Code host actions/composer/session"] -. callbacks .-> VST
|
||||
```
|
||||
|
||||
### 4.1 所有权边界
|
||||
|
||||
| 层级 | 负责 | 不负责 |
|
||||
| -------------------- | ------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| source adapter | 协议归一化、source provenance、scope/generation admission | UI、宿主副作用 |
|
||||
| ChatTranscriptModel | 有序只读消息语义、稳定 block identity、展示所需层级 | composer、活动权限响应、传输、session mutation |
|
||||
| WebShellTranscript | Markdown、thinking、工具、计划、图片和只读时间线展示 | daemon 连接、持久化、权限 API |
|
||||
| VS Code host adapter | 连接路径、scope/generation、callbacks、feature flag、原生操作 | 复制聊天 renderer |
|
||||
| export projector | record policy、逐字段 allowlist、ID 重写、预算与 diagnostic | live side-channel、raw payload 透传 |
|
||||
| HTML shell | schema/version 校验、CSP、document mode、主题/打印 | 工具执行、远程 runtime 下载 |
|
||||
|
||||
宿主始终是传输、session 和副作用的事实来源。共享 renderer 不得通过 DOM 反向恢复业务状态。
|
||||
|
||||
## 5. ChatTranscriptModel 契约
|
||||
|
||||
### 5.1 最小定义
|
||||
|
||||
```ts
|
||||
import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon';
|
||||
|
||||
interface ChatTranscriptModel {
|
||||
readonly blocks: readonly DaemonTranscriptBlock[];
|
||||
}
|
||||
|
||||
interface TranscriptAdapterContext {
|
||||
readonly scopeKey: string;
|
||||
readonly generation: number;
|
||||
}
|
||||
```
|
||||
|
||||
`ChatTranscriptModel` 是逻辑契约名。除非 MR2 的真实消费者证明现有类型无法表达必需语义,否则生产代码继续直接传递 `DaemonTranscriptBlock[]`,不发布上述 wrapper。
|
||||
|
||||
版本系统必须分离:
|
||||
|
||||
- runtime model 不增加文档 schema version;
|
||||
- fixture 使用 `fixtureVersion`;
|
||||
- HTML envelope 使用 `schemaVersion`;
|
||||
- renderer 使用精确 `rendererVersion`。
|
||||
|
||||
`scopeKey` 和 `generation` 属于 adapter context,不进入 block 列表,也不进入导出文档。
|
||||
|
||||
### 5.2 必须表达的共享语义
|
||||
|
||||
| 能力 | block 语义 | 必测状态 |
|
||||
| ------------------- | ---------------------------------------- | --------------------------------------------------------- |
|
||||
| 用户/assistant 文本 | `user` / `assistant` | streaming、空 delta、usage、replay |
|
||||
| thinking/commentary | `thought` | 与 assistant 交错、结束、折叠 |
|
||||
| 图片 | text block images | 多图、非法 MIME、缺失/超限资源 |
|
||||
| 工具 | `tool` + tool identity + typed preview | pending、完成、失败、取消、并行、嵌套、后台、replay |
|
||||
| shell | `shell` / `user_shell` | stdout/stderr、增量、退出、重连 |
|
||||
| 计划/Todo | tool block 的类型化计划展示语义 | revision、priority、依赖、完成、失败 |
|
||||
| 权限历史 | `permission` | pending、approved、rejected、cancelled、expired、resolved |
|
||||
| 状态与错误 | `status` / `error` / `prompt_cancelled` | 取消、截断、不完整 replay、连接/模型错误 |
|
||||
| 未知输入 | 安全 fallback、明确排除或阻断 diagnostic | 不得静默丢失用户可见内容 |
|
||||
|
||||
计划当前不是独立 block kind。不能仅凭工具名称声明支持;fixture 必须证明用户可见的标题、步骤、状态、revision 和依赖仍存在。
|
||||
|
||||
### 5.3 不属于 model 的状态
|
||||
|
||||
- composer 内容、光标、草稿和待发送附件;
|
||||
- follow-up queue、suggestion 查询和输入模式;
|
||||
- 活动 permission response、credential 输入和 ask-user 表单状态;
|
||||
- session 列表、当前 session、branch 导航和持久化对象;
|
||||
- 文件打开、diff、URL、artifact、复制和编辑消息等宿主副作用;
|
||||
- current tool、approval mode、resync、pending shell 等 side-channel。
|
||||
|
||||
`permission` block 只描述时间线历史,不授予调用权限 API 的能力。
|
||||
|
||||
### 5.4 未知内容与完整性
|
||||
|
||||
遇到未知输入时只能选择:
|
||||
|
||||
1. 转为不泄露 raw payload 的可见安全 fallback;
|
||||
2. 按已冻结策略明确排除并记录 diagnostic;
|
||||
3. 若原本应用户可见,则标记契约缺口并阻断门禁。
|
||||
|
||||
禁止把任意 `unknown`、`meta`、`details`、`content` 或 raw object 作为逃生口。diagnostic 对外只包含 code、severity、count 和完整性标记,不能回显 prompt、token、绝对路径或工具参数。
|
||||
|
||||
## 6. Render mode 与兼容性
|
||||
|
||||
最终定义三种模式:
|
||||
|
||||
| 模式 | 输入 | raw 语义 | 交互与资源策略 |
|
||||
| ------------- | -------------------------------------------------- | ------------------------------------------ | --------------------------------------------------- |
|
||||
| `interactive` | live runtime blocks | 保持现状,以 raw 为完整工具事实来源 | 完整 WebShell 交互 |
|
||||
| `readonly` | live/replayed runtime blocks | 保持现状,以 raw 为完整工具事实来源 | 无 composer/permission response;宿主 callback 可选 |
|
||||
| `document` | `ExportTranscriptDocumentV1` 的安全 renderer input | 禁止 raw;只消费 typed safe preview/result | 无宿主动作、无虚拟化、无主动远程资源 |
|
||||
|
||||
兼容性不变量:
|
||||
|
||||
1. MR2 不改变 interactive/readonly 的 `rawInput`、`rawOutput`、`content` 和 permission `toolCall` 读取优先级;
|
||||
2. typed `preview`/`resultPreview` 即使同时存在,也不能改变默认 adapter 输出;
|
||||
3. document mode 缺少安全 typed 字段时不能回退到 raw;
|
||||
4. `write_file` Turn Output 继续优先使用完整 `tool.args.content`,不能被截断的 `preview.newText` 替代;
|
||||
5. interactive/readonly 的 Markdown、Mermaid、工具卡、折叠、虚拟化和动作行为保持现状;
|
||||
6. document mode 的限制通过独立 context/option 启用,配置缓存必须按 mode 隔离。
|
||||
|
||||
如果 `ExportTranscriptBlockV1` 不能类型安全地直接交给 `WebShellTranscript`,MR2 只允许增加一个纯函数 document adapter。该 adapter 只能把安全 DTO 映射为 renderer input,不能恢复 raw payload、复制 reducer 或演变成第二套消息模型。
|
||||
|
||||
## 7. 稳定 identity 设计
|
||||
|
||||
### 7.1 稳定域
|
||||
|
||||
在同一 `scopeKey` 和同一原生语义事件链中,同一个 block 的 identity 必须在以下操作后保持不变:
|
||||
|
||||
- React rerender;
|
||||
- 相同输入重复归约;
|
||||
- streaming delta append;
|
||||
- 完整、部分和重叠 replay;
|
||||
- reconnect;
|
||||
- 先加载尾部窗口再 prepend 更早历史;
|
||||
- timestamp 归一化;
|
||||
- 允许乱序的独立事件交换。
|
||||
|
||||
不同 session、branch、原生协议或独立导出文件不要求产生相同字符串 ID。跨 adapter 比较使用语义与 provenance,不虚构全局 ID。
|
||||
|
||||
### 7.2 scopeKey 与 generation
|
||||
|
||||
- `scopeKey` 是宿主为 session + branch 分配的稳定不透明 key;同一 session 重连保持不变,切换 session/branch 必须变化;
|
||||
- `generation` 是同一 scope 每次重新绑定 transport 时递增的本地代数;
|
||||
- `generation` 不参与 block ID;
|
||||
- event、tool update、permission、copy/edit/open-file 请求及异步结果都携带接收时的 `{scopeKey, generation}`;
|
||||
- reducer/host 在应用结果前再次比对当前 context,不匹配则丢弃;
|
||||
- 未知、bootstrapping、draining 或 removed scope 必须 fail closed,不回退到 primary/上一 session。
|
||||
|
||||
### 7.3 原生 provenance
|
||||
|
||||
| block | 首选 source identity | 缺失处理 |
|
||||
| ---------------------- | ---------------------------------------------------------- | -------------------------------- |
|
||||
| tool | `toolCallId` + scope | 无 tool identity 则失败 |
|
||||
| permission | `requestId` + scope | 无 request identity 则失败 |
|
||||
| persisted text/thought | `sourceRecordIds` + lane + 持久化 segment identity | 无法确定性得到则失败 |
|
||||
| live text/thought | prompt identity + lane + producer-stamped segment identity | 禁止用 ordinal/content hash 兜底 |
|
||||
| shell/user_shell | 权威 shell/event identity + scope | 缺失则失败 |
|
||||
| status/error/cancelled | 权威 event identity + scope | 缺失则失败或并入已有稳定 block |
|
||||
|
||||
event cursor 只表示传输顺序。对由多个 delta 合并的文本 block,cursor 会随最后一个 delta 改变,因此不能单独作为 segment identity。
|
||||
|
||||
### 7.4 segment identity 规则
|
||||
|
||||
MR2 在 source producer/admission 边界建立 segment identity:
|
||||
|
||||
1. 同一 streaming segment 的多个 delta 复用同一 `segmentId`;
|
||||
2. user、assistant、thought 和 sub-agent lane 分开;
|
||||
3. tool/permission/离散消息边界结束当前 text segment;
|
||||
4. persisted replay 保留 record-derived segment identity,不能在 replay adapter 中重新编号;
|
||||
5. direct-daemon envelope 和 ACP update 都必须把 source identity 带到 normalizer;
|
||||
6. 不同 `segmentId` 的相邻文本不能仅因当前窗口相邻而合并成同一 identity block;
|
||||
7. 缺少 stable prompt/record/segment 来源时输出阻断 diagnostic,不猜测补齐。
|
||||
|
||||
稳定 block ID 由版本化确定性函数从 `{scopeKey, blockKind, nativeSourceIdentity}` 派生。父子 block 引用必须同步重写。默认 Web/Tauri reducer 的 ordinal runtime ID 可保持兼容;稳定投影只进入明确需要它的 VS Code adapter/probe,除非后续单独证明全局替换无回归。
|
||||
|
||||
### 7.5 当前 FAIL 证据
|
||||
|
||||
MR1 的 read-only probe 使用当前 `normalizeDaemonEvent` 和 `reduceDaemonTranscriptEvents`:
|
||||
|
||||
1. 完整输入从空 state 归约;
|
||||
2. 去掉首个历史片段后重新归约同一尾部;
|
||||
3. 通过语义 key 对齐同一 block;
|
||||
4. 比较当前 block ID,并记录 source provenance 是否存在。
|
||||
|
||||
当前结果:
|
||||
|
||||
| Candidate | partial-prepend | 原生文本 identity | MR1 gate |
|
||||
| ------------- | --------------------- | --------------------------- | -------- |
|
||||
| direct-daemon | ordinal block ID 漂移 | user/thought/assistant 缺失 | FAIL |
|
||||
| ACP | ordinal block ID 漂移 | user/thought/assistant 缺失 | FAIL |
|
||||
|
||||
MR2 合入前,两条候选都必须运行完整 identity matrix 并通过;若要永久放弃其中一条,必须先在本文档中记录范围变更与理由,不能只从测试中删除失败候选。
|
||||
|
||||
## 8. Renderer item 与宿主动作 identity
|
||||
|
||||
稳定 block ID 只是必要条件。renderer 会合并 assistant 文本、合组相邻工具并嵌套 thought/sub-agent,因此一个 block 不一定对应一个 DOM 节点。
|
||||
|
||||
测试和宿主接缝使用以下逻辑证据:
|
||||
|
||||
```ts
|
||||
interface TranscriptRenderedItemEvidence {
|
||||
readonly renderedItemId: string;
|
||||
readonly sourceBlockIds: readonly string[];
|
||||
readonly sourceToolCallIds: readonly string[];
|
||||
readonly capabilities: readonly (
|
||||
| 'copy'
|
||||
| 'copy-all'
|
||||
| 'copy-last-reply'
|
||||
| 'edit-user-message'
|
||||
| 'open-file'
|
||||
)[];
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `renderedItemId` 来自稳定 source IDs、tool call identity 和固定分组边界;
|
||||
- 工具分组不能仅依赖“当前窗口中的相邻位置”;缺少稳定 batch/turn 边界时宁可不跨边界合组;
|
||||
- 合并后的所有 `sourceBlockIds` 必须保留,每个 tool call 可单独寻址;
|
||||
- identity 在 rerender、折叠、虚拟化开关、partial-prepend 和 replay 后不变;
|
||||
- semantic copy 从 renderer 展示模型生成,不抓取当前挂载 DOM;
|
||||
- copy、edit、open-file 等动作把稳定目标交还宿主,宿主执行前再次校验 scope/generation;
|
||||
- streaming 文本增长可以改变 semantic copy hash,但不能改变同一 segment 的 item identity;
|
||||
- React key、DOM 顺序、数组下标和可见窗口不能成为业务 identity。
|
||||
|
||||
MR2 只增加由失败 fixture 证明必要的最小 callback/handle,不能借此创建通用宿主框架。
|
||||
|
||||
## 9. 四端适配设计
|
||||
|
||||
### 9.1 Web/Qwen Server
|
||||
|
||||
- 继续使用 daemon reducer 和完整 WebShell;
|
||||
- 不新增生产 adapter;
|
||||
- 作为 interactive/readonly raw 兼容基线;
|
||||
- MR2 的 document/identity 改动必须运行其回归测试,不能改变默认输出。
|
||||
|
||||
### 9.2 Qwen Tauri Desktop
|
||||
|
||||
- 继续打包同一 WebShell build、assets 和 library dist;
|
||||
- 不新增 Desktop transcript fork;
|
||||
- MR1 通过源码 wiring 断言建立基础证据;
|
||||
- 最终桌面发布仍需安装产物 smoke,但不阻塞 MR1。
|
||||
|
||||
### 9.3 VS Code direct-daemon 候选
|
||||
|
||||
```text
|
||||
daemon events
|
||||
→ SDK normalizer/reducer
|
||||
→ stable identity projection
|
||||
→ scoped ChatTranscriptModel
|
||||
→ WebShellTranscript readonly timeline
|
||||
```
|
||||
|
||||
必须验证:loopback/auth/workspace scope、session 生命周期、SSE replay、permission ownership、VSIX bundle、CSP、sidebar/editor tab、callbacks 和 feature flag。direct-daemon 不能因仓库已有 connection spike 就自动成为生产路径。
|
||||
|
||||
### 9.4 VS Code ACP 候选
|
||||
|
||||
```text
|
||||
ACP session/update
|
||||
→ thin source normalizer
|
||||
→ SDK transcript reducer
|
||||
→ stable identity projection
|
||||
→ scoped ChatTranscriptModel
|
||||
→ WebShellTranscript readonly timeline
|
||||
```
|
||||
|
||||
ACP adapter 只做协议归一化和 provenance 传递:
|
||||
|
||||
- 不重新实现 Markdown、tool、plan 或 permission 展示;
|
||||
- 不把 ACP session object 交给 renderer;
|
||||
- 不接管 composer、权限响应、session 管理或原生文件操作;
|
||||
- live 与 history 必须共享同一 segment identity 语义;
|
||||
- 迟到 update 与异步动作结果按 scope/generation 丢弃。
|
||||
|
||||
### 9.5 VS Code 选型规则
|
||||
|
||||
两条候选先使用相同 fixture、identity matrix 和 render/action probe。MR2 只能选择满足以下条件的路径:
|
||||
|
||||
1. stable identity 全部通过;
|
||||
2. 现有 composer、permission、session 和 host action 边界保持不变,或范围变更已单独评审;
|
||||
3. VSIX bundle、CSP 和三平台行为通过;
|
||||
4. 能在 feature flag 关闭时完整回退 legacy timeline。
|
||||
|
||||
ACP 是当前生产基线,因此在两条路径同等可行时优先 ACP 薄转换;这不是 MR1 的预选结果。最终选择及舍弃理由写入 capability matrix 和本文档状态表。
|
||||
|
||||
### 9.6 HTML Export
|
||||
|
||||
```text
|
||||
ChatRecord[]
|
||||
→ record-level export policy
|
||||
→ projectChatRecordsToDaemonTranscript()
|
||||
→ ChatTranscriptModel
|
||||
→ safe document projector
|
||||
→ ExportTranscriptDocumentV1
|
||||
→ schema/budget validation
|
||||
→ WebShellTranscript document mode
|
||||
→ version-bound HTML
|
||||
```
|
||||
|
||||
HTML 路径不能复制 ChatRecord replay/reducer,也不能把完整 `ChatRecord`、`DaemonTranscriptState` 或 runtime blocks 直接序列化进文件。
|
||||
|
||||
## 10. ExportTranscriptDocumentV1
|
||||
|
||||
### 10.1 角色
|
||||
|
||||
`ExportTranscriptDocumentV1` 是 `ChatTranscriptModel` 的单向安全派生物:
|
||||
|
||||
```text
|
||||
ExportTranscriptDocumentV1 = projectForDocument(ChatTranscriptModel)
|
||||
```
|
||||
|
||||
它是版本化文档 DTO,不是第二套运行时 transcript model,也不回流到 live session。
|
||||
|
||||
```ts
|
||||
interface ExportTranscriptDocumentV1 {
|
||||
readonly schemaVersion: 1;
|
||||
readonly rendererVersion: string;
|
||||
readonly blocks: readonly ExportTranscriptBlockV1[];
|
||||
readonly diagnostics: readonly {
|
||||
readonly code: string;
|
||||
readonly severity: 'info' | 'warning' | 'error';
|
||||
readonly count: number;
|
||||
}[];
|
||||
readonly metadata: ExportMetadataPresentationV1;
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 两层安全投影
|
||||
|
||||
顺序不能调换:
|
||||
|
||||
1. **record-level policy**:分类用户可见、内部控制、system 和未知记录;不得改写顺序、parent/branch 或因果链;
|
||||
2. **post-projection allowlist**:对规范 blocks 逐 kind 新建安全对象,不使用 spread 后删黑名单。
|
||||
|
||||
若拒绝的记录是后续可见记录的必要因果节点,导出标记不完整或直接失败,不能重连 parent 伪造会话。
|
||||
|
||||
### 10.3 block 字段 allowlist
|
||||
|
||||
JSON Schema 以 `additionalProperties: false` 封闭每种 block:
|
||||
|
||||
| block | V1 允许字段 |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 所有 kind | 文档内 opaque `id`、`kind`;三个 block 时间字段固定为 `0` |
|
||||
| `user`/`assistant`/`thought` | `text`、安全 raster images、`collapsed`、安全 parent 引用、数值 allowlist 后的 usage;`streaming=false` |
|
||||
| `tool` | opaque `toolCallId`、title、终态 status、安全 toolName/toolKind、typed `preview`/`resultPreview`、安全父子引用、短 `subagentType` |
|
||||
| `shell` | `text`、`stream` |
|
||||
| `user_shell` | `text`、`command`、脱敏 workspace-relative `cwd`、`stream` |
|
||||
| `permission` | opaque `requestId`、title、label/description、`raw:null` options、typed preview、opaque tool identity、安全 resolved 终态 |
|
||||
| `status`/`error` | text、已知 code/errorKind、安全 source |
|
||||
| `prompt_cancelled` | 安全 reason |
|
||||
| `debug` | V1 默认拒绝;产生 code/count diagnostic |
|
||||
|
||||
以下字段永不直接导出:
|
||||
|
||||
- `eventId`、`serverTimestamp`、`sourceRecordIds`、`promptId`、`branchRecordId`;
|
||||
- tool 的 `content`、`locations`、`details`、`rawInput`、`rawOutput`;
|
||||
- permission 的 `sessionId`、`toolCall` 和原始 response token;
|
||||
- status 的 `data`、`debugReason`;
|
||||
- credential、环境变量、绝对用户路径、原始 session ID/label、任意 metadata bag。
|
||||
|
||||
### 10.4 typed tool projection
|
||||
|
||||
- runtime `preview` 进入文档前必须按 kind 重新构造并递归应用长度、URL、path、image 和数组限制;
|
||||
- `ask_user_question`/permission 的 `raw` 固定为 `null`;
|
||||
- tool 完成后只写入安全 `resultPreview`,不能写入 raw result;
|
||||
- 新的 unsafe、oversized 或无法分类的 tool update 必须清除同一 call 上旧的安全 result preview,避免 stale preview 泄漏;
|
||||
- Plan/Todo 只保留 id、content、status、priority、blockedBy、planId 和 revision 等 allowlist 字段;
|
||||
- document adapter 只消费 typed preview/result;缺失时输出安全占位与 diagnostic,不能回退 raw;
|
||||
- interactive/readonly adapter 继续使用既有 raw 语义。
|
||||
|
||||
### 10.5 opaque ID 与引用
|
||||
|
||||
原生 ID 不直接进入导出文档。projector 按最终 canonical traversal 为 block、tool、permission 和父子引用建立文档内 opaque 映射,并同步重写所有引用:
|
||||
|
||||
- 映射对同一输入确定;
|
||||
- 不写入原始到 opaque 的反向表;
|
||||
- 导出文件之间不承诺 ID 稳定;
|
||||
- duplicate、悬空或循环引用使文档失败或明确降级;
|
||||
- opaque ID 受字符集和长度限制。
|
||||
|
||||
这套 ID 与 live stable identity 是两个域:前者服务单个离线文档,后者服务同一 session scope 的重放和动作寻址。
|
||||
|
||||
### 10.6 metadata
|
||||
|
||||
```ts
|
||||
interface ExportMetadataPresentationV1 {
|
||||
readonly title?: string;
|
||||
readonly startedAt?: string;
|
||||
readonly exportedAt: string;
|
||||
readonly complete: boolean;
|
||||
readonly truncated: boolean;
|
||||
readonly projectName?: string;
|
||||
readonly repository?: string;
|
||||
readonly gitBranch?: string;
|
||||
readonly model?: string;
|
||||
readonly channel?: string;
|
||||
readonly promptCount?: number;
|
||||
readonly contextUsagePercent?: number;
|
||||
readonly contextWindowSize?: number;
|
||||
readonly totalTokens?: number;
|
||||
readonly filesWritten?: number;
|
||||
readonly linesAdded?: number;
|
||||
readonly linesRemoved?: number;
|
||||
}
|
||||
```
|
||||
|
||||
metadata 逐字段构造:
|
||||
|
||||
- `projectName` 只能是 basename 或显式安全标签;
|
||||
- 不导出绝对 cwd、uniqueFiles、原始 session ID/label 或用户目录;
|
||||
- 字符串有长度/字符策略;计数是非负安全整数;
|
||||
- block 时间归零,`exportedAt` 只用于文档元数据,不能参与 identity 或语义快照。
|
||||
|
||||
### 10.7 资源与计算预算
|
||||
|
||||
| 预算 | V1 上限 |
|
||||
| ----------------------------------- | ------------------------------------------------------- |
|
||||
| transcript blocks | 1,000 |
|
||||
| 单文本字段 | 400 KiB UTF-8;schema `maxLength` 另设 409,600 结构上限 |
|
||||
| 全部可见文本 | 8 MiB UTF-8 |
|
||||
| 单 raster 解码后 | 8 MiB |
|
||||
| 全部 raster 解码后 | 16 MiB |
|
||||
| JSON envelope | 32 MiB UTF-8 |
|
||||
| JSON/preview depth | 16 |
|
||||
| 单数组元素 | 1,000 |
|
||||
| 单对象属性 | 1,000 |
|
||||
| Mermaid/chart/diff/highlight 富任务 | 合计 100,超出显示源码 fallback |
|
||||
|
||||
JSON Schema 无法表达 UTF-8 总字节、总文本、总图片和 envelope 预算,因此 Node builder 与浏览器 validator 都必须执行显式预算检查;schema `maxLength` 只是结构上限,不能替代字节预算。
|
||||
|
||||
资源规则:
|
||||
|
||||
- 仅允许 `image/png`、`image/jpeg`、`image/gif`、`image/webp` 的受限 base64 raster;V1 拒绝动画内容;
|
||||
- 拒绝 SVG、HTML、JavaScript、`file:`、`blob:`、远程图片和非预期 data URI;
|
||||
- Markdown 图片与结构化 images 共用 MIME/来源/字节策略;
|
||||
- code、diff、shell、command 和普通文本中的 URL 字面量只作为文本,不触发资源加载;
|
||||
- 外部链接仅在明确用户点击时导航,去除 credential,并按策略处理 query/fragment;
|
||||
- 超限时在富解析前输出安全占位和 diagnostic,不继续容错解析危险内容。
|
||||
|
||||
### 10.8 document mode
|
||||
|
||||
document mode 必须:
|
||||
|
||||
- 关闭虚拟化和内部滚动,确保全文查找、选择、复制和打印覆盖全部 blocks;
|
||||
- 默认展开 thinking、plan、工具组、并行 agent、sub-agent 及嵌套工具;
|
||||
- 关闭 composer、权限响应、retry、branch、session navigation 和宿主 action;
|
||||
- 只读取 `ExportTranscriptDocumentV1` 的安全 renderer input;
|
||||
- Markdown 远程图片不请求网络,危险 HTML/SVG 不执行;
|
||||
- Mermaid 限制、超时和 fallback 只在 document mode 启用,不能污染 interactive/readonly 的全局配置或缓存;
|
||||
- Mermaid、代码高亮、diff 和 chart 失败时保留可复制源码;
|
||||
- 不加载需要 `unsafe-eval`、远程 WASM、远程 grammar、字体或动态 renderer 的资源。
|
||||
|
||||
### 10.9 CSP 与零网络
|
||||
|
||||
HTML 使用与 CLI build 精确绑定的 renderer。禁止 `latest`、版本范围和运行时远程解析。
|
||||
|
||||
最低安全要求:
|
||||
|
||||
- `default-src 'none'`;
|
||||
- `connect-src 'none'`、`object-src 'none'`、`frame-src 'none'`、`media-src 'none'`;
|
||||
- `base-uri 'none'`、`form-action 'none'`;
|
||||
- images 只允许批准的 `data:` 或明确登记的同包资源;
|
||||
- script/style 使用 nonce/hash 或等价静态策略;若现有 React 需要 style attribute,只允许 `style-src-attr` 的最小例外,DTO 不接受 style 字段;
|
||||
- V1 优先内联 renderer 必需资源;打开本地 HTML 后不得产生未登记 subrequest;
|
||||
- 浏览器测试拦截打开、展开、Markdown/Mermaid、主题和打印期间的全部请求;任何未登记请求或 CSP violation 立即失败。
|
||||
|
||||
### 10.10 失败、完整性与 canary
|
||||
|
||||
- sanitizer error 不得回显被拒绝值;
|
||||
- 安全降级记录 code/severity/count;
|
||||
- 因预算或安全策略替换原本可见内容时:`truncated=true`、`complete=false`;
|
||||
- 富 renderer 失败但完整安全源码仍在时,可以 `complete=true`,同时记录 warning;
|
||||
- 删除 raw 后工具结果、计划、权限历史只剩不等价摘要时,正向 fixture 和 MR2 gate 失败;
|
||||
- envelope、HTML、CSP、diagnostic 和旁路资源都执行字节级 canary scan;
|
||||
- duplicate ID、危险 URL/image、cycle、object-property flood、非法 metadata、schema widening 或超限后继续渲染都失败。
|
||||
|
||||
### 10.11 版本规则
|
||||
|
||||
- 字段、kind、默认值、allowlist、metadata、CSP、MIME/URL 策略或资源预算的安全语义变化都递增 `schemaVersion`;
|
||||
- 已发布 schema 不能原地改义;
|
||||
- `rendererVersion` 必须是精确 CLI 版本或内容 hash,禁止 `latest`、`~`、`^`、比较范围和 `*`;
|
||||
- renderer 明确声明支持的 schema 版本;不兼容时显示只读错误页,不宽松解析;
|
||||
- schema 升级保留上一已发布版本的读取和安全 canary 测试,直到对应 CLI 退出支持;
|
||||
- `fixtureVersion` 只描述测试录制格式,不代替 export schema version。
|
||||
|
||||
## 11. Fixture、schema 与 capability matrix
|
||||
|
||||
### 11.1 MR1 当前目录
|
||||
|
||||
```text
|
||||
integration-tests/fixtures/chat-transcript-contract/v1/
|
||||
├── capability-matrix.md
|
||||
├── cases/
|
||||
│ └── representative/
|
||||
│ ├── manifest.json
|
||||
│ ├── daemon-events.jsonl
|
||||
│ ├── acp-session-updates.jsonl
|
||||
│ ├── chat-records.jsonl
|
||||
│ ├── expected-model.json
|
||||
│ ├── expected-render-items.json
|
||||
│ ├── expected-export.json
|
||||
│ └── expected-gate.json
|
||||
└── schema/
|
||||
├── manifest.schema.json
|
||||
└── export-transcript-document-v1.schema.json
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 只使用确定性合成数据,不采集真实用户会话;
|
||||
- ID、时间、cwd、用户名、URL、token 和图片使用固定测试值;
|
||||
- manifest 记录 source、consumer、capability、expected diagnostics 和所有 case/schema SHA-256;
|
||||
- 普通测试只验证,不自动覆盖 fixture;
|
||||
- gate report 是测试运行产物,不写回 hash fixture;
|
||||
- `complete: true` 表示 fixture case 完整,不代表迁移 gate PASS。
|
||||
|
||||
### 11.2 MR2 必须扩展的场景
|
||||
|
||||
1. streaming 多 delta、重复/乱序、完整/部分/重叠 replay;
|
||||
2. multi-delta partial-prepend,distinct segment adjacency;
|
||||
3. 并行/嵌套工具、shell、后台 agent、失败/取消/迟到完成;
|
||||
4. Plan/Todo revision、priority、依赖及安全 result preview;
|
||||
5. permission 全生命周期且无原始 `toolCall` 仍可安全显示历史;
|
||||
6. render 合并/分组/折叠/虚拟化下的 source mapping 和 actions;
|
||||
7. scope 切换、generation 递增和迟到 event/action/result;
|
||||
8. raw、credential、绝对路径、危险 URL/image、unknown/system/debug canary;
|
||||
9. Markdown 远程资源、HTML/SVG/data URI、重定向和嵌套链接;
|
||||
10. 每项预算边界/超限与最大文档 find/copy/print/performance;
|
||||
11. 正向 Markdown、code、diff、LaTeX、Mermaid、tool summary 和 metadata;
|
||||
12. `write_file` 同时存在完整 raw content 与截断 preview;
|
||||
13. document → readonly/interactive mode 切换后 Mermaid 配置无泄漏。
|
||||
|
||||
### 11.3 capability matrix
|
||||
|
||||
MR1 的紧凑矩阵每项记录 Capability、当前 source/path、Fixture/Evidence、Owner 和 Gate;其中 path 合并 native source 与当前 mapping,evidence 合并 contract/render 证据。MR2 增加真实消费者时再拆出 Consumers 和 Render/action mapping,不能用省略列隐藏未验证能力。
|
||||
|
||||
required 项不能以 `unknown`、`TBD`、人工截图或“测试能运行”通过。PASS、FAIL、BLOCKED、DEFERRED 必须分别使用,不能把预计后续修复写成当前 PASS。
|
||||
|
||||
## 12. 两个 MR 的实施边界
|
||||
|
||||
### 12.1 MR1:契约预验证 MR
|
||||
|
||||
只包含:
|
||||
|
||||
- 本文档;
|
||||
- fixtures、schemas、hash 和 capability matrix;
|
||||
- 测试目录内 direct-daemon/ACP/read-only probes;
|
||||
- ChatRecord → SDK → 默认 Web Shell adapter 等价性测试;
|
||||
- `write_file` → Turn Output 完整 diff 回归;
|
||||
- 必要的 integration test alias/config、no-AK CI wiring;
|
||||
- 为 integration TypeScript program 补齐的显式 `.js` ESM import specifier;这些改动不得改变 Web Shell runtime 语义。
|
||||
|
||||
明确不包含:
|
||||
|
||||
- daemon/ACP 协议或 source emitter 新字段;
|
||||
- SDK/CLI/Web Shell/VS Code 的生产 pipeline;
|
||||
- stable identity production projector;
|
||||
- `ExportTranscriptDocumentV1` builder;
|
||||
- `document` render mode、Mermaid/CSP/browser probe;
|
||||
- VS Code timeline 迁移或路径选择;
|
||||
- HTML exporter wiring。
|
||||
|
||||
MR1 验收:测试通过,同时 `expected-gate.json` 保持 `overall: "fail"`、两候选 FAIL、`selectedVscodePath: null`。
|
||||
|
||||
### 12.2 MR2:VS Code 迁移 + HTML Export MR
|
||||
|
||||
MR2 中每项生产代码必须有真实消费者。实施顺序:
|
||||
|
||||
1. **identity source**:在 producer/admission 边界建立并持久化 direct-daemon/ACP segment provenance;
|
||||
2. **stable projection**:实现 scope-keyed block 与 parent reference 投影,完成两候选 identity matrix;
|
||||
3. **render/action seam**:补齐稳定 item/source mapping 和最小 VS Code callbacks;
|
||||
4. **VS Code timeline**:按选型接入 `WebShellTranscript`,保留现有 composer/permission/session/host actions;
|
||||
5. **safe tool projection**:实现 document-only typed preview/result,保持 runtime raw 兼容;
|
||||
6. **export builder**:实现 record policy、canonical projection、allowlist、opaque ID、metadata、budget 和 diagnostics;
|
||||
7. **document mode**:实现非虚拟化/只读/无动作 renderer,Mermaid 限制仅在此 mode;
|
||||
8. **HTML wiring**:CLI 和 integration runner 复用同一产品模板及版本绑定 renderer;
|
||||
9. **browser/security gates**:CSP、零网络、canary、最大预算和版本失败测试;
|
||||
10. **gate flip**:真实消费者和全部测试通过后,更新 expected gate 与 capability matrix。
|
||||
|
||||
MR2 不能只修改 `expected-gate.json` 或恢复拆分前整包代码。应按上述消费者顺序选择性迁移备份实现,并重新对照当前 `main`。
|
||||
|
||||
## 13. 验证架构与测试矩阵
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
INPUTS["daemon / ACP / ChatRecord fixtures"] --> SEM["semantic projection"]
|
||||
INPUTS --> IDS["block identity matrix"]
|
||||
SEM --> RENDER["renderer item/action probe"]
|
||||
SEM --> EXPORT["export allowlist projector"]
|
||||
EXPORT --> SCHEMA["schema + budget + canary"]
|
||||
SCHEMA --> BROWSER["document browser probe"]
|
||||
RENDER --> HOSTS["Web / Tauri / VS Code"]
|
||||
BROWSER --> HTML["HTML Export"]
|
||||
IDS --> GATE{"overall gate"}
|
||||
RENDER --> GATE
|
||||
HOSTS --> GATE
|
||||
HTML --> GATE
|
||||
```
|
||||
|
||||
### 13.1 MR1 已执行验证
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run typecheck
|
||||
cd integration-tests && npx tsc -p tsconfig.json --pretty false
|
||||
npx vitest run chat-transcript-contract.test.ts
|
||||
cd ../packages/web-shell && npx vitest run client/components/artifacts/turnOutputSelectors.test.ts
|
||||
```
|
||||
|
||||
MR1 证明:fixture/hash/schema 可重复、默认 raw runtime 兼容、Turn Output 完整 diff 不回归,以及两条 identity blocker 可重复。
|
||||
|
||||
MR1 不以源码文本断言认证 Desktop 打包行为。Web/Tauri 的现有构建检查继续作为回归信号;安装产物中 Web Shell 文件布局与可加载性的行为 smoke 属于 MR2 Packaging gate,在当前矩阵中保持 DEFERRED。
|
||||
|
||||
### 13.2 MR2 必须验证
|
||||
|
||||
| 范围 | 必须覆盖 |
|
||||
| -------------- | ------------------------------------------------------------------------------------- |
|
||||
| SDK/source | segment provenance、append/prepend/replay、parent refs、默认 ordinal 兼容 |
|
||||
| ACP | live/history 同 identity、缺失 provenance fail closed、迟到 update |
|
||||
| VS Code | direct/ACP probes、选定路径、scope/generation、callbacks、feature flag、legacy parity |
|
||||
| Web Shell | interactive/readonly raw 兼容、document safe-only、render/action identity |
|
||||
| Export builder | record policy、per-kind allowlist、opaque IDs、metadata、diagnostic、version |
|
||||
| Browser | schema failure、zero network、CSP、canary、find/copy/print、最大预算 |
|
||||
| Packaging | Web/Tauri regression、VSIX 三平台、CLI renderer 版本绑定、integration runner 收敛 |
|
||||
|
||||
Passing test 也必须反向审计:测试是否断言了正确语义、是否加载当前构建产物、是否真的覆盖真实消费者,不能用静态 source assertion 替代浏览器或 VSIX 行为验证。
|
||||
|
||||
## 14. 门禁
|
||||
|
||||
### 14.1 共享语义门禁
|
||||
|
||||
- required capability 都有 source、mapping、owner、fixture 和结论;
|
||||
- unknown/incomplete 输入有安全 fallback、明确排除或阻断;
|
||||
- 工具、计划、权限历史删除 raw 后在 document mode 仍保留等价可见语义;
|
||||
- interactive/readonly 默认 adapter 不消费 document-only result projection;
|
||||
- 没有宿主对象、callback 或 transport 进入 model。
|
||||
|
||||
### 14.2 identity/action 门禁
|
||||
|
||||
- direct-daemon 与 ACP 的 append、partial-prepend、replay、reconnect 和乱序矩阵通过;
|
||||
- 每个 block 有 source provenance;
|
||||
- rendered item 保留完整 source block/tool mapping;
|
||||
- copy/edit/open-file 不依赖 DOM 或数组位置;
|
||||
- scope/generation 切换丢弃全部迟到 event/action/result;
|
||||
- 缺失 identity 时 fail closed。
|
||||
|
||||
### 14.3 Export 安全门禁
|
||||
|
||||
- record policy 和每个 block kind 的 allowlist 实现并校验;
|
||||
- raw、credential、绝对路径、session identity 和危险资源负向测试通过;
|
||||
- envelope/HTML/CSP/diagnostic/resources canary 零命中;
|
||||
- 打开、展开、富渲染、主题和打印期间零未登记网络请求、零 CSP violation;
|
||||
- 所有预算边界和超限 fixture 通过;
|
||||
- 正常 Markdown、code、diff、LaTeX、Mermaid、tool/plan/permission 与 metadata 不被过度删除;
|
||||
- schema/renderer 不兼容安全失败。
|
||||
|
||||
### 14.4 最终结论
|
||||
|
||||
MR1 的正确结论是 FAIL evidence:
|
||||
|
||||
```json
|
||||
{
|
||||
"overall": "fail",
|
||||
"selectedVscodePath": null
|
||||
}
|
||||
```
|
||||
|
||||
MR2 只有在上述三组门禁和真实消费者验证全部通过后才能改为 PASS。任何 required 组失败都阻断 MR2 合入;不能人工豁免,也不能先 assert false、合入生产代码后在同一证据缺失状态下只把期望改成 true。
|
||||
|
||||
## 15. 发布、观察与回滚
|
||||
|
||||
### 15.1 VS Code
|
||||
|
||||
- 新时间线受独立 feature flag 控制;
|
||||
- legacy timeline 在 pre-release 和观察期内保留;
|
||||
- 比较相同录制会话的状态、动作、截图、性能和错误;
|
||||
- flag 关闭必须完整回退 legacy,不改变 session 数据;
|
||||
- 观察期和删除证据完成后再移除 legacy timeline。
|
||||
|
||||
### 15.2 HTML Export
|
||||
|
||||
- 新 exporter 与旧 exporter 可独立切换;
|
||||
- 生成失败不覆盖已有文件;
|
||||
- schema/version 错误显示安全只读页面;
|
||||
- integration runner 与产品模板在新路径稳定后再删除重复 renderer;
|
||||
- 回滚 renderer 不得放宽已经发布的 schema 安全语义。
|
||||
|
||||
### 15.3 Web 与 Desktop
|
||||
|
||||
Web/Qwen 和 Tauri 不迁移。若 MR2 对共享组件的改动导致默认模式回归,应回滚 MR2,而不是为两端增加兼容 adapter。
|
||||
|
||||
## 16. 风险与控制
|
||||
|
||||
| 风险 | 控制 |
|
||||
| ------------------------------------ | ------------------------------------------------------- |
|
||||
| 证据 MR 膨胀成生产实现 | MR1 文件范围白名单;生产变更全部留给 MR2 真实消费者 |
|
||||
| 测试模型变成第二套 model | `ChatTranscriptModel` 只命名现有 blocks;不发布 wrapper |
|
||||
| ordinal ID 在简单 replay 中假稳定 | 强制 multi-delta、partial-prepend、overlap replay |
|
||||
| block ID 稳定但 render/action 不稳定 | 单独 item/source/action probe |
|
||||
| ACP 与 direct 只验证一条 | 两候选共用 matrix;删除候选必须更新本文档 |
|
||||
| document projection 污染 runtime | mode 隔离;默认 raw compatibility tests |
|
||||
| preview 截断破坏 Turn Output | `write_file` 完整 raw content 回归 |
|
||||
| raw/metadata 泄漏 | 两层 allowlist、closed schema、canary 和字节扫描 |
|
||||
| Markdown/图片绕过网络策略 | 统一资源 policy、CSP 和浏览器全请求拦截 |
|
||||
| Mermaid 全局配置污染 | 仅 document context 启用限制,缓存按 mode 隔离 |
|
||||
| document 无虚拟化导致资源耗尽 | builder/browser 双预算、源码 fallback、最大文档测试 |
|
||||
| snapshot update 掩盖 blocker | hash、显式 fixture diff、gate 由测试生成 |
|
||||
| 备份实现与最新 main 漂移 | MR2 选择性迁移并重新审计,不整包恢复 |
|
||||
|
||||
## 17. 完成定义
|
||||
|
||||
整个总体设计完成需要同时满足:
|
||||
|
||||
- MR1 已合入且稳定保存 PASS/FAIL/DEFERRED 证据;
|
||||
- direct-daemon 与 ACP identity 验证达到本文档门禁;
|
||||
- VS Code 已选择并实现一条路径,其时间线使用 WebShell UI,现有 composer、permission、session 和 host actions 保持边界;
|
||||
- HTML Export 使用 canonical projector、`ExportTranscriptDocumentV1`、版本绑定 renderer 和 document mode;
|
||||
- HTML 产品路径与 integration runner 不再维护第二套 renderer;
|
||||
- Web/Qwen Server 和 Tauri Desktop 默认行为无回归;
|
||||
- security、network、budget、CSP、version、VSIX/CLI packaging 和观察期完成;
|
||||
- legacy HTML renderer 与 VS Code timeline 只有在各自有删除证据时才移除;
|
||||
- 未引入新的跨宿主 ChatPanel 包、通用消息模型或 OpenWork overlay;
|
||||
- 后续任何公共契约变化继续更新本文档,不创建平行设计来源。
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
针对 [#8321](https://github.com/QwenLM/qwen-code/issues/8321) 的实现方案。初始实现由
|
||||
`48d1e1d69` 落地,review 修正由 `afb55ebae` 补齐 admission、恢复和资源边界。
|
||||
其中队列展示和 admission 失败语义已由
|
||||
[Web Shell backend-authoritative queue display](../web-shell-backend-authoritative-queue-display.md)
|
||||
取代;下文相关内容仅保留为历史记录。
|
||||
|
||||
该功能只补齐 Web Shell composer 的图片拖放入口,并复用现有图片粘贴、
|
||||
附件预览、prompt 提交和多模态模型链路。daemon wire format、ACP、Core 和公开
|
||||
|
||||
@@ -370,8 +370,7 @@ when daemon doesn't explicitly stamp provenance, MCP tools are detectable.
|
||||
## Debug reason categorization
|
||||
|
||||
`DaemonUiStatusEvent.debugReason` is a closed-enum the normalizer stamps
|
||||
when it projects a `debug` block instead of a typed event (mirrored onto
|
||||
`DaemonStatusTranscriptBlock` for transcript consumers):
|
||||
when it projects a `debug` event instead of a typed event:
|
||||
|
||||
```ts
|
||||
import type { DaemonUiDebugReason } from '@qwen-code/sdk/daemon';
|
||||
@@ -385,8 +384,18 @@ diagnostics rather than conversation content. `malformed_*` means a frame
|
||||
the SDK _does_ know arrived with an unusable payload — a real defect
|
||||
signal.
|
||||
|
||||
Renderers should branch on `debugReason`, not the debug text — the text
|
||||
prefix is diagnostic wording and changes without notice:
|
||||
**Routing differs by category.** `unrecognized_*` diagnostics are routed
|
||||
to the bounded `unrecognizedDiagnostics` sidechannel and never enter
|
||||
`blocks[]` (so they cannot finalize a streaming assistant/thought block or
|
||||
consume the `maxBlocks` budget). Read them with
|
||||
`selectUnrecognizedDiagnostics`; the cap is `UNRECOGNIZED_DIAGNOSTICS_LIMIT`
|
||||
and the routed subset is `DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS`.
|
||||
`malformed_*` diagnostics — and legacy blocks persisted before this split —
|
||||
stay in the transcript as `DaemonStatusTranscriptBlock`s, so block-level
|
||||
`debugReason` handling now applies to those only.
|
||||
|
||||
Renderers filtering blocks should branch on `debugReason`, not the debug
|
||||
text — the text prefix is diagnostic wording and changes without notice:
|
||||
|
||||
```ts
|
||||
function hideDebugBlock(reason?: DaemonUiDebugReason): boolean {
|
||||
@@ -407,7 +416,8 @@ Every layer in the daemon UI SDK follows the **forward-compat principle**:
|
||||
unknown values do NOT throw; they degrade gracefully.
|
||||
|
||||
- Unknown daemon event types → `debug` event with the raw type name,
|
||||
stamped with an `unrecognized_*` `debugReason` (see above)
|
||||
stamped with an `unrecognized_*` `debugReason` and routed to the bounded
|
||||
`unrecognizedDiagnostics` sidechannel (see above)
|
||||
- Unknown tool status → `currentToolCallId` left untouched (no clear)
|
||||
- Unknown error kind → `errorKind` undefined (renderer falls back to text)
|
||||
- Missing serverTimestamp → falls back to `clientReceivedAt`
|
||||
|
||||
@@ -120,6 +120,8 @@ Workspace read-only snapshots: `workspace_mcp`, `workspace_skills`, `workspace_p
|
||||
|
||||
Extension management: `extension_management_v2` adds the global `/extensions/*` catalog/mutation/operation contract and the workspace activation projection. It is separate from the published `workspace_extensions` compatibility surface and from `workspace_qualified_rest_core`.
|
||||
|
||||
V2 Extension batch activation: `extension_batch_activation_v2` adds queued global default-activation and selected-workspace override batches to `extension_management_v2`. Clients must pre-flight it independently because older V2 daemons expose only singular activation routes.
|
||||
|
||||
Workspace-qualified session reads: `workspace_persisted_transcript`, `workspace_session_export`, `workspace_archived_session_export`, `workspace_session_live_state`. The active and archived export tags are independent from each other and from `session_export` and `workspace_qualified_rest_core`, so clients must pre-flight the exact storage state they intend to export. Persisted transcript paging permits an untrusted secondary under its bounded read policy; both full export paths remain trusted-only. `workspace_session_live_state` is likewise independent from `workspace_qualified_rest_core` and is trusted-only: it serves the selected runtime's memory-only live-session snapshot and catalog version and does not extend the untrusted-secondary persisted read policy to live bridge state.
|
||||
|
||||
Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_acp_preheat`, `workspace_tool_toggle`, **`workspace_settings`** (conditional), `workspace_permissions`, `workspace_init`, `workspace_github_setup`, `workspace_trust`, `workspace_mcp_restart`, `workspace_mcp_manage`, `workspace_file_read`, `workspace_file_bytes`, `workspace_file_read_cursor`, `workspace_file_write`, `workspace_file_upload`, **`workspace_reload`** (conditional).
|
||||
|
||||
@@ -167,6 +167,26 @@ await client
|
||||
|
||||
`DaemonSkillBatchToggleResult` contains ordered successful `results`, per-target `errors`, and batch-level activation/session-refresh counts. The daemon persists valid targets together and refreshes active sessions once; one expected target error does not block other valid targets. The method throws only on a non-200 response; a 200 does not mean every target was applied, so always inspect `errors` before treating the batch as successful.
|
||||
|
||||
V2 Extension batch activation retains the asynchronous Extension operation model. Pre-flight `extension_batch_activation_v2`, submit a global default batch or a selected-workspace override batch, then poll it with the existing operation helper:
|
||||
|
||||
```ts
|
||||
const globalHandle = await client.setExtensionDefaultActivations(
|
||||
['formatter', 'review-tools'],
|
||||
'disabled',
|
||||
'dashboard-1',
|
||||
);
|
||||
const workspaceHandle = await client
|
||||
.workspaceByCwd('/work/secondary')
|
||||
.setExtensionActivations(
|
||||
['formatter', 'review-tools'],
|
||||
'inherit',
|
||||
'dashboard-1',
|
||||
);
|
||||
const operation = await client.waitForExtensionOperation(workspaceHandle);
|
||||
```
|
||||
|
||||
The terminal operation result contains ordered `results`. Targets do not need to be installed when setting `enabled` or `disabled`: the daemon stores a name declaration and preserves that activation policy when an Extension with that name is installed later. All changed targets share one Extension Store generation and one reconciliation pass. Global default batches reconcile every registered runtime; workspace batches resolve and reconcile only the selected trusted runtime. Workspace `inherit` clears the exact override but does not create a declaration for an unknown name; an all-unknown clear succeeds as a no-op without reconciliation. Singular activation methods remain installed-only.
|
||||
|
||||
Workspace display names are optional presentation metadata. Pre-flight `capabilities.features.includes('workspace_display_name')`; workspace ids and canonical paths remain the only selectors, and duplicate display names are valid.
|
||||
|
||||
```ts
|
||||
@@ -392,7 +412,7 @@ When `workspace_session_export` is advertised, `client.workspaceById(workspaceId
|
||||
|
||||
When `workspace_archived_session_export` is advertised, use `client.workspaceById(workspaceId).exportArchivedSession(sessionId, { format })` or the corresponding `workspaceByCwd` method to export only the selected workspace's archived persisted transcript. The method uses the same result type and native REST behavior as active export, but it never falls back to an active session; support cannot be inferred from any active export capability.
|
||||
|
||||
When `workspace_session_live_state` is advertised, `client.getWorkspaceSessionLiveState(workspaceCwd)` or the scoped `client.workspaceById(workspaceId).getSessionLiveState()` / `client.workspaceByCwd(workspaceCwd).getSessionLiveState()` reads the selected trusted workspace's memory-only live-session snapshot plus its catalog version, returning `DaemonWorkspaceSessionLiveState` (`{ v: 1, catalogVersion: DaemonSessionCatalogVersion, sessions: DaemonSessionLiveState[] }`). These methods always use native REST with bearer authentication and an encoded workspace selector, preserve optional client identity, and use the existing short-request timeout. They do not call `requireCapability()` — a capability probe on every poll would double request volume — so consumers pre-flight `workspace_session_live_state` once from their already-loaded capabilities and fall back to existing catalog polling when the tag is absent. Do not infer support from `workspace_qualified_rest_core`.
|
||||
When `workspace_session_live_state` is advertised, `client.getWorkspaceSessionLiveState(workspaceCwd)` or the scoped `client.workspaceById(workspaceId).getSessionLiveState()` / `client.workspaceByCwd(workspaceCwd).getSessionLiveState()` reads the selected trusted workspace's memory-only live-session snapshot plus its catalog version, returning `DaemonWorkspaceSessionLiveState` (`{ v: 1, catalogVersion: DaemonSessionCatalogVersion, sessions: DaemonSessionLiveState[] }`). These methods always use native REST with bearer authentication and an encoded workspace selector, preserve optional client identity, and use the existing short-request timeout. They do not call `requireCapability()` — a capability probe on every poll would double request volume — so consumers pre-flight `workspace_session_live_state` once from their already-loaded capabilities and fall back to existing catalog polling when the tag is absent. Do not infer support from `workspace_qualified_rest_core`. Each `DaemonSessionLiveState` carries an optional `updatedAt` activity watermark that lets a consumer refresh the recency of a catalog row it already holds instead of reloading the catalog after a completed turn; it is absent before the first running-turn terminal in the current bridge and after a daemon or runtime replacement, so a consumer must keep its existing catalog fallback for a missing value rather than treating absence as unsupported.
|
||||
|
||||
### Seeding `lastEventId` at Construction
|
||||
|
||||
|
||||
@@ -53,20 +53,20 @@ This page collects every setting that affects the `qwen serve` daemon and its ad
|
||||
|
||||
### Read by `runQwenServe` / Express middleware
|
||||
|
||||
| Env | Effect |
|
||||
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `QWEN_SERVER_TOKEN` | Bearer token; trimmed at boot. |
|
||||
| `QWEN_SERVE_DEBUG` | `1` / `true` / `on` / `yes` (case-insensitive) enables verbose stderr logs. See [`19-observability.md`](./19-observability.md). |
|
||||
| `QWEN_SERVE_NO_MCP_POOL` | `1` disables the workspace MCP transport pool and falls back to per-session `McpClientManager`; capabilities stop advertising `mcp_workspace_pool` / `mcp_pool_restart`. |
|
||||
| `QWEN_SERVE_PROMPT_DEADLINE_MS` | Env fallback for `--prompt-deadline-ms`. |
|
||||
| `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` | Env fallback for `--writer-idle-timeout-ms`. |
|
||||
| `QWEN_SERVE_RATE_LIMIT` | `1` / `true` enables per-tier HTTP rate limiting; CLI `--rate-limit` / `--no-rate-limit` wins. |
|
||||
| `QWEN_SERVE_RATE_LIMIT_PROMPT` | Env fallback for `--rate-limit-prompt`. |
|
||||
| `QWEN_SERVE_RATE_LIMIT_MUTATION` | Env fallback for `--rate-limit-mutation`. |
|
||||
| `QWEN_SERVE_RATE_LIMIT_READ` | Env fallback for `--rate-limit-read`. |
|
||||
| `QWEN_SERVE_RATE_LIMIT_WINDOW_MS` | Env fallback for `--rate-limit-window-ms`. |
|
||||
| `QWEN_SERVE_NEW_FILE_MODE` | New-file mode policy for daemon text writes: `owner` (default — NEW files are created `0600`, umask-independent) or `system` (NEW files follow `0o666 & ~umask`). Case-insensitive; the literal `0600` is accepted as an alias for `owner` (no other octal modes are supported), and unrecognized values warn on stderr and keep the `0600` default. Existing files always preserve their mode. See [`qwen-serve.md` — New-file mode for agent text writes](../../users/qwen-serve.md#new-file-mode-for-agent-text-writes). |
|
||||
| `QWEN_CODE_MEMORY_PROJECT_SCOPE` | `workspace` keys project memory by the exact workspace dir; `git-root` selects the legacy shared scope. When unset, the daemon injects `workspace`; unrecognized values warn once and retain the legacy `git-root` behavior. Propagates via the runtime base env, not `childEnvOverrides`; `--memory-project-scope` wins. Each workspace remember/forget/dream lane caps pending tasks at `MAX_PENDING = 16`; N workspaces allow up to 16·N queued tasks with no daemon-wide cap. |
|
||||
| Env | Effect |
|
||||
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `QWEN_SERVER_TOKEN` | Bearer token; trimmed at boot. |
|
||||
| `QWEN_SERVE_DEBUG` | `1` / `true` / `on` / `yes` (case-insensitive) enables verbose stderr logs. See [`19-observability.md`](./19-observability.md). |
|
||||
| `QWEN_SERVE_NO_MCP_POOL` | `1` disables the workspace MCP transport pool and falls back to per-session `McpClientManager`; capabilities stop advertising `mcp_workspace_pool` / `mcp_pool_restart`. |
|
||||
| `QWEN_SERVE_PROMPT_DEADLINE_MS` | Env fallback for `--prompt-deadline-ms`. |
|
||||
| `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` | Env fallback for `--writer-idle-timeout-ms`. |
|
||||
| `QWEN_SERVE_RATE_LIMIT` | `1` / `true` enables per-tier HTTP rate limiting; CLI `--rate-limit` / `--no-rate-limit` wins. |
|
||||
| `QWEN_SERVE_RATE_LIMIT_PROMPT` | Env fallback for `--rate-limit-prompt`. |
|
||||
| `QWEN_SERVE_RATE_LIMIT_MUTATION` | Env fallback for `--rate-limit-mutation`. |
|
||||
| `QWEN_SERVE_RATE_LIMIT_READ` | Env fallback for `--rate-limit-read`. |
|
||||
| `QWEN_SERVE_RATE_LIMIT_WINDOW_MS` | Env fallback for `--rate-limit-window-ms`. |
|
||||
| `QWEN_SERVE_NEW_FILE_MODE` | New-file mode policy for daemon text writes: `owner` (default — NEW files are created `0600`, umask-independent) or `system` (NEW files follow `0o666 & ~umask`). Case-insensitive; the literal `0600` is accepted as an alias for `owner` (no other octal modes are supported), and unrecognized values warn on stderr and keep the `0600` default. Existing files always preserve their mode. See [`qwen-serve.md` — New-file mode for agent text writes](../../users/qwen-serve.md#new-file-mode-for-agent-text-writes). |
|
||||
| `QWEN_CODE_MEMORY_PROJECT_SCOPE` | `workspace` keys project memory by the exact workspace dir; `git-root` selects the legacy shared scope. When unset, the daemon injects `workspace`; unrecognized values warn once and retain the legacy `git-root` behavior. Propagates via the runtime base env, not `childEnvOverrides`; `--memory-project-scope` wins. Each workspace remember/forget/dream lane caps pending tasks at `MAX_PENDING = 16`; N workspaces allow up to 16·N queued tasks with no daemon-wide cap. |
|
||||
|
||||
Blank `QWEN_CODE_MEMORY_PROJECT_SCOPE` values are treated as unset and therefore default to `workspace`; unrecognized non-empty values still warn once and retain the legacy `git-root` behavior.
|
||||
|
||||
|
||||
@@ -403,6 +403,45 @@ established the principle: "telemetry's scope of work doesn't include
|
||||
sending identifiers to LLM providers"; correlation-header work moves to
|
||||
its own design discussion rather than landing under telemetry.
|
||||
|
||||
## Inbound correlation (daemon HTTP API)
|
||||
|
||||
The daemon HTTP API accepts the standard W3C `traceparent` header on every
|
||||
request. Two consumers read it independently:
|
||||
|
||||
- **Request span re-parenting (telemetry enabled).** When the telemetry SDK
|
||||
is initialized, a valid header is extracted as the request span's remote
|
||||
parent, so daemon spans attach under the caller's trace instead of
|
||||
starting a new one. The `_meta` forwarding path reads the same parent
|
||||
chain, so session subprocess spans forwarded through a daemon request
|
||||
inherit it too.
|
||||
- **Access-log `traceId` field (both modes).** A dedicated pre-auth capture
|
||||
middleware parses the header on every request — including ones
|
||||
short-circuited at auth (401), the rate limiter (429), the JSON body
|
||||
parser (400), or never matched by any route (404) — and the access log
|
||||
emits the caller trace id as a camelCase `traceId` field. With telemetry
|
||||
disabled this field is the only join between a daemon log line and the
|
||||
caller's logs (or trace backend), so one saved query works for both modes
|
||||
with no telemetry configuration.
|
||||
|
||||
An invalid-but-present header is rejected (the span stays parentless) and
|
||||
leaves a rate-limited DEBUG breadcrumb
|
||||
(`qwen-code.daemon.traceparent.invalid`) recording the rejected value, so a
|
||||
broken cross-service join is diagnosable from daemon logs alone.
|
||||
|
||||
### Forced sampling under inbound parents
|
||||
|
||||
Under the default `parentbased_always_on` sampler (and other parentbased
|
||||
defaults), a remote parent's `sampled=0` flag is a head-based decision on
|
||||
the caller's side, not a request to drop daemon telemetry, so extraction
|
||||
forces the SAMPLED flag on inbound parents. The only opt-out is
|
||||
`OTEL_TRACES_SAMPLER=parentbased_always_off`, which honors the caller's
|
||||
flags — note it also disables root-span sampling for the whole daemon, not
|
||||
just inbound-linked requests.
|
||||
|
||||
**Warning:** a constant `traceparent` (e.g. hardcoded in a load-test
|
||||
client) re-parents every daemon request into one single trace; generate a
|
||||
fresh header per request.
|
||||
|
||||
## Aliyun Telemetry
|
||||
|
||||
### Manual OTLP Export
|
||||
|
||||
@@ -198,6 +198,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
|
||||
'workspace_file_upload',
|
||||
'session_approval_mode_control', 'workspace_tool_toggle', 'workspace_skill_toggle',
|
||||
'workspace_skill_batch_toggle',
|
||||
'extension_batch_activation_v2',
|
||||
'workspace_settings', 'workspace_init', 'workspace_mcp_restart',
|
||||
'session_recap', 'session_generation', 'session_btw', 'session_shell_command',
|
||||
'mcp_workspace_pool', 'mcp_pool_restart',
|
||||
@@ -210,7 +211,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
|
||||
'multi_workspace_session_shell', 'persistent_workspace_registration',
|
||||
'workspace_display_name',
|
||||
'workspace_qualified_rest_core', 'workspace_qualified_voice',
|
||||
'workspace_qualified_memory', 'extension_management_v2',
|
||||
'workspace_qualified_memory', 'extension_management_v2', 'extension_git_credentials',
|
||||
'workspace_persisted_transcript',
|
||||
'workspace_session_export', 'workspace_archived_session_export',
|
||||
'workspace_session_live_state',
|
||||
@@ -241,7 +242,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
|
||||
|
||||
`workspace_archived_session_export` advertises `GET /workspaces/:workspace/session/:id/archive/export`, a trusted-only full export from the selected workspace's archived persisted storage. It is independent of `workspace_session_export` and `workspace_qualified_rest_core`; clients must pre-flight this tag directly. A distinct route prevents an older daemon from ignoring archive intent and returning an active transcript with the same id.
|
||||
|
||||
`workspace_session_live_state` advertises `GET /workspaces/:workspace/sessions/live-state`, a trusted-only, memory-only snapshot of the selected workspace runtime's live sessions plus an in-memory catalog version that tells clients when a full persisted-catalog reload is warranted. It is independent of `workspace_qualified_rest_core`: released daemons can advertise the broader workspace REST capability without implementing this route, so clients must pre-flight this tag directly. The tag is unconditional because a trusted single-workspace primary can use the route by id or cwd; per-workspace trust checks still apply on every request, and the route does not extend the permissive untrusted-secondary persisted-catalog read policy to live bridge state.
|
||||
`workspace_session_live_state` advertises `GET /workspaces/:workspace/sessions/live-state`, a trusted-only, memory-only snapshot of the selected workspace runtime's live sessions plus an in-memory catalog version that tells clients when a full persisted-catalog reload is warranted. It is independent of `workspace_qualified_rest_core`: released daemons can advertise the broader workspace REST capability without implementing this route, so clients must pre-flight this tag directly. The tag is unconditional because a trusted single-workspace primary can use the route by id or cwd; per-workspace trust checks still apply on every request, and the route does not extend the permissive untrusted-secondary persisted-catalog read policy to live bridge state. The tag means the endpoint exists; it does not promise that every live item carries the optional `updatedAt` activity watermark, which is lifecycle-dependent.
|
||||
|
||||
`slow_client_warning` covers SSE backpressure behavior: (a) the daemon emits a `slow_client_warning` synthetic event-stream frame when a subscriber's live frame backlog or live serialized-byte backlog crosses 75% full, once per overflow episode (rearmed after both measurements drain below 37.5%); (b) `GET /session/:id/events` accepts a `?maxQueued=N` query param (range `[16, 2048]`) to pre-size the per-subscriber frame backlog for cold reconnects against a large replay ring. The serialized-byte cap is daemon-owned (default **2 MiB** per subscriber), live-only, and intentionally has no query parameter. The daemon-wide ring size is controlled by `--event-ring-size` (default **8000**, per #3803 §02). Old daemons silently lack the warning/query behavior — pre-flight this tag before opting in.
|
||||
|
||||
@@ -263,11 +264,11 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
|
||||
|
||||
`session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status.
|
||||
|
||||
`session_status` advertises `GET /session/:id/status`, the live bridge summary for a single session by id. In addition to `clientCount` and `hasActivePrompt`, live sessions expose `isWaitingForPermission`, `isWaitingForUserQuestion`, `pendingInteractionCount`, and a retained `turnError` after a failed turn. The error clears when the next prompt actually starts. Both the single-session status response and workspace session lists include `turnError` and `pendingInteractions`: render-ready permission actions or `ask_user_question` questions plus the `requestId` and selectable options required by the existing permission vote routes. Each user question has an `answerKey`; vote with `answers`, for example `{ "0": "Polling" }`, keyed by that value. Persisted-only sessions omit runtime state because no runtime exists. Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list.
|
||||
`session_status` advertises `GET /session/:id/status`, the live bridge summary for a single session by id. In addition to `clientCount` and `hasActivePrompt`, live sessions expose `isWaitingForPermission`, `isWaitingForUserQuestion`, `pendingInteractionCount`, and a retained `turnError` after a failed turn. The error clears when the next prompt actually starts. A live session that has settled a running turn in the current bridge also carries `updatedAt`, the same activity watermark documented under the live-state route; because this route returns the bridge summary directly, the value is not merged with the persisted transcript mtime and may be earlier than the one a session list reports. Both the single-session status response and workspace session lists include `turnError` and `pendingInteractions`: render-ready permission actions or `ask_user_question` questions plus the `requestId` and selectable options required by the existing permission vote routes. Each user question has an `answerKey`; vote with `answers`, for example `{ "0": "Polling" }`, keyed by that value. Persisted-only sessions omit runtime state because no runtime exists. Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list.
|
||||
|
||||
`session_info` advertises `GET /workspace/:id/session-info` and its `/workspaces/:workspace/session-info` twin. The response aggregates persisted active and archived session counts without hydrating list metadata. It is an explicit O(n) disk scan and must not be polled; clients should treat `truncated: true` as a lower-bound result.
|
||||
|
||||
`session_approval_mode_control`, `workspace_tool_toggle`, `workspace_skill_toggle`, `workspace_skill_batch_toggle`, `workspace_init`, and `workspace_mcp_restart` advertise the mutation control routes documented below. They are strict-gated by the mutation gate (a daemon configured without a bearer token rejects them with 401 `token_required`). Older daemons return `404`; pre-flight each tag before exposing the corresponding affordance.
|
||||
`session_approval_mode_control`, `workspace_tool_toggle`, `workspace_skill_toggle`, `workspace_skill_batch_toggle`, `extension_batch_activation_v2`, `workspace_init`, and `workspace_mcp_restart` advertise the mutation control routes documented below. They are strict-gated by the mutation gate (a daemon configured without a bearer token rejects them with 401 `token_required`). Older daemons return `404`; pre-flight each tag before exposing the corresponding affordance.
|
||||
|
||||
`mcp_guardrails` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14) covers the MCP budget surface: the `clientCount` / `clientBudget` / `budgetMode` / `budgets[]` fields on `GET /workspace/mcp`, the `disabledReason` field on per-server cells, and the `--mcp-client-budget` / `--mcp-budget-mode` CLI flags. Older daemons omit the new fields entirely; SDK clients pre-flight this tag before relying on `budgets[]` semantics. The registry descriptor also carries `modes: ['warn', 'enforce']` for future feature-modes exposure — for now, clients infer mode from the snapshot's `budgetMode` field. Server refusal under `enforce` mode is deterministic by `Object.entries(mcpServers)` declaration order; a future scope-precedence layer (if qwen-code adopts one) would shift this to "lowest-precedence first" to mirror claude-code's `plugin < user < project < local` convention.
|
||||
|
||||
@@ -293,6 +294,10 @@ The same tag also exposes workspace-qualified project-agent CRUD at `/workspaces
|
||||
|
||||
`extension_management_v2` advertises a user-level extension catalog and mutation surface at `/extensions/*`, plus workspace activation projections at `/workspaces/:workspace/extensions/*`. Artifacts are global; workspace routes expose only projection reads, exact activation overrides, and runtime refresh. Reads may target an untrusted registered workspace, while activation, refresh, and workspace-scoped install require a trusted target. Slow mutations use daemon-local operations at `/extensions/operations/:operationId`; store generation, not operation history, is authoritative across restart and across daemons. The published `workspace_extensions` capability and `/workspace/extensions/*` routes remain a primary-workspace compatibility adapter. Clients must preflight `extension_management_v2` and must not infer it from daemon mode or `workspace_qualified_rest_core`.
|
||||
|
||||
`extension_git_credentials` advertises authenticated HTTPS Git installs on both `POST /workspace/extensions/install` and `POST /extensions/install`. Clients must preflight this tag before sending URL userinfo or `credentialPersistence`; older daemons reject URL credentials. The tag describes backend protocol support, not the availability of a keychain: stored mode reports the selected backend in the terminal operation result.
|
||||
|
||||
`extension_batch_activation_v2` adds `PUT /extensions/activation` and `PUT /workspaces/:workspace/extensions/activation`. Both accept 1–100 names in `extensionNames`, deduplicate them case-insensitively while preserving first-seen order, persist changed targets in one generation, and return one `202` operation handle. A target does not need to be installed when setting `enabled` or `disabled`: its name creates a desired-state declaration that is preserved when an Extension with that name is installed. The global route accepts `state: "enabled" | "disabled"`, writes V2 `defaultActivation`, and reconciles every registered runtime. The workspace route also accepts `"inherit"`, applies or clears exact overrides for the selected trusted runtime, and reconciles only that runtime. `inherit` does not declare an unknown name; an all-unknown clear reports `updated: false` and skips reconciliation. Singular activation routes remain installed-only and id-addressed.
|
||||
|
||||
### Extension Management V2 wire contract
|
||||
|
||||
All routes use the daemon bearer authentication rules above. `X-Qwen-Client-Id` is optional for the V2 mutation routes; when supplied, it must identify a client registered with one of the mutation's target workspace runtimes. `:extensionId` is the lowercase 64-hex extension identity. `:workspace` resolves as an exact workspace id first and otherwise as a URL-encoded absolute cwd after canonicalization.
|
||||
@@ -300,6 +305,7 @@ All routes use the daemon bearer authentication rules above. `X-Qwen-Client-Id`
|
||||
| Method and path | Success |
|
||||
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| `GET /extensions` | `200` global artifact catalog |
|
||||
| `PUT /extensions/activation` | `202` global default-activation batch operation |
|
||||
| `PUT /extensions/:extensionId/activation` | `202` global default-activation operation |
|
||||
| `POST /extensions/install` | `202` install operation |
|
||||
| `POST /extensions/check-updates` | `202` update-check operation |
|
||||
@@ -307,6 +313,7 @@ All routes use the daemon bearer authentication rules above. `X-Qwen-Client-Id`
|
||||
| `DELETE /extensions/:extensionId` | `202` uninstall operation, or idempotent `204` when the extension is absent |
|
||||
| `GET /extensions/operations/:operationId` | `200` operation snapshot |
|
||||
| `GET /workspaces/:workspace/extensions` | `200` workspace activation projection |
|
||||
| `PUT /workspaces/:workspace/extensions/activation` | `202` exact workspace-activation batch operation |
|
||||
| `PUT /workspaces/:workspace/extensions/:extensionId/activation` | `202` exact workspace-activation operation |
|
||||
| `DELETE /workspaces/:workspace/extensions/:extensionId/activation` | `202` clear-override operation |
|
||||
| `POST /workspaces/:workspace/extensions/refresh` | `202` runtime-refresh operation |
|
||||
@@ -374,6 +381,10 @@ Install requires explicit consent and an initial activation:
|
||||
|
||||
For workspace-only initial activation use `{ "scope": "workspace", "workspaceId": "target-workspace-id" }`; the target must exist and be trusted. Daemon installs accept GitHub, Git, and npm sources. `ref` does not apply to npm, and `registry` applies only to npm. `ref`, `autoUpdate`, `allowPreRelease`, and `registry` are optional.
|
||||
|
||||
When `extension_git_credentials` is advertised, an HTTPS Git source may include userinfo, for example `https://username:[email protected]/org/repository.git`. `credentialPersistence` is valid only with such a source. It is `stored` or `one_time` and defaults to `one_time` when omitted. Stored mode saves the credential through the daemon's hybrid secret storage and keeps only the clean repository URL in install metadata, so the extension remains updatable. One-time mode saves neither the repository URL nor the credential and creates a non-updatable `snapshot`; `autoUpdate: true` is rejected for this mode. Supplying the field without URL credentials, supplying invalid credentials, or using credentials with npm, archive, local, SSH, or non-Git sources returns `400`.
|
||||
|
||||
Credentialed install responses and operations expose `credentialPersistence` and may expose `credentialStorage` as `keychain` or `encrypted_file`. One-time operations omit `source`; stored operations may return the clean source. Snapshot catalog/status entries omit source, set `credentialPersistence` to `one_time`, and report `not updatable`. Update fails with `extension_not_updatable`; an unavailable stored secret fails before network access with `extension_credential_unavailable`.
|
||||
|
||||
Global and workspace activation `PUT` requests use the same body:
|
||||
|
||||
```json
|
||||
@@ -382,6 +393,17 @@ Global and workspace activation `PUT` requests use the same body:
|
||||
|
||||
`state` is `enabled` or `disabled`. Update, uninstall, check-updates, clear-activation, and refresh requests have no required body.
|
||||
|
||||
Batch activation requests use Extension names:
|
||||
|
||||
```json
|
||||
{
|
||||
"extensionNames": ["formatter", "review-tools"],
|
||||
"state": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
The workspace batch also accepts `"state": "inherit"`. Terminal global results contain `name` and `defaultActivation`; workspace results contain `name`, `workspaceActivation` (`null` for inherit), and `effectiveActivation`. Malformed names reject the request; conflicts with existing Store identities fail atomically without a partial commit. An unknown `inherit` target is not persisted, because clearing an override must not manufacture a default-activation declaration or replace later install consent.
|
||||
|
||||
Every accepted asynchronous mutation returns:
|
||||
|
||||
```http
|
||||
@@ -411,7 +433,7 @@ An operation snapshot has this shape:
|
||||
}
|
||||
```
|
||||
|
||||
`status` transitions from `queued` to `running`, then to `succeeded`, `succeeded_with_warnings`, or `failed`. While running, `phase` is `preparing`, `committing`, or `reconciling`. Terminal success may include `result` with `status` equal to `installed`, `enabled`, `disabled`, `updated`, `uninstalled`, `checked`, or `refreshed`; reconciliation results can additionally contain `refreshed`, `failed`, and `error`. Update checks return `result.states`, keyed by extension name, with values such as `checking for updates`, `update available`, `up to date`, `not updatable`, or `error`.
|
||||
`status` transitions from `queued` to `running`, then to `succeeded`, `succeeded_with_warnings`, or `failed`. While running, `phase` is `preparing`, `committing`, or `reconciling`. Terminal success may include `result` with `status` equal to `installed`, `enabled`, `disabled`, `updated`, `uninstalled`, `checked`, or `refreshed`; reconciliation results can additionally contain `refreshed`, `failed`, and `error`, while batch activation results contain ordered `results`. Update checks return `result.states`, keyed by extension name, with values such as `checking for updates`, `update available`, `up to date`, `not updatable`, or `error`. Credentials and authorization headers are never operation fields.
|
||||
|
||||
A durable commit followed by incomplete cleanup or runtime reconciliation is not reported as a failed mutation. It returns `succeeded_with_warnings` and preserves the committed result:
|
||||
|
||||
@@ -442,7 +464,7 @@ A durable commit followed by incomplete cleanup or runtime reconciliation is not
|
||||
|
||||
Warning `workspaceId` and `code` are optional; `workspaceCwd` and `error` are always present. Clients should display warnings, refresh their catalog/projection, and must not retry the durable mutation blindly.
|
||||
|
||||
Validation and authorization failures are synchronous HTTP errors using `{ "error": "...", "code": "..." }` when a stable code exists. Important cases are `400 invalid_extension_id`, `400 invalid_extension_activation`, `400 workspace_mismatch`, `403 untrusted_workspace`, `404 extension_operation_not_found`, and `429 extension_queue_full`. Install validation also returns `400` for invalid source/ref/registry options, missing consent, or missing/invalid initial activation. A mutation that fails after `202` is represented, while retained in operation history, with `status: "failed"`, `error`, and an optional stable `code`; common codes include `extension_prepare_timeout` and `extension_conflict`. HTTP `404` for an operation does not imply rollback because operation history is not durable.
|
||||
Validation and authorization failures are synchronous HTTP errors using `{ "error": "...", "code": "..." }` when a stable code exists. Important cases are `400 invalid_extension_id`, `400 invalid_extension_names`, `400 invalid_extension_name`, `400 invalid_extension_activation`, `400 workspace_mismatch`, `403 untrusted_workspace`, `404 extension_operation_not_found`, and `429 extension_queue_full`. Install validation also returns `400` for invalid source/ref/registry options, missing consent, or missing/invalid initial activation. A mutation that fails after `202` is represented, while retained in operation history, with `status: "failed"`, `error`, and an optional stable `code`; common codes include `extension_prepare_timeout` and `extension_conflict`. HTTP `404` for an operation does not imply rollback because operation history is not durable.
|
||||
|
||||
`daemon_status` advertises `GET /daemon/status`, the consolidated read-only
|
||||
operator diagnostic snapshot documented below.
|
||||
@@ -671,6 +693,8 @@ runtime routes return `503`.
|
||||
|
||||
`runtime.memory.children` is additive within that block and reports aggregate RSS across the children `childRssCoverage` names: `rssBytes` (their summed self-reported RSS), `sampled` (how many produced a reading), and `oldestReadingAgeMs` (the age of the oldest reading in the sum, so a caller can tell how far apart its parts were taken). The denominator for `sampled` is the sibling `activeAcpChildren`, not repeated inside the block; when `sampled` is lower, `rssBytes` is a floor rather than a total. Sampling is gated on an active SSE/WS watcher, so a status request against a daemon nobody is streaming from reports `sampled: 0` even with live children — `activeAcpChildren` beside it makes that gap visible, and `rssBytes: 0` with `sampled: 0` never means a measured zero. `oldestReadingAgeMs` is `null` when nothing was sampled and also when every contributor is a bridge predating the field, so it never means "fresh". Read the sum as an over-count and an under-count at once: summing per-process RSS double-counts pages the children share, while each child reports only its own process, so its MCP descendants and every channel worker are missing. It is not the daemon tree's memory. The field is optional in the SDK mirror because daemons reporting `primary_only` never send it.
|
||||
|
||||
`runtime.memory.children.heap` is additive within that block and reports each ACP child's lifetime V8 old-generation high-water marks, aggregated as a **maximum, not a sum**: `peakOldGenerationBytes`, `peakLiveSetBytes`, `peakTotalHeapBytes`, `majorGcCount`, `majorGcMs`, `unclassifiedSpaceNames`, and `reported`. A heap ceiling applies per child and the peaks were reached at different times, so a total would answer no question; each field is an independent maximum across the reporting children, not a portrait of one child, and a per-child ceiling is judged against each axis on its own. `reported` counts how many of `sampled` contributed, and is lower when some children predate the fields. Every byte figure covers the **old generation** — what `--max-old-space-size` actually bounds — and not `old_space` alone, because a child can exhaust its ceiling with `old_space` at a few megabytes while `large_object_space` holds everything. `peakOldGenerationBytes` is committed bytes and rises with the ceiling the child was given, so read it as an upper bound on what the workload needs rather than as its requirement; `peakLiveSetBytes` is what survives a major GC and does not move with the ceiling, which is what makes it the figure able to say a child cannot fit one; read it as an upper bound rather than an exact live set, because GC entries arrive asynchronously and anything allocated between the collection and the read is counted. `peakLiveSetBytes` is `0` until a major GC is observed, which is an absence rather than a measurement. `unclassifiedSpaceNames` is the union of heap spaces no reporting child could classify; V8 renames and adds spaces between versions, an unknown space is dropped from the sums, and dropping under-counts — so a non-empty array means the byte figures are incomplete and must not be read as a full measurement. The whole object is `null`, never a zeroed object, when no sampled child reported one; with no SSE/WS watcher attached nothing is sampled at all, so that is a routine state rather than an edge case. All of it is observational: nothing here sizes a child, refuses a spawn, or moves `limits.memory.enforced` off `false`.
|
||||
|
||||
`runtime.memory.pressure` is additive within that block and reports the daemon root's own memory pressure: `mode` (`off` / `observe`), `level` (`normal` / `soft` / `hard` / `critical`), `source` (`rss` / `heap` / `unknown`), `ratio`, and the six raw figures the ratios come from — `rssBytes`, `rssRatio`, `availableBytes`, `heapUsedBytes`, `heapRatio`, `heapLimitBytes`. `ratio` is the larger of `rssRatio` and `heapRatio`, and `source` names which one it was; ties are reported as `rss`. `availableBytes` is `limits.memory.availableMemoryMb` in bytes — deliberately the detected cgroup/host figure rather than `effectiveBudgetMb`, because what ends the process is the real limit, not an operator's policy number. `source: "unknown"` means neither denominator was measurable and must not be read as healthy; `level` is `normal` in that case only because there is nothing to classify. The figures cover the daemon **root process only**: they are this process's own `memoryUsage()`, so children growing does not move them. `runtime.memory.children` reports those separately, and neither figure is process-tree memory. Both modes report the whole block; only `observe` additionally raises the path-free `daemon_memory_pressure` warning into the status rollup, so `off` leaves the top-level `status` unchanged. Nothing remediates in either mode. The field is optional in the SDK mirror because daemons that shipped `runtime.memory` before it exists send the block without it.
|
||||
|
||||
`limits.maxTotalSessions` is additive. `null` means the effective daemon-wide fresh-session cap is disabled. When several startup/restored workspaces are present, `--max-total-sessions` is omitted, and `maxSessionsPerWorkspace` is finite, the daemon derives the effective total cap once as `maxSessionsPerWorkspace * startupWorkspaceCount`; later dynamic registration does not recompute it. When set, it limits fresh session creation across the daemon and reports total-limit failures with the existing `session_limit_exceeded` error shape plus `scope: "total"`.
|
||||
@@ -2267,6 +2291,8 @@ Response:
|
||||
|
||||
With `view=organized`, the daemon reads `<Storage.getProjectDir(cwd)>/session-organization.v1.json`, returns pinned sessions first, then activity time descending, and then `sessionId` for stable ties. The organized cursor is opaque base64url JSON and must not be reused with the legacy recent list. `pinned` is a virtual filter, not a group. `groupId: null` means ungrouped. Archived sessions keep their organization metadata, but `archiveState=archived&view=organized` still returns only archived sessions.
|
||||
|
||||
Activity-ordered cursors — the organized view and the `parentSessionId` / `sourceType` filtered lists — are not snapshot-isolated, and a trusted active list orders rows by the later of the transcript mtime and the live activity watermark. A live watermark is in-memory only, so a session's key can regress to its mtime when the live entry retires between two page fetches. The cursor compensates: it carries the identities already emitted at a live-derived key — retaining them while the row is absent from a page's collection and while a pin flip could re-admit them — and excludes them for the rest of the pass, so live-derived key movement returns a session at most once per pass. The guarantee is scoped to the carry: it is bounded at 64 identities (excess identities in one pass degrade to an at-most-once duplicate rather than an error), and a persisted-only row emitted before its pin state changed is never carried, so an unpin between fetches can return that row a second time exactly as before this field existed. Callers that accumulate pages should therefore always key rows by `sessionId`, not only in the over-64 case. Rows can still move or be skipped under concurrent activity, exactly as before; a caller that needs a coherent view reloads from the first page after an activity change.
|
||||
|
||||
Additional fields may appear on each session when `view=organized`:
|
||||
|
||||
```json
|
||||
@@ -2298,15 +2324,18 @@ Response:
|
||||
"clientCount": 1,
|
||||
"hasActivePrompt": true,
|
||||
"isWaitingForPermission": false,
|
||||
"isWaitingForUserQuestion": false
|
||||
"isWaitingForUserQuestion": false,
|
||||
"updatedAt": "2026-08-18T08:12:30.123Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`v` is the response schema version. Every successful response includes `Cache-Control: no-store`. `sessions` is the complete, unpaginated, unordered set of sessions currently live in the selected runtime; an empty live runtime returns `200` with `sessions: []`. `clientCount`, `hasActivePrompt`, `isWaitingForPermission`, and `isWaitingForUserQuestion` are required wire fields, and missing optional bridge values project to `0` or `false`. Static catalog fields such as display name, timestamps, organization, and source metadata are deliberately excluded and remain owned by the full catalog. An absent live-state row only clears a known catalog row's volatile fields; it never deletes a persisted catalog row.
|
||||
`v` is the response schema version. Every successful response includes `Cache-Control: no-store`. `sessions` is the complete, unpaginated, unordered set of sessions currently live in the selected runtime; an empty live runtime returns `200` with `sessions: []`. `clientCount`, `hasActivePrompt`, `isWaitingForPermission`, and `isWaitingForUserQuestion` are required wire fields, and missing optional bridge values project to `0` or `false`. Static catalog fields such as display name, creation time, organization, and source metadata are deliberately excluded and remain owned by the full catalog. An absent live-state row only clears a known catalog row's volatile fields; it never deletes a persisted catalog row.
|
||||
|
||||
`catalogVersion` is an equality token for daemon-observed catalog changes. `generation` is a random UUID created with each bridge instance and changes on daemon restart or workspace runtime replacement; `revision` starts at zero and increases monotonically within a generation. The only supported operation is equality over the whole pair: same generation and revision means no daemon-observed catalog change, and any difference means reload the full catalog. Clients must not perform revision arithmetic or compare revisions across generations, and conservative extra increments are allowed. The version covers catalog membership and static metadata changes observed by the daemon; ordinary turn activity, prompt lifecycle, attach/detach, and waiting-state transitions do not advance it because the live snapshot already carries the corresponding volatile fields. Two volatile overlay values are deliberately outside both signals: turn-error state (`hasTurnError`/`turnError`) and the pending-interaction count/content (`pendingInteractionCount`/`pendingInteractions`) neither advance the version nor appear in the snapshot, so a client that needs them must keep reading the per-session event stream or the full catalog rather than relying on this route; either field can be added wire-additively when a concrete consumer requires it. Mutations written directly by another daemon, a TUI, or an external process are not observed, so once a client stops periodic full-catalog polling those writes have no bounded discovery time and surface only after an explicit full reload, another observed catalog mutation, reconnect, or daemon/runtime replacement.
|
||||
`updatedAt` is an optional daemon-observed activity watermark, present when a prompt that reached the running state has published a formal terminal in the current bridge. It advances exactly once per such terminal — success, error, cancellation, and deadline alike — is written before the terminal event is published, and is strictly increasing per live session even when two terminals land in one wall-clock millisecond or the wall clock moves backward; a forward clock jump therefore persists until wall time catches up. It is never earlier than the session's `createdAt`: the first advance floors at creation time, so a wall-clock rollback between creation and the first terminal cannot key a row behind the `createdAt` it was already listed at. Prompt admission, queue waits, streamed updates, queue-only cancellation, heartbeats, and interaction waits never advance it. Clients use it to refresh the recency of a catalog row they already hold instead of reloading the full catalog after a completed turn. It is not a persistence acknowledgement: the recorder writes turn results asynchronously, so the value proves only that the daemon observed a running attempt settle. It is absent before the first running terminal in a bridge generation — including for a session restored from disk — so absence is not a support probe, and it disappears when a daemon restart or workspace runtime replacement installs a new bridge. When both a live and a persisted summary exist for one session, full catalog responses report the later valid timestamp, so `GET /session/:id/status`, which returns the bridge summary directly without that merge, may report an earlier value than a list response.
|
||||
|
||||
`catalogVersion` is an equality token for daemon-observed catalog changes. `generation` is a random UUID created with each bridge instance and changes on daemon restart or workspace runtime replacement; `revision` starts at zero and increases monotonically within a generation. The only supported operation is equality over the whole pair: same generation and revision means no daemon-observed catalog change, and any difference means reload the full catalog. Clients must not perform revision arithmetic or compare revisions across generations, and conservative extra increments are allowed. The version covers catalog membership and static metadata changes observed by the daemon; ordinary turn activity, prompt lifecycle, attach/detach, and waiting-state transitions do not advance it because the live snapshot already carries the corresponding volatile fields. A changed `updatedAt` under an unchanged version is therefore valid and expected, and it does not invalidate the daemon's persisted-list caches. Two volatile overlay values are deliberately outside both signals: turn-error state (`hasTurnError`/`turnError`) and the pending-interaction count/content (`pendingInteractionCount`/`pendingInteractions`) neither advance the version nor appear in the snapshot, so a client that needs them must keep reading the per-session event stream or the full catalog rather than relying on this route; either field can be added wire-additively when a concrete consumer requires it. Mutations written directly by another daemon, a TUI, or an external process are not observed, so once a client stops periodic full-catalog polling those writes have no bounded discovery time and surface only after an explicit full reload, another observed catalog mutation, reconnect, or daemon/runtime replacement.
|
||||
|
||||
Clients reconcile a catalog bundle with a two-read handshake: read live-state A, load the full session list (plus `GET /workspaces/:workspace/session-groups` when the client consumes `session_organization`), then read live-state B. Equal A and B versions accept the bundle; differing versions mark the catalog stale and coalesce at most one trailing reload rather than entering a tight retry loop. Every accepted catalog request must be initiated after A — a request or deduplicated promise that began before A cannot satisfy the reconciliation. Version-driven reloads are single-flight per workspace and obey a non-zero background minimum interval, so sustained catalog churn cannot drive one full catalog scan per live-state poll; explicit local mutations may still request an immediate refresh through the same single-flight operation.
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Plan: bilingual autofix failure-path handoff comments
|
||||
|
||||
Date: 2026-08-18
|
||||
Design: `docs/design/2026-08-18-autofix-handoff-bilingual.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Make every autofix failure-path handoff comment bilingual (English body
|
||||
unchanged + collapsed `中文说明` details block), matching the convention the
|
||||
rest of `qwen-autofix.yml` already follows.
|
||||
|
||||
## Architecture
|
||||
|
||||
- Agent contract: new `failure.zh.md` companion file (Chinese translation of
|
||||
`failure.md`), plain Markdown, no HTML.
|
||||
- Workflow: `HEADLINE_ZH` sibling variable at every `HEADLINE` site; report
|
||||
block emits a `<details>` section before the Run log line; 3000-byte
|
||||
truncated + sanitized Chinese excerpt.
|
||||
- Skill: SKILL.md bilingual rule extended.
|
||||
- Tests: contract pins in `scripts/tests/qwen-autofix-workflow.test.js`.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `.github/workflows/qwen-autofix.yml` | HEADLINE_ZH at ~9 template sites (+ clause variants); report-block details section; zh excerpt truncation/escaping |
|
||||
| `.qwen/skills/autofix/SKILL.md` | bilingual rule: `failure.zh.md` companion requirement + constraints |
|
||||
| `scripts/tests/qwen-autofix-workflow.test.js` | contract pins: details block present, HEADLINE/HEADLINE_ZH pairing, SKILL rule pin |
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Create branch `feat/autofix-handoff-bilingual` from `origin/main`
|
||||
- [x] Commit design + plan docs
|
||||
- [x] yml: add HEADLINE_ZH (+ GATE_CLAUSE_ZH / CAUSE_ZH / LAST_FIX_ZH /
|
||||
IDLE_CLAUSE_ZH / REMEDY_ZH) at every HEADLINE assignment
|
||||
- [x] yml: report block — emit details section (headline ZH, section labels
|
||||
ZH, failure.zh.md excerpt with 3000B truncation + iconv + sed
|
||||
escaping, gate-rejection note, graceful absence); also the
|
||||
develop-issue withdraw comment; failure.zh.md added to all cleanup
|
||||
and artifact lists
|
||||
- [x] SKILL.md: extend bilingual-outputs rule with failure.zh.md contract
|
||||
- [x] Tests: extend handoff-comment contract block; run focused vitest
|
||||
- [x] Self-audit full diff (two clean passes), then offer to push/open PR
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx vitest run --config ./scripts/tests/vitest.config.ts
|
||||
qwen-autofix-workflow.test.js qwen-fleet-shepherd-workflow.test.js` —
|
||||
all green except two pre-existing local-environment failures (macOS bash
|
||||
3.2 lacks `mapfile` for the gate script; confirmed failing on the clean
|
||||
tree via stash)
|
||||
- YAML parse (PyYAML) + `bash -n` over all 58 run blocks
|
||||
- Smoke tests: zh excerpt sanitization (`<details`/`</details`/`<summary`/
|
||||
`<!--` neutralized), mid-character truncation under `set -eo pipefail`,
|
||||
three render scenarios (zh + gate-rejection / no zh / committed-not-pushed)
|
||||
- Manual render check: assemble a sample report.md locally and eyeball the
|
||||
GitHub Markdown rendering of the details block
|
||||
@@ -109,13 +109,13 @@ Settings are organized into categories. Most settings should be placed within th
|
||||
|
||||
#### review
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `review.attribution` | boolean | Append the attribution footer naming the model and CLI version (e.g. `_— qwen3-coder via Qwen Code /review (v0.21.2)_`) to review bodies and inline comments posted by `/review`. Disable to post reviews without AI attribution. With the footer off, presubmit duplicate detection still recognizes earlier posts by the same GitHub account, but footer-less posts from other accounts escape it. | `true` |
|
||||
| `review.effort` | enum | Default effort for `/review` when `--effort` is not given: `"low"`, `"medium"`, `"high"`, or `"auto"` (the built-in rule: high for PRs, medium for local changes). An explicit `--effort` wins; an effective `--comment` still forces high and `--fix` still floors at medium. | `"auto"` |
|
||||
| `review.comment` | boolean | Treat every PR `/review` as if `--comment` was passed: findings are posted to the pull request without the flag. The post still binds to the PR named in the invocation. Enable only if you always want reviews published. | `false` |
|
||||
| `review.severityFloor` | enum | The lowest severity a PR `/review` posts when `--severity-floor` is not given: `"auto"` (the round-adaptive default — Suggestions post through round 5, only Criticals from round 6, with otherwise-postable high-confidence Suggestions recorded and deferred, and rounds 2–5 deferring new Suggestions on code unchanged since the previous round; low-confidence and Nice-to-have findings stay terminal-only), `"critical"` (that posture from round 1), or `"suggestion"` (Suggestions post at every round; turns the convergence posture off). Non-PR targets have no rounds and ignore this. | `"auto"` |
|
||||
| `review.reverseAuditRounds` | number | Lower the reverse-audit loop's round cap for every high-effort review. The cap otherwise follows the diff topology (10 small / 5 chunked; a huge diff is 3 with a review deadline and 5 without). This can only **lower** whichever tier applies: a value below 3, above the tier, or not a whole number above zero is ignored. Cutting the cap does not make reviews converge sooner — the loop ends on two consecutive dry rounds — it makes them stop before converging more often, and every such stop caps the verdict at Comment. | `0` (unset) |
|
||||
| Setting | Type | Description | Default |
|
||||
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `review.attribution` | boolean | Append the attribution footer naming the model and CLI version (e.g. `_— qwen3-coder via Qwen Code /review (v0.21.2)_`) to review bodies and inline comments posted by `/review`. Disable to post reviews without visible AI attribution: the footer is omitted and posted comments and body lists lose their `**[Critical]**`/`**[Suggestion]**` markers. The posts stay identifiable in the raw source: each carries an invisible severity marker (`<!-- qwen-review critical -->`) and the review body carries a ledger marker (`<!-- qwen-review-ledger ... -->`) — anything reading comment bodies (GitHub API automation, the workflows this setting couples to) still recognizes a `/review` artifact, and presubmit duplicate detection recognizes the reviewing account's earlier posts by the severity marker, though unattributed posts from other accounts escape it. Another consequence: qwen-autofix's Critical-only mode (engaged after round 5, or earlier when a counting window's diff-growth budget trips) no longer recognizes the posted findings as Critical and defers them. Disabling also withholds the model from the machine-ledger marker embedded in the review body, so in a fresh environment (CI, another clone — anywhere without a review cache) the incremental anchor recovered from the last posted review fails the same-model check and the re-review falls back to full-range. | `true` |
|
||||
| `review.effort` | enum | Default effort for `/review` when `--effort` is not given: `"low"`, `"medium"`, `"high"`, or `"auto"` (the built-in rule: high for PRs, medium for local changes). An explicit `--effort` wins; an effective `--comment` still forces high and `--fix` still floors at medium. | `"auto"` |
|
||||
| `review.comment` | boolean | Treat every PR `/review` as if `--comment` was passed: findings are posted to the pull request without the flag. The post still binds to the PR named in the invocation. Enable only if you always want reviews published. | `false` |
|
||||
| `review.severityFloor` | enum | The lowest severity a PR `/review` posts when `--severity-floor` is not given: `"auto"` (the round-adaptive default — Suggestions post through round 5, only Criticals from round 6, with otherwise-postable high-confidence Suggestions recorded and deferred, and rounds 2–5 deferring new Suggestions on code unchanged since the previous round; low-confidence and Nice-to-have findings stay terminal-only), `"critical"` (that posture from round 1), or `"suggestion"` (Suggestions post at every round; turns the convergence posture off). Non-PR targets have no rounds and ignore this. | `"auto"` |
|
||||
| `review.reverseAuditRounds` | number | Lower the reverse-audit loop's round cap for every high-effort review. The cap otherwise follows the diff topology (10 small / 5 chunked; a huge diff is 3 with a review deadline and 5 without). This can only **lower** whichever tier applies: a value below 3, above the tier, or not a whole number above zero is ignored. Cutting the cap does not make reviews converge sooner — the loop ends on two consecutive dry rounds — it makes them stop before converging more often, and every such stop caps the verdict at Comment. | `0` (unset) |
|
||||
|
||||
These settings are read from operator scopes only (User, System, and SystemDefaults); values in a workspace `.qwen/settings.json` are ignored, so a repository cannot set review policy for its reviewers.
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ You can send photos and documents to the bot, not just text.
|
||||
|
||||
- **Use DingTalk markdown-aware instructions** — DingTalk supports headings, bold text, links, code blocks, and tables. Keep tables compact because narrow screens may scroll horizontally.
|
||||
- **Restrict access** — In an organization context, `senderPolicy: "open"` may be acceptable. For tighter control, use `"allowlist"` or `"pairing"`. See [DM Pairing](./overview#dm-pairing) for details.
|
||||
- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. Quoting bot responses is not yet supported.
|
||||
- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. If the quoted message is a picture, file, audio, or video message, the bot downloads and attaches it the same way as when sent directly. Quoting bot responses is not yet supported.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
# Review local changes and apply the findings to your working tree
|
||||
/review --fix
|
||||
|
||||
# Continue a review of the same PR that was interrupted, instead of starting over
|
||||
/review 123 --resume
|
||||
|
||||
# Review a specific file
|
||||
/review src/utils/auth.ts
|
||||
|
||||
@@ -184,7 +187,7 @@ Or, after running `/review 123`, type `post comments` to publish findings withou
|
||||
- Where the fix is a single localized edit, a ` ```suggestion ` block you can apply in one click
|
||||
- For Approve/Request changes verdicts: a review summary with the verdict
|
||||
- For Comment verdict with all inline comments posted: no separate summary (inline comments are sufficient)
|
||||
- Model and CLI version attribution footer on each comment (e.g., _— qwen3-coder via Qwen Code /review (v0.21.2)_); set `review.attribution` to `false` in your user or system `settings.json` (the workspace `.qwen/settings.json` is ignored for `review.*` settings) to post without it
|
||||
- Model and CLI version attribution footer on each comment (e.g., _— qwen3-coder via Qwen Code /review (v0.21.2)_); set `review.attribution` to `false` in your user or system `settings.json` (the workspace `.qwen/settings.json` is ignored for `review.*` settings) to post without it — comments and body lists then also lose the `**[Critical]**`/`**[Suggestion]**` severity markers, and the model is withheld from the review's machine-ledger marker, so in fresh environments (no review cache) the recovered incremental anchor fails the same-model check and the re-review falls back to full-range
|
||||
|
||||
**What stays terminal-only:**
|
||||
|
||||
@@ -222,6 +225,18 @@ A finding is skipped when its fix would change intended behavior, would need cha
|
||||
|
||||
**Every finding gets an outcome, and this is enforced rather than requested.** The ledger goes through `qwen review findings --outcomes`, which refuses a set that does not cover all of them — a fixer that applies six of nine findings and reports six has not lied about any one of them, it has silently shortened the list, and you would have no way to see the three that fell off.
|
||||
|
||||
## Resuming an interrupted review (`--resume`)
|
||||
|
||||
A long review that dies part-way — a dropped connection, a timeout, a killed terminal — leaves everything it had done on disk: the worktree, the captured diff, and the harness's own record of every agent that ran. `--resume` continues from there instead of starting over:
|
||||
|
||||
```bash
|
||||
/review 123 --resume
|
||||
```
|
||||
|
||||
It applies to **PR targets only** (a local review's diff comes from a live working tree, which has no stable interrupted state to continue), and it is safe to pass whenever you are unsure: the review rules on the on-disk state itself — the worktree still at the fetched commit and clean, the captured diff unchanged byte for byte, the PR head unmoved, the resume limit unspent — and silently starts fresh whenever anything no longer matches, telling you which check refused. A continuation reuses the earlier attempt's certified agent results, so the report says how many were recovered; it is disclosed, never a coverage gap.
|
||||
|
||||
Two things to know. A continuation keeps the interrupted run's **effort**: passing a different `--effort` refuses the resume and runs fresh at the level you asked for, because different effort is different work. And if the PR head moved while the review was down, the resume refuses (`head-moved`) and the fresh run reviews the new commits — which is what you want, and it counts as this review's one restart.
|
||||
|
||||
## Findings as Data
|
||||
|
||||
Confirmed findings are canonicalized into `.qwen/tmp/qwen-review-<target>-findings.json` before anything else consumes them — the terminal report, the saved Markdown report, and the PR review JSON all read that one artifact instead of re-typing the list. Each finding carries a unique `id` (what outcomes and resolved anchors join on), `severity`, `confidence`, `source`, `summary`, a `shortSummary` capped at 60 characters for list rendering, `failureScenario`, and one or more `locations` — a pattern-aggregated finding keeps **one location per occurrence**, so each still gets its own inline comment.
|
||||
@@ -345,6 +360,8 @@ If you switch models (via `/model`) and re-review the same PR, `/review` detects
|
||||
# → "Previous review used qwen3-coder. Running full review with gpt-4o for a second opinion."
|
||||
```
|
||||
|
||||
The model match also gates incremental scoping, not just the skip: "clean up to the cached commit" is the previous model's verdict, so when new commits have landed since the cached review, a model mismatch never scopes to `lastCommitSha..HEAD` — the range is the full diff, noting "Previous round was reviewed by qwen3-coder. Running full review with gpt-4o." — unless an anchor certified by the model now running is recovered from the last posted review (below), which scopes the range instead. The previous round's findings still carry over to be re-ruled; only the anchor does not. The same gate binds the anchor recovered from the last posted review's machine-ledger marker when the cache is absent or its anchor is unusable (CI, another clone): it scopes the incremental range only if the model now running certified it — a marker certified by a different model, or carrying no model (a review posted with `review.attribution` off, or one from before the field), falls back to the full diff.
|
||||
|
||||
Cache is stored in `.qwen/review-cache/` and tracks both the commit SHA and model ID. Make sure this directory is in your `.gitignore` (a broader rule like `.qwen/*` also works). If the cached commit was rebased away, it falls back to a full review. Only high-effort reviews consult or write the cache — a `--effort low|medium` quick pass never counts as "already reviewed".
|
||||
|
||||
## Review Reports
|
||||
@@ -364,6 +381,8 @@ The deterministic halves of the pipeline — argument parsing (`qwen review pars
|
||||
|
||||
**GitHub Enterprise:** reviewing a PR URL on a non-`github.com` host routes every GitHub call at that host — the review subcommands (`match-remote`, `meta`, `fetch-pr`, `pr-context`, `comment-status`, `issue-context`, `fetch-diff`, `comment-body`, `plan-diff`, `test-plan`, `presubmit`, `compose-review`, `submit`, `publish-assets`) accept `--host` and set it in code, so a forgotten host cannot silently retarget the review at `github.com`.
|
||||
|
||||
**Aone Code:** for a clone whose origin is on `gitlab.alibaba-inc.com`, run `/review` from inside that clone — the platform is detected from the remote and the read subcommands work, backed by the `a1` CLI — the target number is the global MR id. `fetch-pr` fetches `refs/merge-requests/<id>/head` and builds the worktree + diff, so the agent review of the worktree is unchanged. In this phase every Aone run is context-unavailable and several flows are skipped (rather than hitting github.com's same-named repo): `pr-context`/`comment-status`/`presubmit` have no Aone backing (verdict caps at `COMMENT`), `test-plan` is unbacked, Agent 0 is skipped, and the `publish-assets` write is skipped — with `--comment` also refused, an Aone run is read-only toward the platform in this phase; findings land in the terminal output and the saved report. See `docs/design/2026-08-15-review-aone-provider.md`.
|
||||
|
||||
Every run ends with one machine-readable line (`Review complete: <target> — <disposition>`), so scripts and CI wrappers can detect completion and outcome with a single `^Review complete: ` match.
|
||||
|
||||
## Headless runs (`qwen review run`)
|
||||
@@ -371,7 +390,7 @@ Every run ends with one machine-readable line (`Review complete: <target> — <d
|
||||
`/review` is interactive. When a script or CI job needs to run a review and act on its outcome, use the headless wrapper:
|
||||
|
||||
```bash
|
||||
qwen review run [target] [--json] [--fail-on request-changes] [--comment] [--quiet]
|
||||
qwen review run [target] [--json] [--fail-on request-changes] [--comment] [--resume] [--quiet]
|
||||
```
|
||||
|
||||
`target` is a PR number, a PR URL, or a file path; omit it to review the local working tree. The command runs this build's own CLI non-interactively (with stdin closed, so slash-command detection survives), streams the child's progress to **stderr**, and prints the verdict to **stdout** — or, with `--json`, the full result object. The verdict is read from the artifact `compose-review` writes (the same JSON the skill treats as the verdict authority), never parsed from the model's prose.
|
||||
@@ -386,6 +405,8 @@ The exit code is the contract a gate should read:
|
||||
|
||||
`3` (not `2`) lets a gate distinguish "the review is blocking" from "the tool broke" — yargs already uses `1` for usage errors — without parsing any output. `--timeout-minutes` (default 120, floored at 1) terminates a hung review and exits `1`, and cancelling the command (Ctrl+C / SIGTERM) terminates the review's process group rather than orphaning it.
|
||||
|
||||
`--resume` continues an interrupted review of the same PR instead of starting over — when a long local run dies part-way (a dropped connection, a timeout, a killed terminal), the retry would otherwise re-fetch, re-chunk and re-launch agents whose work is already on disk. It is safe to pass unconditionally on a retry: `fetch-pr` rules on the on-disk state itself (worktree still at the fetched SHA and clean, diff bytes unchanged, PR head unmoved, resume cap unspent) and silently falls back to a fresh review whenever anything no longer matches, so the flag never fails a run that could start over. A continuation is pinned to the interrupted run's recorded effort — an explicitly different `--effort` refuses the resume and runs fresh at the requested level. PR targets only (a local review's diff is captured from a live working tree, which has no stable interrupted state to continue). Resume is a **local convenience**: the repository's own CI review workflow does **not** resume — each retry re-runs fresh, because a CI attempt runs no-sandbox and its worktree is deleted on exit, leaving no interrupted state to continue.
|
||||
|
||||
A time-budgeted run can also export a **soft** deadline so the review stops its open-ended reverse-audit loop while there is still time to verify, compose and post: `QWEN_REVIEW_DEADLINE_EPOCH` is the Unix-seconds moment the run will be killed, and `QWEN_REVIEW_DEADLINE_RESERVE_SECONDS` (default 3600; `0` keeps only the round estimate) is the tail that must remain for the last round's verification, `compose-review` and submission. When the remaining budget no longer fits another round plus that tail, the round builder refuses to build it, and the composed verdict discloses the truncated audit (an otherwise-Approve verdict is capped at Comment). A missing or malformed deadline leaves the review ungated — the outer timeout still bounds the run.
|
||||
|
||||
Nested inside that reserve is a smaller **compose floor**, `QWEN_REVIEW_DEADLINE_COMPOSE_FLOOR_SECONDS` (default 1200; `0` disables this gate entirely, at every point including past the deadline). The reserve is one number covering "verify the last round **plus** compose **plus** submit", which fits a normal per-finding re-trace but not a security review whose verification re-runs real filesystem/git workloads without bound. So the verifier — not the round builder — is gated on this floor: once the floor or less remains, `agent-prompt --role verify` refuses to build (a `VERIFY BUDGET:` line, exit **4**), the findings in hand keep their unverified tag (which caps the verdict), and `compose-review` and submission run. The floor is strictly below the reserve, so a healthy run hits the reverse-audit gate first and never reaches it; it is the cover for the one span the reserve cannot bound.
|
||||
|
||||
@@ -192,7 +192,7 @@ idle daemon returns `initialized: false` with an empty snapshot. Once a
|
||||
session is alive they switch to `initialized: true` and surface the real
|
||||
state.
|
||||
|
||||
To mirror the CLI `/skills` panel remotely, call `POST /workspace/skills/:name/enable` with `{ "enabled": true | false }` after checking the `workspace_skill_toggle` capability. To change several Skills, check `workspace_skill_batch_toggle` and call `POST /workspace/skills/enable` with `{ "skillNames": ["review", "deploy"], "enabled": false }`; its response separates successful `results` from per-target `errors`, persists valid targets together, and refreshes active ACP sessions once. The routes update workspace `skills.disabled` and `skills.enabled` as needed and reject unknown, hidden, inactive-extension, higher-scope-locked, and untrusted targets. Enabling a `skills.defaultDisabled` skill writes a canonical opt-in to `skills.enabled`; a hard `skills.disabled` entry inherited from a higher scope still cannot be overridden. Skill status cells expose `disabledReason` (`hard`, `default`, or `inactive_extension`) and an optional `lockedScope`. A `deferred` response means the setting was saved while no ACP child was running; it will apply when the child starts. `skills.disabled` disables both manual and model use, unlike `disable-model-invocation: true`, which keeps direct `/skill-name` invocation available.
|
||||
To mirror the CLI `/skills` panel remotely, call `POST /workspace/skills/:name/enable` with `{ "enabled": true | false }` after checking the `workspace_skill_toggle` capability. To change several Skills, check `workspace_skill_batch_toggle` and call `POST /workspace/skills/enable` with `{ "skillNames": ["review", "deploy"], "enabled": false }`; its response separates successful `results` from per-target `errors`, persists valid targets together, and refreshes active ACP sessions once. The routes update workspace `skills.disabled` and `skills.enabled` as needed and reject unknown, hidden, inactive-extension, higher-scope-locked, and untrusted targets. Enabling a `skills.defaultDisabled` skill writes a canonical opt-in to `skills.enabled`; a hard `skills.disabled` entry inherited from a higher scope still cannot be overridden. Skill status cells expose `disabledReason` (`hard`, `default`, or `inactive_extension`) and an optional `lockedScope`. A `deferred` response means the setting was saved while no ACP child was running; it will apply when the child starts. `skills.disabled` disables both manual and model use, unlike `disable-model-invocation: true`, which keeps direct `/skill-name` invocation available. For V2 Extension batches, check `extension_batch_activation_v2`: `PUT /extensions/activation` changes global defaults, while `PUT /workspaces/:workspace/extensions/activation` changes exact overrides for the selected workspace and accepts `"inherit"` to clear them. Both accept names in `extensionNames`; `enabled` and `disabled` may be declared before installation, while `inherit` for an unknown name is a no-op. Each request returns one operation to poll.
|
||||
|
||||
`GET /workspace/env` and `GET /workspace/preflight` always answer with
|
||||
`initialized: true` regardless of ACP state. `env` never consults ACP
|
||||
@@ -674,9 +674,9 @@ Agent text writes (`write_file`, `edit`, `notebook_edit`, and the shell tool's s
|
||||
|
||||
Operators whose deployment convention is umask-driven (e.g. a systemd unit with `UMask=0002`, shared-group repositories) can opt new files into the standard POSIX handling with:
|
||||
|
||||
| Env var | Values | Default | What it does |
|
||||
| -------------------------- | ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `QWEN_SERVE_NEW_FILE_MODE` | `owner` \| `system` | `owner` | `system` creates NEW files at `0o666 & ~umask`, so agent-created files follow the daemon process's umask like any other process on the machine. `owner` keeps the umask-independent `0600` default. Values are case-insensitive; the literal `0600` is accepted as an alias for `owner` (no other octal modes are supported), and any other value is rejected with a stderr warning and the `0600` default is kept. |
|
||||
| Env var | Values | Default | What it does |
|
||||
| -------------------------- | ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `QWEN_SERVE_NEW_FILE_MODE` | `owner` \| `system` | `owner` | `system` creates NEW files at `0o666 & ~umask`, so agent-created files follow the daemon process's umask like any other process on the machine. `owner` keeps the umask-independent `0600` default. Values are case-insensitive; the literal `0600` is accepted as an alias for `owner` (no other octal modes are supported), and any other value is rejected with a stderr warning and the `0600` default is kept. |
|
||||
|
||||
Scope and limits:
|
||||
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createDaemonTranscriptState,
|
||||
DAEMON_ERROR_KINDS,
|
||||
normalizeDaemonEvent,
|
||||
reduceDaemonTranscriptEvents,
|
||||
type DaemonEvent,
|
||||
type DaemonTranscriptBlock,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import { projectChatRecordsToDaemonTranscript } from '@qwen-code/sdk/daemon/transcript';
|
||||
import { transcriptBlocksToDaemonMessages } from '../packages/web-shell/client/adapters/transcriptToMessages.js';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const fixtureRoot = resolve(
|
||||
repoRoot,
|
||||
'integration-tests/fixtures/chat-transcript-contract/v1',
|
||||
);
|
||||
const caseRoot = resolve(fixtureRoot, 'cases/representative');
|
||||
|
||||
interface FixtureManifest {
|
||||
readonly fixtureVersion: number;
|
||||
readonly name: string;
|
||||
readonly generatorVersion?: string;
|
||||
readonly sources: readonly string[];
|
||||
readonly consumers: readonly string[];
|
||||
readonly capabilities: readonly string[];
|
||||
readonly complete: boolean;
|
||||
readonly expectedDiagnostics: readonly string[];
|
||||
readonly normalizedFields?: readonly string[];
|
||||
readonly hashes: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface ExpectedModel {
|
||||
readonly kinds: readonly string[];
|
||||
readonly texts: readonly string[];
|
||||
readonly sourceRecordIds: readonly (readonly string[])[];
|
||||
}
|
||||
|
||||
interface ExpectedRenderItems {
|
||||
readonly roles: readonly string[];
|
||||
readonly expectedTextContent: readonly string[];
|
||||
readonly runtimeFields: readonly string[];
|
||||
readonly expectedToolArgs: Readonly<Record<string, unknown>>;
|
||||
readonly expectedToolResult: unknown;
|
||||
}
|
||||
|
||||
interface ExpectedExportContract {
|
||||
readonly schemaVersion: number;
|
||||
readonly forbiddenFields: readonly string[];
|
||||
readonly frozenErrorKinds: readonly string[];
|
||||
readonly timestamps: number;
|
||||
readonly implementation: string;
|
||||
}
|
||||
|
||||
interface IdentityCandidateResult {
|
||||
readonly status: 'fail';
|
||||
readonly stableUnderPartialPrepend: false;
|
||||
readonly unstableBlockKinds: readonly string[];
|
||||
readonly missingNativeTextIdentity: readonly string[];
|
||||
}
|
||||
|
||||
interface ExpectedGate {
|
||||
readonly overall: 'fail';
|
||||
readonly selectedVscodePath: null;
|
||||
readonly candidates: {
|
||||
readonly directDaemon: IdentityCandidateResult;
|
||||
readonly acp: IdentityCandidateResult;
|
||||
};
|
||||
readonly blockers: readonly string[];
|
||||
}
|
||||
|
||||
function readJson<T>(path: string): T {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
||||
}
|
||||
|
||||
function readJsonLines<T>(path: string): T[] {
|
||||
return readFileSync(path, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line) as T);
|
||||
}
|
||||
|
||||
function sha256(path: string): string {
|
||||
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
}
|
||||
|
||||
function listFixtureEvidenceFiles(
|
||||
directory: string,
|
||||
relativeDirectory = '',
|
||||
): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const relativePath = relativeDirectory
|
||||
? `${relativeDirectory}/${entry.name}`
|
||||
: entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
return listFixtureEvidenceFiles(
|
||||
resolve(directory, entry.name),
|
||||
relativePath,
|
||||
);
|
||||
}
|
||||
return relativePath === 'cases/representative/manifest.json'
|
||||
? []
|
||||
: [relativePath];
|
||||
});
|
||||
}
|
||||
|
||||
function expectManifestToMatchSchema(
|
||||
manifest: FixtureManifest,
|
||||
schema: Record<string, unknown>,
|
||||
): void {
|
||||
const properties = schema['properties'] as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const required = schema['required'];
|
||||
expect(properties).toBeTypeOf('object');
|
||||
expect(required).toBeInstanceOf(Array);
|
||||
expect(schema['additionalProperties']).toBe(false);
|
||||
|
||||
const allowedKeys = new Set(Object.keys(properties));
|
||||
for (const key of Object.keys(manifest)) {
|
||||
expect(allowedKeys.has(key), `manifest property ${key}`).toBe(true);
|
||||
}
|
||||
for (const key of required as string[]) {
|
||||
expect(manifest, `required manifest property ${key}`).toHaveProperty(key);
|
||||
}
|
||||
|
||||
const nameSchema = properties['name'];
|
||||
expect(manifest.name.length).toBeGreaterThanOrEqual(
|
||||
nameSchema?.['minLength'] as number,
|
||||
);
|
||||
expect(manifest.name.length).toBeLessThanOrEqual(
|
||||
nameSchema?.['maxLength'] as number,
|
||||
);
|
||||
const capabilitySchema = properties['capabilities'];
|
||||
const capabilityItemSchema = capabilitySchema?.['items'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(manifest.capabilities.length).toBeGreaterThanOrEqual(
|
||||
capabilitySchema?.['minItems'] as number,
|
||||
);
|
||||
expect(new Set(manifest.capabilities)).toHaveLength(
|
||||
manifest.capabilities.length,
|
||||
);
|
||||
for (const capability of manifest.capabilities) {
|
||||
expect(capability).toBeTypeOf('string');
|
||||
expect(capability.length).toBeLessThanOrEqual(
|
||||
capabilityItemSchema['maxLength'] as number,
|
||||
);
|
||||
}
|
||||
const hashSchema = properties['hashes']?.['additionalProperties'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const hashPattern = new RegExp(hashSchema['pattern'] as string, 'u');
|
||||
for (const [relativePath, hash] of Object.entries(manifest.hashes)) {
|
||||
expect(relativePath).not.toBe('cases/representative/manifest.json');
|
||||
expect(hash, relativePath).toMatch(hashPattern);
|
||||
}
|
||||
}
|
||||
|
||||
function collectDeclaredSchemaProperties(
|
||||
value: unknown,
|
||||
names = new Set<string>(),
|
||||
): Set<string> {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectDeclaredSchemaProperties(item, names);
|
||||
return names;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return names;
|
||||
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (key === 'properties' && item && typeof item === 'object') {
|
||||
for (const propertyName of Object.keys(item)) names.add(propertyName);
|
||||
}
|
||||
collectDeclaredSchemaProperties(item, names);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function reduceDaemonEvents(
|
||||
events: readonly DaemonEvent[],
|
||||
): readonly DaemonTranscriptBlock[] {
|
||||
let state = createDaemonTranscriptState({ now: 0 });
|
||||
for (const event of events) {
|
||||
state = reduceDaemonTranscriptEvents(state, normalizeDaemonEvent(event), {
|
||||
now: 0,
|
||||
});
|
||||
}
|
||||
return state.blocks;
|
||||
}
|
||||
|
||||
function reduceAcpUpdates(
|
||||
updates: readonly unknown[],
|
||||
): readonly DaemonTranscriptBlock[] {
|
||||
return reduceDaemonEvents(
|
||||
updates.map(
|
||||
(update): DaemonEvent => ({
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: { update },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function blockSemanticKey(block: DaemonTranscriptBlock): string {
|
||||
switch (block.kind) {
|
||||
case 'user':
|
||||
case 'assistant':
|
||||
case 'thought':
|
||||
return `${block.kind}:${block.text}`;
|
||||
case 'tool':
|
||||
return `tool:${block.toolCallId}`;
|
||||
case 'permission':
|
||||
return `permission:${block.requestId}`;
|
||||
default:
|
||||
throw new Error(`Unsupported identity probe block kind: ${block.kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
function indexBlocksBySemanticKey(
|
||||
blocks: readonly DaemonTranscriptBlock[],
|
||||
label: 'complete' | 'partial',
|
||||
): ReadonlyMap<string, DaemonTranscriptBlock> {
|
||||
const indexed = new Map<string, DaemonTranscriptBlock>();
|
||||
for (const block of blocks) {
|
||||
const key = blockSemanticKey(block);
|
||||
if (indexed.has(key)) {
|
||||
throw new Error(`Ambiguous ${label} identity probe semantic key: ${key}`);
|
||||
}
|
||||
indexed.set(key, block);
|
||||
}
|
||||
return indexed;
|
||||
}
|
||||
|
||||
function probeIdentity(
|
||||
complete: readonly DaemonTranscriptBlock[],
|
||||
partial: readonly DaemonTranscriptBlock[],
|
||||
): IdentityCandidateResult {
|
||||
const completeBySemanticKey = indexBlocksBySemanticKey(complete, 'complete');
|
||||
const partialBySemanticKey = indexBlocksBySemanticKey(partial, 'partial');
|
||||
const unstableBlockKinds = [
|
||||
...new Set(
|
||||
[...partialBySemanticKey].flatMap(([key, block]) => {
|
||||
const completeBlock = completeBySemanticKey.get(key);
|
||||
if (!completeBlock) {
|
||||
throw new Error(`Missing complete identity probe block: ${key}`);
|
||||
}
|
||||
return completeBlock.id !== block.id ? [block.kind] : [];
|
||||
}),
|
||||
),
|
||||
];
|
||||
const missingNativeTextIdentity = [
|
||||
...new Set(
|
||||
complete.flatMap((block) => {
|
||||
if (
|
||||
block.kind !== 'user' &&
|
||||
block.kind !== 'assistant' &&
|
||||
block.kind !== 'thought'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return block.sourceRecordIds?.length || block.promptId
|
||||
? []
|
||||
: [block.kind];
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
expect(unstableBlockKinds.length).toBeGreaterThan(0);
|
||||
return {
|
||||
status: 'fail',
|
||||
stableUnderPartialPrepend: false,
|
||||
unstableBlockKinds,
|
||||
missingNativeTextIdentity,
|
||||
};
|
||||
}
|
||||
|
||||
describe('chat transcript contract prevalidation', () => {
|
||||
it('locks the evidence fixtures, schemas, and fail-first capability decision', () => {
|
||||
const manifest = readJson<FixtureManifest>(
|
||||
resolve(caseRoot, 'manifest.json'),
|
||||
);
|
||||
const manifestSchema = readJson<Record<string, unknown>>(
|
||||
resolve(fixtureRoot, 'schema/manifest.schema.json'),
|
||||
);
|
||||
const exportSchema = readJson<Record<string, unknown>>(
|
||||
resolve(fixtureRoot, 'schema/export-transcript-document-v1.schema.json'),
|
||||
);
|
||||
const expectedExport = readJson<ExpectedExportContract>(
|
||||
resolve(caseRoot, 'expected-export.json'),
|
||||
);
|
||||
const matrix = readFileSync(
|
||||
resolve(fixtureRoot, 'capability-matrix.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expectManifestToMatchSchema(manifest, manifestSchema);
|
||||
const manifestWithUnknownProperty = {
|
||||
...manifest,
|
||||
unknownProperty: true,
|
||||
};
|
||||
expect(() =>
|
||||
expectManifestToMatchSchema(manifestWithUnknownProperty, manifestSchema),
|
||||
).toThrow(/manifest property unknownProperty/u);
|
||||
expect(manifest.fixtureVersion).toBe(1);
|
||||
expect(manifest.complete).toBe(true);
|
||||
expect(new Set(manifest.sources)).toEqual(
|
||||
new Set(['daemon', 'acp', 'chat-records']),
|
||||
);
|
||||
expect(new Set(manifest.consumers)).toEqual(
|
||||
new Set(['web', 'tauri', 'vscode', 'html']),
|
||||
);
|
||||
expect(manifest.name).toBe('representative');
|
||||
expect(manifest.generatorVersion).toBe(
|
||||
'chat-transcript-prevalidation-evidence-v1',
|
||||
);
|
||||
expect(new Set(manifest.capabilities)).toEqual(
|
||||
new Set([
|
||||
'semantic-projection',
|
||||
'runtime-raw-compatibility',
|
||||
'stable-identity-prepend-probe',
|
||||
'export-document-schema',
|
||||
'two-mr-migration-gate',
|
||||
]),
|
||||
);
|
||||
expect(manifest.expectedDiagnostics).toEqual([
|
||||
'direct_daemon_unstable_identity',
|
||||
'acp_unstable_identity',
|
||||
]);
|
||||
expect(manifest.normalizedFields).toEqual([
|
||||
'clientReceivedAt',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
]);
|
||||
expect(manifestSchema['additionalProperties']).toBe(false);
|
||||
expect(exportSchema['additionalProperties']).toBe(false);
|
||||
|
||||
const exportDefinitions = exportSchema['$defs'] as Record<string, unknown>;
|
||||
const blockSchema = exportDefinitions['block'] as {
|
||||
oneOf: Array<{ $ref: string }>;
|
||||
};
|
||||
expect(blockSchema.oneOf).toHaveLength(10);
|
||||
for (const definitionName of ['statusBlock', 'errorBlock']) {
|
||||
const definition = exportDefinitions[definitionName] as {
|
||||
properties: { errorKind: { enum: string[] } };
|
||||
};
|
||||
expect(definition.properties.errorKind.enum).toEqual(
|
||||
expectedExport.frozenErrorKinds,
|
||||
);
|
||||
}
|
||||
for (const errorKind of expectedExport.frozenErrorKinds) {
|
||||
expect(
|
||||
DAEMON_ERROR_KINDS,
|
||||
`Export V1 error kind ${errorKind} must remain supported by the SDK`,
|
||||
).toContain(errorKind);
|
||||
}
|
||||
const declaredExportProperties =
|
||||
collectDeclaredSchemaProperties(exportSchema);
|
||||
for (const field of expectedExport.forbiddenFields) {
|
||||
expect(declaredExportProperties.has(field), field).toBe(false);
|
||||
}
|
||||
const permissionOption = exportDefinitions['permissionOption'] as {
|
||||
properties: { raw: { const: unknown } };
|
||||
};
|
||||
const toolBlock = exportDefinitions['toolBlock'] as {
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
const statusBlock = exportDefinitions['statusBlock'] as {
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
const errorBlock = exportDefinitions['errorBlock'] as {
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
expect(toolBlock.properties).not.toHaveProperty('content');
|
||||
expect(statusBlock.properties).not.toHaveProperty('data');
|
||||
expect(errorBlock.properties).not.toHaveProperty('data');
|
||||
expect(permissionOption.properties.raw.const).toBeNull();
|
||||
expect(expectedExport).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
timestamps: 0,
|
||||
implementation: 'deferred-to-mr2',
|
||||
});
|
||||
|
||||
expect(Object.keys(manifest.hashes).sort()).toEqual(
|
||||
listFixtureEvidenceFiles(fixtureRoot).sort(),
|
||||
);
|
||||
for (const [relativePath, expectedHash] of Object.entries(
|
||||
manifest.hashes,
|
||||
)) {
|
||||
expect(sha256(resolve(fixtureRoot, relativePath))).toBe(expectedHash);
|
||||
}
|
||||
|
||||
const exportProperties = exportSchema['properties'] as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const rendererVersionPattern = new RegExp(
|
||||
exportProperties['rendererVersion']?.['pattern'] as string,
|
||||
'u',
|
||||
);
|
||||
for (const validVersion of [
|
||||
'1.2.3',
|
||||
'1.2.3-beta.1+build.7',
|
||||
'a'.repeat(64),
|
||||
]) {
|
||||
expect(validVersion, validVersion).toMatch(rendererVersionPattern);
|
||||
}
|
||||
for (const invalidVersion of [
|
||||
'LATEST',
|
||||
'latest',
|
||||
'1.0.0 - 2.0.0',
|
||||
'1.x',
|
||||
'1.0.0 || 2.0.0',
|
||||
'^1.2.3',
|
||||
'~1.2.3',
|
||||
'*',
|
||||
'>=1.0.0',
|
||||
]) {
|
||||
expect(invalidVersion, invalidVersion).not.toMatch(
|
||||
rendererVersionPattern,
|
||||
);
|
||||
}
|
||||
expect(matrix).toContain('FAIL — migration blocked');
|
||||
expect(matrix).toContain('No VS Code transport is selected in MR1');
|
||||
expect(matrix).not.toMatch(/pass; selected/i);
|
||||
});
|
||||
|
||||
it('preserves current ChatRecord and Web Shell runtime semantics', () => {
|
||||
const records = readJsonLines<unknown>(
|
||||
resolve(caseRoot, 'chat-records.jsonl'),
|
||||
);
|
||||
const expected = readJson<ExpectedModel>(
|
||||
resolve(caseRoot, 'expected-model.json'),
|
||||
);
|
||||
const expectedRender = readJson<ExpectedRenderItems>(
|
||||
resolve(caseRoot, 'expected-render-items.json'),
|
||||
);
|
||||
const projection = projectChatRecordsToDaemonTranscript(records);
|
||||
const messages = transcriptBlocksToDaemonMessages(projection.blocks);
|
||||
const toolBlock = projection.blocks.find((block) => block.kind === 'tool');
|
||||
const toolMessage = messages.find(
|
||||
(message) => message.role === 'tool_group',
|
||||
);
|
||||
|
||||
expect(projection.complete).toBe(true);
|
||||
expect(projection.diagnostics).toEqual([]);
|
||||
expect(projection.blocks.map((block) => block.kind)).toEqual(
|
||||
expected.kinds,
|
||||
);
|
||||
expect(
|
||||
projection.blocks.flatMap((block) => {
|
||||
switch (block.kind) {
|
||||
case 'user':
|
||||
case 'assistant':
|
||||
case 'thought':
|
||||
return [block.text];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
).toEqual(expected.texts);
|
||||
expect(
|
||||
projection.blocks.map((block) => block.sourceRecordIds ?? []),
|
||||
).toEqual(expected.sourceRecordIds);
|
||||
expect(messages.map((message) => message.role)).toEqual(
|
||||
expectedRender.roles,
|
||||
);
|
||||
expect(
|
||||
messages.flatMap((message) => {
|
||||
switch (message.role) {
|
||||
case 'user':
|
||||
case 'thinking':
|
||||
case 'assistant':
|
||||
return [message.content];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
).toEqual(expectedRender.expectedTextContent);
|
||||
expect(toolBlock).toMatchObject({
|
||||
rawInput: expectedRender.expectedToolArgs,
|
||||
rawOutput: expectedRender.expectedToolResult,
|
||||
});
|
||||
expect(toolMessage).toMatchObject({
|
||||
tools: [
|
||||
{
|
||||
args: expectedRender.expectedToolArgs,
|
||||
rawOutput: expectedRender.expectedToolResult,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(expectedRender.runtimeFields).toEqual(['rawInput', 'rawOutput']);
|
||||
});
|
||||
|
||||
it('records both VS Code identity candidates as reproducible blockers', () => {
|
||||
const daemonEvents = readJsonLines<DaemonEvent>(
|
||||
resolve(caseRoot, 'daemon-events.jsonl'),
|
||||
);
|
||||
const acpUpdates = readJsonLines<unknown>(
|
||||
resolve(caseRoot, 'acp-session-updates.jsonl'),
|
||||
);
|
||||
const expectedGate = readJson<ExpectedGate>(
|
||||
resolve(caseRoot, 'expected-gate.json'),
|
||||
);
|
||||
const observedGate: ExpectedGate = {
|
||||
overall: 'fail',
|
||||
selectedVscodePath: null,
|
||||
candidates: {
|
||||
directDaemon: probeIdentity(
|
||||
reduceDaemonEvents(daemonEvents),
|
||||
reduceDaemonEvents(daemonEvents.slice(1)),
|
||||
),
|
||||
acp: probeIdentity(
|
||||
reduceAcpUpdates(acpUpdates),
|
||||
reduceAcpUpdates(acpUpdates.slice(1)),
|
||||
),
|
||||
},
|
||||
blockers: [
|
||||
'direct-daemon uses reducer ordinal block IDs that change when history is prepended',
|
||||
'ACP text updates do not carry a stable source identity and inherit the same ordinal block IDs',
|
||||
],
|
||||
};
|
||||
|
||||
expect(observedGate).toEqual(expectedGate);
|
||||
});
|
||||
|
||||
it('fails closed on ambiguous identity keys and records kind sets', () => {
|
||||
const assistantBlock = (
|
||||
id: string,
|
||||
text: string,
|
||||
): DaemonTranscriptBlock => ({
|
||||
id,
|
||||
kind: 'assistant',
|
||||
clientReceivedAt: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
text,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
probeIdentity(
|
||||
[assistantBlock('complete-1', 'duplicate')],
|
||||
[
|
||||
assistantBlock('partial-1', 'duplicate'),
|
||||
assistantBlock('partial-2', 'duplicate'),
|
||||
],
|
||||
),
|
||||
).toThrow(/Ambiguous partial identity probe semantic key/u);
|
||||
|
||||
expect(
|
||||
probeIdentity(
|
||||
[
|
||||
assistantBlock('complete-1', 'first'),
|
||||
assistantBlock('complete-2', 'second'),
|
||||
],
|
||||
[
|
||||
assistantBlock('partial-1', 'first'),
|
||||
assistantBlock('partial-2', 'second'),
|
||||
],
|
||||
),
|
||||
).toEqual({
|
||||
status: 'fail',
|
||||
stableUnderPartialPrepend: false,
|
||||
unstableBlockKinds: ['assistant'],
|
||||
missingNativeTextIdentity: ['assistant'],
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
probeIdentity(
|
||||
[
|
||||
{
|
||||
id: 'status-1',
|
||||
kind: 'status',
|
||||
clientReceivedAt: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
text: 'status',
|
||||
},
|
||||
],
|
||||
[],
|
||||
),
|
||||
).toThrow(/Unsupported identity probe block kind: status/u);
|
||||
});
|
||||
});
|
||||
@@ -371,6 +371,7 @@ describe('qwen serve — capabilities envelope', () => {
|
||||
'workspace_tool_toggle',
|
||||
'workspace_skill_toggle',
|
||||
'workspace_skill_batch_toggle',
|
||||
'extension_batch_activation_v2',
|
||||
'workspace_skill_manage',
|
||||
'workspace_settings',
|
||||
'workspace_permissions',
|
||||
@@ -405,6 +406,7 @@ describe('qwen serve — capabilities envelope', () => {
|
||||
'workspace_runtime_removal',
|
||||
'workspace_qualified_rest_core',
|
||||
'extension_management_v2',
|
||||
'extension_git_credentials',
|
||||
'workspace_persisted_transcript',
|
||||
'workspace_session_export',
|
||||
'workspace_archived_session_export',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Chat transcript contract prevalidation matrix
|
||||
|
||||
MR1 freezes evidence for the current paths. A green test run means that the
|
||||
evidence is reproducible; it does not turn a failed migration gate into a pass.
|
||||
|
||||
| Capability | Current path under test | Evidence | MR1 gate | Follow-up owner |
|
||||
| ------------------------------- | --------------------------------------------------- | ------------------------------------------------------ | ---------------------------- | ----------------------- |
|
||||
| ChatRecord semantic projection | persisted records → SDK transcript projector | representative record fixture and semantic snapshot | PASS | existing SDK path |
|
||||
| Web Shell runtime compatibility | SDK blocks → default interactive/read-only adapter | roles plus unchanged `rawInput`/`rawOutput` assertions | PASS | existing Web Shell path |
|
||||
| `write_file` Turn Output | raw tool input → complete file diff | focused Web Shell regression | PASS | existing Web Shell path |
|
||||
| direct-daemon identity | daemon envelopes → current SDK reducer | full history versus partial-prepend probe | **FAIL — migration blocked** | MR2 |
|
||||
| ACP identity | ACP session updates → current SDK reducer | full history versus partial-prepend probe | **FAIL — migration blocked** | MR2 |
|
||||
| Export document contract | frozen V1 schema and security allowlist | schema and hash assertions only | DEFERRED | MR2 |
|
||||
| document-mode rendering | sanitized export document → Web Shell document mode | no production consumer or browser probe in MR1 | DEFERRED | MR2 |
|
||||
| VS Code migration | selected transport → shared ChatPanel contract | depends on a passing identity gate | BLOCKED | MR2 |
|
||||
| Desktop reuse | packaged Web Shell artifact | no installed-artifact behavior probe in MR1 | DEFERRED | existing Desktop path |
|
||||
|
||||
No VS Code transport is selected in MR1. Both current candidate paths use
|
||||
reducer-ordinal block IDs, and the ACP text updates also lack a native stable
|
||||
source identity. MR2 must resolve and verify those facts before selecting a
|
||||
transport or wiring a production consumer.
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Inspect the contract"}}
|
||||
{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Checking identity"}}
|
||||
{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The contract is stable."}}
|
||||
{"sessionUpdate":"tool_call","toolCallId":"read-1","title":"Read file","status":"completed","rawInput":{"path":"src/index.ts"},"_meta":{"toolName":"read"}}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{"uuid":"user-1","parentUuid":null,"sessionId":"session-test","timestamp":"2026-08-16T00:00:00.000Z","type":"user","cwd":"/workspace/project","version":"test","message":{"role":"user","parts":[{"text":"Inspect the contract"}]}}
|
||||
{"uuid":"assistant-1","parentUuid":"user-1","sessionId":"session-test","timestamp":"2026-08-16T00:00:01.000Z","type":"assistant","cwd":"/workspace/project","version":"test","message":{"role":"model","parts":[{"text":"Checking identity","thought":true},{"text":"The contract is stable."}]}}
|
||||
{"uuid":"tool-start","parentUuid":"assistant-1","sessionId":"session-test","timestamp":"2026-08-16T00:00:02.000Z","type":"assistant","cwd":"/workspace/project","version":"test","message":{"role":"model","parts":[{"functionCall":{"id":"read-1","name":"read_file","args":{"path":"/workspace/project/src/index.ts"}}}]}}
|
||||
{"uuid":"tool-result","parentUuid":"tool-start","sessionId":"session-test","timestamp":"2026-08-16T00:00:03.000Z","type":"tool_result","cwd":"/workspace/project","version":"test","message":{"role":"user","parts":[{"functionResponse":{"id":"read-1","name":"read_file","response":{"output":"hidden raw output"}}}]},"toolCallResult":{"callId":"read-1","status":"success","resultDisplay":{"type":"vision_bridge_notice","summary":"Visible summary","notice":"Visible notice"}}}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{"id":10,"v":1,"type":"session_update","data":{"update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Inspect the contract"}}}}
|
||||
{"id":20,"v":1,"type":"session_update","data":{"update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Checking identity"}}}}
|
||||
{"id":30,"v":1,"type":"session_update","data":{"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The contract is stable."}}}}
|
||||
{"id":40,"v":1,"type":"session_update","data":{"update":{"sessionUpdate":"tool_call","toolCallId":"read-1","title":"Read file","status":"completed","rawInput":{"path":"src/index.ts"},"_meta":{"toolName":"read"}}}}
|
||||
{"id":50,"v":1,"type":"permission_request","data":{"requestId":"permission-1","sessionId":"session-test","title":"Allow read?","options":[{"optionId":"allow","name":"Allow","kind":"allow_once"}],"toolCall":{"toolCallId":"read-2","name":"read","kind":"read","rawInput":{"path":"src/other.ts"}}}}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"forbiddenFields": [
|
||||
"rawInput",
|
||||
"rawOutput",
|
||||
"eventId",
|
||||
"uniqueFiles",
|
||||
"serverTimestamp",
|
||||
"promptId",
|
||||
"branchRecordId",
|
||||
"debugReason",
|
||||
"toolCall",
|
||||
"details",
|
||||
"locations",
|
||||
"sourceRecordIds",
|
||||
"sessionId",
|
||||
"sessionLabel"
|
||||
],
|
||||
"frozenErrorKinds": [
|
||||
"missing_binary",
|
||||
"blocked_egress",
|
||||
"auth_env_error",
|
||||
"init_timeout",
|
||||
"restore_timeout",
|
||||
"protocol_error",
|
||||
"missing_file",
|
||||
"parse_error",
|
||||
"budget_exhausted",
|
||||
"mcp_budget_would_exceed",
|
||||
"mcp_server_spawn_failed",
|
||||
"invalid_config",
|
||||
"prompt_deadline_exceeded",
|
||||
"writer_idle_timeout",
|
||||
"model_stream_interrupted",
|
||||
"loop_detected"
|
||||
],
|
||||
"timestamps": 0,
|
||||
"implementation": "deferred-to-mr2"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"overall": "fail",
|
||||
"selectedVscodePath": null,
|
||||
"candidates": {
|
||||
"directDaemon": {
|
||||
"status": "fail",
|
||||
"stableUnderPartialPrepend": false,
|
||||
"unstableBlockKinds": ["thought", "assistant", "tool", "permission"],
|
||||
"missingNativeTextIdentity": ["user", "thought", "assistant"]
|
||||
},
|
||||
"acp": {
|
||||
"status": "fail",
|
||||
"stableUnderPartialPrepend": false,
|
||||
"unstableBlockKinds": ["thought", "assistant", "tool"],
|
||||
"missingNativeTextIdentity": ["user", "thought", "assistant"]
|
||||
}
|
||||
},
|
||||
"blockers": [
|
||||
"direct-daemon uses reducer ordinal block IDs that change when history is prepended",
|
||||
"ACP text updates do not carry a stable source identity and inherit the same ordinal block IDs"
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"kinds": ["user", "thought", "assistant", "tool"],
|
||||
"texts": [
|
||||
"Inspect the contract",
|
||||
"Checking identity",
|
||||
"The contract is stable."
|
||||
],
|
||||
"sourceRecordIds": [
|
||||
["user-1"],
|
||||
["assistant-1"],
|
||||
["assistant-1"],
|
||||
["tool-start", "tool-result"]
|
||||
]
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"roles": ["user", "thinking", "assistant", "tool_group"],
|
||||
"expectedTextContent": [
|
||||
"Inspect the contract",
|
||||
"Checking identity",
|
||||
"The contract is stable."
|
||||
],
|
||||
"runtimeFields": ["rawInput", "rawOutput"],
|
||||
"expectedToolArgs": { "path": "/workspace/project/src/index.ts" },
|
||||
"expectedToolResult": {
|
||||
"type": "vision_bridge_notice",
|
||||
"summary": "Visible summary",
|
||||
"notice": "Visible notice"
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"fixtureVersion": 1,
|
||||
"name": "representative",
|
||||
"generatorVersion": "chat-transcript-prevalidation-evidence-v1",
|
||||
"sources": ["daemon", "acp", "chat-records"],
|
||||
"consumers": ["web", "tauri", "vscode", "html"],
|
||||
"capabilities": [
|
||||
"semantic-projection",
|
||||
"runtime-raw-compatibility",
|
||||
"stable-identity-prepend-probe",
|
||||
"export-document-schema",
|
||||
"two-mr-migration-gate"
|
||||
],
|
||||
"complete": true,
|
||||
"expectedDiagnostics": [
|
||||
"direct_daemon_unstable_identity",
|
||||
"acp_unstable_identity"
|
||||
],
|
||||
"normalizedFields": ["clientReceivedAt", "createdAt", "updatedAt"],
|
||||
"hashes": {
|
||||
"capability-matrix.md": "2f8925d7343b47f70ee66df15939ab5d1c1d2ee58dfd291ccaa3360d08a124ca",
|
||||
"cases/representative/daemon-events.jsonl": "196d6d03c8e71545123a2be340f41f9ad128fe9a53ddb934511858c89e936041",
|
||||
"cases/representative/acp-session-updates.jsonl": "7c7fc96fcf3768c8595ce21d069cb7e7cabccc2d5359596e2c442ff7df887a34",
|
||||
"cases/representative/chat-records.jsonl": "b66abea928c3c65cdedc4ca1c455d86b1b0a46b90e2a6a186afefaa89e87db0e",
|
||||
"cases/representative/expected-model.json": "c0380aac16a7d85e855148fff95959d58f9662ff4589b3a230452ef5ada7410e",
|
||||
"cases/representative/expected-render-items.json": "d51acc8a0b6282898fec49f1870c0e78c901af5136b9866da3da874722d1db7b",
|
||||
"cases/representative/expected-export.json": "964a55e8755c458d83d1932b7e5f3e9d8167894ac9f49cf5fdb097ad773e5672",
|
||||
"cases/representative/expected-gate.json": "d644198a43a35b765672c407966ec99abedadd8c2de00a587e5e5d983bdd9acf",
|
||||
"schema/export-transcript-document-v1.schema.json": "1c0a48d006d2906d6e527dd131c00ee67ac564028f8ffef7bf04407a9592ae9f",
|
||||
"schema/manifest.schema.json": "c6c72f87a9fafff94ba62cd031259a6fdf7235277a8638be21aa26cc3366f3fa"
|
||||
}
|
||||
}
|
||||
+775
@@ -0,0 +1,775 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://qwen-code.invalid/export-transcript-document-v1.schema.json",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"rendererVersion",
|
||||
"blocks",
|
||||
"diagnostics",
|
||||
"metadata"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": 1 },
|
||||
"rendererVersion": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^([0-9a-f]{64}|(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?)$"
|
||||
},
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "$ref": "#/$defs/block" }
|
||||
},
|
||||
"diagnostics": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["code", "severity", "count"],
|
||||
"properties": {
|
||||
"code": { "type": "string", "maxLength": 128 },
|
||||
"severity": { "enum": ["info", "warning", "error"] },
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": { "$ref": "#/$defs/metadata" }
|
||||
},
|
||||
"$defs": {
|
||||
"block": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/userBlock" },
|
||||
{ "$ref": "#/$defs/assistantBlock" },
|
||||
{ "$ref": "#/$defs/thoughtBlock" },
|
||||
{ "$ref": "#/$defs/toolBlock" },
|
||||
{ "$ref": "#/$defs/shellBlock" },
|
||||
{ "$ref": "#/$defs/userShellBlock" },
|
||||
{ "$ref": "#/$defs/permissionBlock" },
|
||||
{ "$ref": "#/$defs/statusBlock" },
|
||||
{ "$ref": "#/$defs/errorBlock" },
|
||||
{ "$ref": "#/$defs/promptCancelledBlock" }
|
||||
]
|
||||
},
|
||||
"userBlock": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"clientReceivedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"kind": { "const": "user" },
|
||||
"clientReceivedAt": { "const": 0 },
|
||||
"createdAt": { "const": 0 },
|
||||
"updatedAt": { "const": 0 },
|
||||
"text": { "type": "string", "maxLength": 409600 },
|
||||
"streaming": { "const": false },
|
||||
"collapsed": { "type": "boolean" },
|
||||
"parentToolCallId": { "type": "string", "maxLength": 200 },
|
||||
"images": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "$ref": "#/$defs/rasterImage" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"assistantBlock": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"clientReceivedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"kind": { "const": "assistant" },
|
||||
"clientReceivedAt": { "const": 0 },
|
||||
"createdAt": { "const": 0 },
|
||||
"updatedAt": { "const": 0 },
|
||||
"text": { "type": "string", "maxLength": 409600 },
|
||||
"streaming": { "const": false },
|
||||
"collapsed": { "type": "boolean" },
|
||||
"parentToolCallId": { "type": "string", "maxLength": 200 },
|
||||
"images": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "$ref": "#/$defs/rasterImage" }
|
||||
},
|
||||
"usage": { "$ref": "#/$defs/usage" }
|
||||
}
|
||||
},
|
||||
"thoughtBlock": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"clientReceivedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"kind": { "const": "thought" },
|
||||
"clientReceivedAt": { "const": 0 },
|
||||
"createdAt": { "const": 0 },
|
||||
"updatedAt": { "const": 0 },
|
||||
"text": { "type": "string", "maxLength": 409600 },
|
||||
"streaming": { "const": false },
|
||||
"collapsed": { "type": "boolean" },
|
||||
"parentToolCallId": { "type": "string", "maxLength": 200 },
|
||||
"images": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "$ref": "#/$defs/rasterImage" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"toolBlock": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"status": { "enum": ["completed", "failed"] }
|
||||
},
|
||||
"required": ["status"]
|
||||
},
|
||||
"then": { "required": ["resultPreview"] }
|
||||
}
|
||||
],
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"clientReceivedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"toolCallId",
|
||||
"title",
|
||||
"status",
|
||||
"preview"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"kind": { "const": "tool" },
|
||||
"clientReceivedAt": { "const": 0 },
|
||||
"createdAt": { "const": 0 },
|
||||
"updatedAt": { "const": 0 },
|
||||
"toolCallId": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"title": { "type": "string", "maxLength": 409600 },
|
||||
"status": {
|
||||
"enum": ["completed", "failed", "cancelled", "canceled"]
|
||||
},
|
||||
"toolName": { "type": "string", "maxLength": 200 },
|
||||
"toolKind": { "type": "string", "maxLength": 200 },
|
||||
"preview": { "$ref": "#/$defs/toolPreview" },
|
||||
"resultPreview": { "$ref": "#/$defs/toolResultPreview" },
|
||||
"parentToolCallId": { "type": "string", "maxLength": 200 },
|
||||
"parentBlockId": { "type": "string", "maxLength": 200 },
|
||||
"subagentType": { "type": "string", "maxLength": 200 }
|
||||
}
|
||||
},
|
||||
"shellBlock": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"clientReceivedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"kind": { "const": "shell" },
|
||||
"clientReceivedAt": { "const": 0 },
|
||||
"createdAt": { "const": 0 },
|
||||
"updatedAt": { "const": 0 },
|
||||
"text": { "type": "string", "maxLength": 409600 },
|
||||
"stream": { "enum": ["stdout", "stderr"] }
|
||||
}
|
||||
},
|
||||
"userShellBlock": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"clientReceivedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"text",
|
||||
"command"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"kind": { "const": "user_shell" },
|
||||
"clientReceivedAt": { "const": 0 },
|
||||
"createdAt": { "const": 0 },
|
||||
"updatedAt": { "const": 0 },
|
||||
"text": { "type": "string", "maxLength": 409600 },
|
||||
"command": { "type": "string", "maxLength": 409600 },
|
||||
"cwd": { "type": "string", "maxLength": 400 },
|
||||
"stream": { "enum": ["stdout", "stderr"] }
|
||||
}
|
||||
},
|
||||
"permissionBlock": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"clientReceivedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"requestId",
|
||||
"title",
|
||||
"options",
|
||||
"preview"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"kind": { "const": "permission" },
|
||||
"clientReceivedAt": { "const": 0 },
|
||||
"createdAt": { "const": 0 },
|
||||
"updatedAt": { "const": 0 },
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"title": { "type": "string", "maxLength": 409600 },
|
||||
"options": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "$ref": "#/$defs/permissionOption" }
|
||||
},
|
||||
"preview": { "$ref": "#/$defs/toolPreview" },
|
||||
"toolCallId": { "type": "string", "maxLength": 200 },
|
||||
"toolName": { "type": "string", "maxLength": 200 },
|
||||
"toolKind": { "type": "string", "maxLength": 200 },
|
||||
"resolved": {
|
||||
"enum": ["approved", "rejected", "cancelled", "expired", "resolved"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"statusBlock": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"clientReceivedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"kind": { "const": "status" },
|
||||
"clientReceivedAt": { "const": 0 },
|
||||
"createdAt": { "const": 0 },
|
||||
"updatedAt": { "const": 0 },
|
||||
"text": { "type": "string", "maxLength": 409600 },
|
||||
"code": { "type": "string", "maxLength": 128 },
|
||||
"errorKind": {
|
||||
"enum": [
|
||||
"missing_binary",
|
||||
"blocked_egress",
|
||||
"auth_env_error",
|
||||
"init_timeout",
|
||||
"restore_timeout",
|
||||
"protocol_error",
|
||||
"missing_file",
|
||||
"parse_error",
|
||||
"budget_exhausted",
|
||||
"mcp_budget_would_exceed",
|
||||
"mcp_server_spawn_failed",
|
||||
"invalid_config",
|
||||
"prompt_deadline_exceeded",
|
||||
"writer_idle_timeout",
|
||||
"model_stream_interrupted",
|
||||
"loop_detected"
|
||||
]
|
||||
},
|
||||
"source": { "type": "string", "maxLength": 128 }
|
||||
}
|
||||
},
|
||||
"errorBlock": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"clientReceivedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"kind": { "const": "error" },
|
||||
"clientReceivedAt": { "const": 0 },
|
||||
"createdAt": { "const": 0 },
|
||||
"updatedAt": { "const": 0 },
|
||||
"text": { "type": "string", "maxLength": 409600 },
|
||||
"code": { "type": "string", "maxLength": 128 },
|
||||
"errorKind": {
|
||||
"enum": [
|
||||
"missing_binary",
|
||||
"blocked_egress",
|
||||
"auth_env_error",
|
||||
"init_timeout",
|
||||
"restore_timeout",
|
||||
"protocol_error",
|
||||
"missing_file",
|
||||
"parse_error",
|
||||
"budget_exhausted",
|
||||
"mcp_budget_would_exceed",
|
||||
"mcp_server_spawn_failed",
|
||||
"invalid_config",
|
||||
"prompt_deadline_exceeded",
|
||||
"writer_idle_timeout",
|
||||
"model_stream_interrupted",
|
||||
"loop_detected"
|
||||
]
|
||||
},
|
||||
"source": { "type": "string", "maxLength": 128 }
|
||||
}
|
||||
},
|
||||
"promptCancelledBlock": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "kind", "clientReceivedAt", "createdAt", "updatedAt"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"kind": { "const": "prompt_cancelled" },
|
||||
"clientReceivedAt": { "const": 0 },
|
||||
"createdAt": { "const": 0 },
|
||||
"updatedAt": { "const": 0 },
|
||||
"reason": { "type": "string", "maxLength": 409600 }
|
||||
}
|
||||
},
|
||||
"rasterImage": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["data", "mimeType"],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"maxLength": 11184812,
|
||||
"pattern": "^[A-Za-z0-9+/]*={0,2}$"
|
||||
},
|
||||
"mimeType": {
|
||||
"enum": ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["inputTokens", "outputTokens"],
|
||||
"properties": {
|
||||
"inputTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"outputTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"cachedTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissionOption": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["optionId", "label", "raw"],
|
||||
"properties": {
|
||||
"optionId": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"label": { "type": "string", "maxLength": 409600 },
|
||||
"description": { "type": "string", "maxLength": 409600 },
|
||||
"raw": { "const": null }
|
||||
}
|
||||
},
|
||||
"questionOption": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["label", "raw"],
|
||||
"properties": {
|
||||
"label": { "type": "string", "maxLength": 409600 },
|
||||
"description": { "type": "string", "maxLength": 409600 },
|
||||
"raw": { "const": null }
|
||||
}
|
||||
},
|
||||
"question": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["question", "options", "raw"],
|
||||
"properties": {
|
||||
"header": { "type": "string", "maxLength": 200 },
|
||||
"question": { "type": "string", "maxLength": 409600 },
|
||||
"options": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "$ref": "#/$defs/questionOption" }
|
||||
},
|
||||
"raw": { "const": null }
|
||||
}
|
||||
},
|
||||
"todoEntry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "content", "status"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"content": { "type": "string", "maxLength": 409600 },
|
||||
"status": { "enum": ["pending", "in_progress", "completed"] },
|
||||
"priority": { "enum": ["high", "medium", "low"] },
|
||||
"blockedBy": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 128 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"todoPreview": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "entries"],
|
||||
"properties": {
|
||||
"kind": { "const": "todo_list" },
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "$ref": "#/$defs/todoEntry" }
|
||||
},
|
||||
"truncated": { "type": "boolean" },
|
||||
"planId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
}
|
||||
},
|
||||
"toolPreview": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "questions"],
|
||||
"properties": {
|
||||
"kind": { "const": "ask_user_question" },
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "$ref": "#/$defs/question" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "command"],
|
||||
"properties": {
|
||||
"kind": { "const": "command" },
|
||||
"command": { "type": "string", "maxLength": 409600 },
|
||||
"cwd": { "type": "string", "maxLength": 400 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "path"],
|
||||
"properties": {
|
||||
"kind": { "const": "file_diff" },
|
||||
"path": { "type": "string", "maxLength": 400 },
|
||||
"oldText": { "type": "string", "maxLength": 409600 },
|
||||
"newText": { "type": "string", "maxLength": 409600 },
|
||||
"patch": { "type": "string", "maxLength": 409600 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "path"],
|
||||
"properties": {
|
||||
"kind": { "const": "file_read" },
|
||||
"path": { "type": "string", "maxLength": 400 },
|
||||
"range": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "url"],
|
||||
"properties": {
|
||||
"kind": { "const": "web_fetch" },
|
||||
"url": { "type": "string", "maxLength": 409600 },
|
||||
"method": { "type": "string", "maxLength": 16 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "serverId", "toolName"],
|
||||
"properties": {
|
||||
"kind": { "const": "mcp_invocation" },
|
||||
"serverId": { "type": "string", "maxLength": 128 },
|
||||
"toolName": { "type": "string", "maxLength": 128 },
|
||||
"argsSummary": { "type": "string", "maxLength": 409600 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "code"],
|
||||
"properties": {
|
||||
"kind": { "const": "code_block" },
|
||||
"language": { "type": "string", "maxLength": 64 },
|
||||
"code": { "type": "string", "maxLength": 409600 },
|
||||
"origin": { "type": "string", "maxLength": 400 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "query"],
|
||||
"properties": {
|
||||
"kind": { "const": "search" },
|
||||
"query": { "type": "string", "maxLength": 409600 },
|
||||
"resultCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"top": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "type": "string", "maxLength": 409600 }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "columns", "rows"],
|
||||
"properties": {
|
||||
"kind": { "const": "tabular" },
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "type": "string", "maxLength": 409600 }
|
||||
},
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": { "type": "string", "maxLength": 409600 }
|
||||
}
|
||||
},
|
||||
"totalRows": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "prompt"],
|
||||
"properties": {
|
||||
"kind": { "const": "image_generation" },
|
||||
"prompt": { "type": "string", "maxLength": 409600 },
|
||||
"thumbnailUrl": {
|
||||
"type": "string",
|
||||
"maxLength": 11184835,
|
||||
"pattern": "^data:image/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/]*={0,2}$"
|
||||
},
|
||||
"model": { "type": "string", "maxLength": 128 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "agentName", "task"],
|
||||
"properties": {
|
||||
"kind": { "const": "subagent_delegation" },
|
||||
"agentName": { "type": "string", "maxLength": 128 },
|
||||
"task": { "type": "string", "maxLength": 409600 },
|
||||
"parentDelegationId": { "type": "string", "maxLength": 128 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "rows"],
|
||||
"properties": {
|
||||
"kind": { "const": "key_value" },
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["label", "value"],
|
||||
"properties": {
|
||||
"label": { "type": "string", "maxLength": 409600 },
|
||||
"value": { "type": "string", "maxLength": 409600 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "$ref": "#/$defs/todoPreview" },
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind"],
|
||||
"properties": {
|
||||
"kind": { "const": "generic" },
|
||||
"summary": { "type": "string", "maxLength": 409600 }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"toolResultPreview": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/todoPreview" },
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "text"],
|
||||
"properties": {
|
||||
"kind": { "const": "text" },
|
||||
"text": { "type": "string", "maxLength": 409600 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "summary"],
|
||||
"properties": {
|
||||
"kind": { "const": "generic" },
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 409600,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "complete": { "const": true } },
|
||||
"required": ["complete"]
|
||||
},
|
||||
"then": {
|
||||
"properties": { "truncated": { "const": false } }
|
||||
}
|
||||
}
|
||||
],
|
||||
"required": ["exportedAt", "complete", "truncated"],
|
||||
"properties": {
|
||||
"title": { "type": "string", "maxLength": 200 },
|
||||
"startedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"maxLength": 32
|
||||
},
|
||||
"exportedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"maxLength": 32
|
||||
},
|
||||
"complete": { "type": "boolean" },
|
||||
"truncated": { "type": "boolean" },
|
||||
"projectName": { "type": "string", "maxLength": 400 },
|
||||
"repository": { "type": "string", "maxLength": 200 },
|
||||
"gitBranch": { "type": "string", "maxLength": 200 },
|
||||
"model": { "type": "string", "maxLength": 200 },
|
||||
"channel": { "type": "string", "maxLength": 100 },
|
||||
"promptCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"contextUsagePercent": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"contextWindowSize": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"totalTokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"filesWritten": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"linesAdded": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"linesRemoved": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://qwen-code.invalid/chat-transcript-contract/manifest-v1.schema.json",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"fixtureVersion",
|
||||
"name",
|
||||
"sources",
|
||||
"consumers",
|
||||
"capabilities",
|
||||
"complete",
|
||||
"expectedDiagnostics",
|
||||
"hashes"
|
||||
],
|
||||
"properties": {
|
||||
"fixtureVersion": { "const": 1 },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
|
||||
"generatorVersion": { "type": "string", "maxLength": 100 },
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "enum": ["daemon", "acp", "chat-records"] }
|
||||
},
|
||||
"consumers": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "enum": ["web", "tauri", "vscode", "html"] }
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "maxLength": 100 }
|
||||
},
|
||||
"complete": { "type": "boolean" },
|
||||
"expectedDiagnostics": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "maxLength": 128 }
|
||||
},
|
||||
"normalizedFields": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "maxLength": 128 }
|
||||
},
|
||||
"hashes": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ export default defineConfig({
|
||||
__dirname,
|
||||
'../packages/sdk-typescript/dist/daemon/transports.js',
|
||||
),
|
||||
'@qwen-code/sdk/daemon/transcript': resolve(
|
||||
__dirname,
|
||||
'../packages/sdk-typescript/dist/daemon/transcript.js',
|
||||
),
|
||||
'@qwen-code/sdk/daemon': resolve(
|
||||
__dirname,
|
||||
'../packages/sdk-typescript/dist/daemon/index.js',
|
||||
|
||||
Generated
+1
@@ -28712,6 +28712,7 @@
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^2.0.1",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.203.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.203.0",
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@
|
||||
"test:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./cli/daemon-invocation-context.test.ts ./cli/list_directory.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-streaming.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts",
|
||||
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./chat-transcript-contract.test.ts ./cli/daemon-invocation-context.test.ts ./cli/list_directory.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-streaming.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts",
|
||||
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 sdk-typescript",
|
||||
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 sdk-typescript",
|
||||
"test:sdk:python": "python3 -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q",
|
||||
|
||||
@@ -17690,6 +17690,401 @@ describe('createAcpSessionBridge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('live activity watermark (summary updatedAt)', () => {
|
||||
const watermarkOf = (
|
||||
bridge: ReturnType<typeof makeBridge>,
|
||||
sessionId: string,
|
||||
) => bridge.getSessionSummary(sessionId).updatedAt;
|
||||
|
||||
const collectEvents = (
|
||||
bridge: ReturnType<typeof makeBridge>,
|
||||
sessionId: string,
|
||||
events: BridgeEvent[],
|
||||
) => {
|
||||
const sub = (async () => {
|
||||
for await (const ev of bridge.subscribeEvents(sessionId)) {
|
||||
events.push(ev);
|
||||
}
|
||||
})();
|
||||
sub.catch(() => {});
|
||||
return sub;
|
||||
};
|
||||
|
||||
const terminalCount = (events: BridgeEvent[], promptId: string) =>
|
||||
events.filter(
|
||||
(e) =>
|
||||
(e.type === 'turn_complete' || e.type === 'turn_error') &&
|
||||
e.promptId === promptId,
|
||||
).length;
|
||||
|
||||
it('is absent until a running prompt settles, then advances per running terminal', async () => {
|
||||
const handle = makeChannel({});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
// A freshly spawned entry has observed no terminal, so the field must
|
||||
// stay off the wire rather than claiming activity at creation time.
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBeUndefined();
|
||||
|
||||
await bridge.sendPrompt(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'first' }],
|
||||
});
|
||||
const first = watermarkOf(bridge, session.sessionId);
|
||||
// Present as soon as the prompt call settles — no polling window between
|
||||
// the terminal and the summary carrying it.
|
||||
expect(first).toBeDefined();
|
||||
expect(Number.isNaN(Date.parse(first!))).toBe(false);
|
||||
|
||||
await bridge.sendPrompt(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'second' }],
|
||||
});
|
||||
expect(
|
||||
Date.parse(watermarkOf(bridge, session.sessionId)!),
|
||||
).toBeGreaterThan(Date.parse(first!));
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('stays strictly increasing under a pinned clock and tracks wall time once it advances', async () => {
|
||||
// Pinning Date.now is what actually exercises the logical +1ms
|
||||
// tie-breaker: with real time the assertion would pass on wall-clock
|
||||
// movement alone. The pinned instant is far in the future so it stays
|
||||
// ahead of the entry's real createdAt, which floors the first advance.
|
||||
const handle = makeChannel({});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const nowSpy = vi
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValue(Date.parse('2126-08-18T08:00:00.000Z'));
|
||||
try {
|
||||
const values: string[] = [];
|
||||
for (const text of ['one', 'two', 'three']) {
|
||||
await bridge.sendPrompt(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text }],
|
||||
});
|
||||
values.push(watermarkOf(bridge, session.sessionId)!);
|
||||
}
|
||||
expect(values).toEqual([
|
||||
'2126-08-18T08:00:00.000Z',
|
||||
'2126-08-18T08:00:00.001Z',
|
||||
'2126-08-18T08:00:00.002Z',
|
||||
]);
|
||||
|
||||
// The wall-clock half of the max needs pinning too: turns are seconds
|
||||
// apart in production, so a watermark that kept adding the logical
|
||||
// millisecond instead of reading the clock would report a just-active
|
||||
// session as minutes stale and rank it below quieter rows.
|
||||
nowSpy.mockReturnValue(Date.parse('2126-08-18T08:05:00.000Z'));
|
||||
await bridge.sendPrompt(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'four' }],
|
||||
});
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBe(
|
||||
'2126-08-18T08:05:00.000Z',
|
||||
);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('floors the first watermark at createdAt under a wall-clock rollback', async () => {
|
||||
const handle = makeChannel({});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const createdAtMs = Date.parse(
|
||||
bridge.getSessionSummary(session.sessionId).createdAt,
|
||||
);
|
||||
// Live-only session cursors key rows by `updatedAt ?? createdAt` and
|
||||
// carry no emitted-identity list, so a first watermark behind
|
||||
// `createdAt` would move an already-emitted row's key backward
|
||||
// mid-pass. Pin the clock before creation time to force the rollback.
|
||||
const nowSpy = vi
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValue(createdAtMs - 10_000);
|
||||
try {
|
||||
await bridge.sendPrompt(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'rolled back' }],
|
||||
});
|
||||
expect(Date.parse(watermarkOf(bridge, session.sessionId)!)).toBe(
|
||||
createdAtMs + 1,
|
||||
);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('does not advance for a queued-only terminal or a heartbeat', async () => {
|
||||
let releaseBlocker: (() => void) | undefined;
|
||||
const blocked = new Promise<void>((r) => {
|
||||
releaseBlocker = r;
|
||||
});
|
||||
const handle = makeChannel({
|
||||
promptImpl: async (req: PromptRequest) => {
|
||||
if ((req.prompt[0] as { text?: string }).text === 'blocker') {
|
||||
await blocked;
|
||||
}
|
||||
return { stopReason: 'end_turn' } as PromptResponse;
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const events: BridgeEvent[] = [];
|
||||
collectEvents(bridge, session.sessionId, events);
|
||||
|
||||
const blocker = bridge.sendPrompt(
|
||||
session.sessionId,
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'blocker' }],
|
||||
},
|
||||
undefined,
|
||||
{ promptId: 'prompt-a' },
|
||||
);
|
||||
const queued = bridge.sendPrompt(
|
||||
session.sessionId,
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'never runs' }],
|
||||
},
|
||||
undefined,
|
||||
{ promptId: 'prompt-b' },
|
||||
);
|
||||
queued.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
// Admission and an in-flight running prompt are not terminals.
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBeUndefined();
|
||||
expect(bridge.removePendingPrompt(session.sessionId, 'prompt-b')).toEqual(
|
||||
{
|
||||
removed: true,
|
||||
},
|
||||
);
|
||||
// Wait for the victim's formal terminal to actually land, otherwise the
|
||||
// assertion below would pass simply because nothing happened yet.
|
||||
await vi.waitFor(() => expect(terminalCount(events, 'prompt-b')).toBe(1));
|
||||
// It published a cancelled terminal but never ran, so it is not
|
||||
// conversation activity.
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBeUndefined();
|
||||
|
||||
releaseBlocker!();
|
||||
await expect(blocker).resolves.toEqual({ stopReason: 'end_turn' });
|
||||
await expect(queued).rejects.toMatchObject({ name: 'AbortError' });
|
||||
const afterRunningTerminal = watermarkOf(bridge, session.sessionId);
|
||||
expect(afterRunningTerminal).toBeDefined();
|
||||
|
||||
bridge.recordHeartbeat(session.sessionId);
|
||||
// Liveness signals stay separate from conversation activity, otherwise
|
||||
// an idle but connected session would keep reordering.
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBe(afterRunningTerminal);
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('advances exactly once when a running prompt is cancelled', async () => {
|
||||
let observeCancel!: () => void;
|
||||
const cancelObserved = new Promise<void>((r) => {
|
||||
observeCancel = r;
|
||||
});
|
||||
const handle = makeChannel({
|
||||
promptImpl: async () => {
|
||||
await cancelObserved;
|
||||
return { stopReason: 'cancelled' } as PromptResponse;
|
||||
},
|
||||
cancelImpl: () => observeCancel(),
|
||||
});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const events: BridgeEvent[] = [];
|
||||
collectEvents(bridge, session.sessionId, events);
|
||||
|
||||
const running = bridge.sendPrompt(
|
||||
session.sessionId,
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'cancel me' }],
|
||||
},
|
||||
undefined,
|
||||
{ promptId: 'prompt-a' },
|
||||
);
|
||||
running.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBeUndefined();
|
||||
|
||||
await bridge.cancelSession(session.sessionId).catch(() => {});
|
||||
await vi.waitFor(() => expect(terminalCount(events, 'prompt-a')).toBe(1));
|
||||
const afterCancel = watermarkOf(bridge, session.sessionId);
|
||||
expect(afterCancel).toBeDefined();
|
||||
|
||||
// A cancellation is one terminal, so it is one advance.
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(terminalCount(events, 'prompt-a')).toBe(1);
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBe(afterCancel);
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('advances once when a running prompt fails', async () => {
|
||||
// An error terminal is still a running attempt that settled, so recency
|
||||
// moves even though the turn produced no result.
|
||||
const handle = makeChannel({
|
||||
promptImpl: async () => {
|
||||
throw new Error('agent blew up');
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const events: BridgeEvent[] = [];
|
||||
collectEvents(bridge, session.sessionId, events);
|
||||
|
||||
await expect(
|
||||
bridge.sendPrompt(
|
||||
session.sessionId,
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'boom' }],
|
||||
},
|
||||
undefined,
|
||||
{ promptId: 'prompt-a' },
|
||||
),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
await vi.waitFor(() => expect(terminalCount(events, 'prompt-a')).toBe(1));
|
||||
const afterError = watermarkOf(bridge, session.sessionId);
|
||||
expect(afterError).toBeDefined();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(terminalCount(events, 'prompt-a')).toBe(1);
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBe(afterError);
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('advances exactly once for a deadline terminal despite its duplicate publish', async () => {
|
||||
// The deadline path publishes twice: `onDeadline` publishes the terminal
|
||||
// and rejects the raced prompt, then that rejection reaches the `result`
|
||||
// handler and re-enters `publishPromptTerminal`, where the per-prompt
|
||||
// latch drops it. The clock is pinned because that is what makes a
|
||||
// second advance observable — it would report the logical `previous + 1`
|
||||
// instead of the wall time the single terminal actually landed at, and
|
||||
// one logical turn would outrank a later one.
|
||||
let releaseLateResult!: () => void;
|
||||
const lateResult = new Promise<void>((r) => {
|
||||
releaseLateResult = r;
|
||||
});
|
||||
const handle = makeChannel({
|
||||
promptImpl: async () => {
|
||||
await lateResult;
|
||||
return { stopReason: 'end_turn' } as PromptResponse;
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const events: BridgeEvent[] = [];
|
||||
collectEvents(bridge, session.sessionId, events);
|
||||
// The deadline timer is a real timer, so pinning `Date.now` changes only
|
||||
// the recorded watermark, not when the deadline fires.
|
||||
const nowSpy = vi
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValue(Date.parse('2126-08-18T10:00:00.000Z'));
|
||||
try {
|
||||
await expect(
|
||||
bridge.sendPrompt(
|
||||
session.sessionId,
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'wedge past the deadline' }],
|
||||
},
|
||||
undefined,
|
||||
{ promptId: 'prompt-a', deadlineMs: 30 },
|
||||
),
|
||||
).rejects.toBeInstanceOf(PromptDeadlineExceededError);
|
||||
await vi.waitFor(() =>
|
||||
expect(terminalCount(events, 'prompt-a')).toBe(1),
|
||||
);
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBe(
|
||||
'2126-08-18T10:00:00.000Z',
|
||||
);
|
||||
|
||||
// The agent's own result lands after the raced promise already
|
||||
// settled, so it produces neither a second terminal nor a second
|
||||
// advance.
|
||||
releaseLateResult();
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
expect(terminalCount(events, 'prompt-a')).toBe(1);
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBe(
|
||||
'2126-08-18T10:00:00.000Z',
|
||||
);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('never decreases when a forward clock jump is later corrected', async () => {
|
||||
const handle = makeChannel({});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const nowSpy = vi
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValue(Date.parse('2126-08-18T08:00:00.000Z'));
|
||||
try {
|
||||
const settle = (text: string) =>
|
||||
bridge.sendPrompt(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text }],
|
||||
});
|
||||
await settle('before the jump');
|
||||
|
||||
nowSpy.mockReturnValue(Date.parse('2126-08-18T09:00:00.000Z'));
|
||||
await settle('during the jump');
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBe(
|
||||
'2126-08-18T09:00:00.000Z',
|
||||
);
|
||||
|
||||
// Time is corrected back an hour. The watermark stays ahead of wall
|
||||
// time rather than rewinding: clients order rows by this value, so a
|
||||
// decrease would reshuffle a catalog that nothing actually changed.
|
||||
nowSpy.mockReturnValue(Date.parse('2126-08-18T08:00:01.000Z'));
|
||||
await settle('after the correction');
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBe(
|
||||
'2126-08-18T09:00:00.001Z',
|
||||
);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('keeps turn activity out of the session catalog version', async () => {
|
||||
const handle = makeChannel({});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const before = bridge.getSessionCatalogVersion();
|
||||
|
||||
await bridge.sendPrompt(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'settle' }],
|
||||
});
|
||||
|
||||
expect(watermarkOf(bridge, session.sessionId)).toBeDefined();
|
||||
// Advancing the version here would make every live-state poll observe a
|
||||
// mismatch and reload the catalog this field exists to avoid.
|
||||
expect(bridge.getSessionCatalogVersion()).toEqual(before);
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelSession', () => {
|
||||
it('cancels an admitted prompt before agent dispatch', async () => {
|
||||
const events: BridgeEvent[] = [];
|
||||
@@ -31923,4 +32318,112 @@ describe('createAcpSessionBridge — child-resource refresh', () => {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('caches a well-formed child heap report', async () => {
|
||||
const report = {
|
||||
peakOldGenerationBytes: 900,
|
||||
peakLiveSetBytes: 300,
|
||||
peakTotalHeapBytes: 1_200,
|
||||
majorGcCount: 4,
|
||||
majorGcMs: 12.5,
|
||||
unclassifiedSpaceNames: [],
|
||||
};
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: async (method) =>
|
||||
method === SERVE_STATUS_EXT_METHODS.workspaceResource
|
||||
? { rssBytes: 1, cpuPercent: 2, heap: report }
|
||||
: {},
|
||||
});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
try {
|
||||
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
await bridge.refreshChildResource!();
|
||||
expect(bridge.getChildResourceSnapshot!()!.heap).toEqual(report);
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves heap absent rather than zeroed when the child reports none', async () => {
|
||||
// A child spawned outside the daemon sends no heap block. Absent and
|
||||
// zeroed are different claims, and only the second would read as "this
|
||||
// child needed no old generation".
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: async (method) =>
|
||||
method === SERVE_STATUS_EXT_METHODS.workspaceResource
|
||||
? { rssBytes: 1, cpuPercent: 2 }
|
||||
: {},
|
||||
});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
try {
|
||||
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
await bridge.refreshChildResource!();
|
||||
const snapshot = bridge.getChildResourceSnapshot!();
|
||||
expect(snapshot).toMatchObject({ rssBytes: 1 });
|
||||
expect(snapshot!.heap).toBeUndefined();
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a malformed heap report whole, keeping the last good one', async () => {
|
||||
// Rejected as a unit rather than field-by-field: these figures decide
|
||||
// whether a child fits a heap ceiling, and a report with some fields
|
||||
// defaulted would understate a peak while looking complete.
|
||||
const good = {
|
||||
peakOldGenerationBytes: 900,
|
||||
peakLiveSetBytes: 300,
|
||||
peakTotalHeapBytes: 1_200,
|
||||
majorGcCount: 4,
|
||||
majorGcMs: 12.5,
|
||||
unclassifiedSpaceNames: [],
|
||||
};
|
||||
// `typeof NaN === 'number'`, so a type check alone would admit the first
|
||||
// two. A negative peak is a broken sender, not a degraded reading.
|
||||
const malformed: unknown[] = [
|
||||
{ ...good, peakOldGenerationBytes: Number.NaN },
|
||||
{ ...good, peakLiveSetBytes: Number.POSITIVE_INFINITY },
|
||||
{ ...good, peakTotalHeapBytes: -1 },
|
||||
{ ...good, majorGcCount: '4' },
|
||||
{ ...good, unclassifiedSpaceNames: 'old_space' },
|
||||
{ ...good, unclassifiedSpaceNames: [1, 2] },
|
||||
// The daemon caches this array for the child's lifetime and the child
|
||||
// chooses its contents, so it is bounded on both axes.
|
||||
{
|
||||
...good,
|
||||
unclassifiedSpaceNames: Array.from({ length: 65 }, (_, i) => `s${i}`),
|
||||
},
|
||||
{ ...good, unclassifiedSpaceNames: ['x'.repeat(65)] },
|
||||
(() => {
|
||||
const { majorGcMs: _omitted, ...rest } = good;
|
||||
return rest;
|
||||
})(),
|
||||
'not an object',
|
||||
null,
|
||||
];
|
||||
|
||||
let payload: unknown = good;
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: async (method) =>
|
||||
method === SERVE_STATUS_EXT_METHODS.workspaceResource
|
||||
? { rssBytes: 1, cpuPercent: 2, heap: payload }
|
||||
: {},
|
||||
});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
try {
|
||||
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
await bridge.refreshChildResource!();
|
||||
expect(bridge.getChildResourceSnapshot!()!.heap).toEqual(good);
|
||||
|
||||
for (const bad of malformed) {
|
||||
payload = bad;
|
||||
await bridge.refreshChildResource!();
|
||||
// Still the last good report: high-water marks are cumulative and the
|
||||
// child cannot resend history, so clearing them would lose it.
|
||||
expect(bridge.getChildResourceSnapshot!()!.heap).toEqual(good);
|
||||
}
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -190,6 +190,7 @@ import type {
|
||||
BridgeWorkspaceGenerationStreamEvent,
|
||||
BridgePromptContentBlock,
|
||||
BridgePromptRequest,
|
||||
ChildHeapReport,
|
||||
RuntimeMcpServerAddResult,
|
||||
RuntimeMcpServerRemoveResult,
|
||||
} from './bridgeTypes.js';
|
||||
@@ -885,6 +886,13 @@ interface ChannelInfo {
|
||||
childRssBytes?: number;
|
||||
childCpuPercent?: number;
|
||||
childResourceAt?: number;
|
||||
/**
|
||||
* The child's lifetime old-generation high-water marks, when it reports
|
||||
* them. Absent — never zeroed — for a child that predates the fields or was
|
||||
* spawned without the daemon marker, so a reader can tell "not measured"
|
||||
* from a measured zero.
|
||||
*/
|
||||
childHeap?: ChildHeapReport;
|
||||
/**
|
||||
* MUST be set to `true` synchronously by any teardown path BEFORE
|
||||
* awaiting `channel.kill()`. `ensureChannel` treats a dying channel
|
||||
@@ -1202,6 +1210,19 @@ interface SessionEntry {
|
||||
* own per-client eviction.
|
||||
*/
|
||||
clientLastSeenAt: Map<string, number>;
|
||||
/**
|
||||
* Strictly monotonic activity watermark (Date.now() epoch ms), advanced
|
||||
* once when a prompt that reached `running` publishes its first formal
|
||||
* terminal. Projected as `BridgeSessionSummary.updatedAt` so clients can
|
||||
* refresh session recency from the memory-only live-state route instead of
|
||||
* rescanning the persisted catalog after each turn.
|
||||
*
|
||||
* Bridge-local and deliberately not a persistence acknowledgement: the
|
||||
* recorder writes turn results through a serialized async queue, so a
|
||||
* terminal only proves the daemon observed the running attempt settle.
|
||||
* Undefined until the first running turn settles in this bridge.
|
||||
*/
|
||||
lastTurnEndedAtMs?: number;
|
||||
}
|
||||
|
||||
function isServeDebugLoggingEnabled(): boolean {
|
||||
@@ -1957,6 +1978,29 @@ function rememberEnrichedTerminalTurnStatus(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a session's activity watermark past both wall time and its own
|
||||
* previous value. The extra millisecond is a logical tie-breaker that keeps
|
||||
* `updatedAt` strictly increasing when several terminals land inside one
|
||||
* wall-clock millisecond or the clock moves backward; it is not a duration.
|
||||
* A forward clock jump therefore stays until wall time catches up — correcting
|
||||
* it downward would break the monotonicity clients order rows by.
|
||||
*
|
||||
* The first advance floors at `createdAt`: rows without a watermark are keyed
|
||||
* by `createdAt`, and the live-only session cursor carries no emitted-identity
|
||||
* list, so a first watermark behind `createdAt` (wall-clock rollback between
|
||||
* creation and the first terminal) would move an already-emitted row's key
|
||||
* backward mid-pass and let the strictly-older filter return it twice.
|
||||
*/
|
||||
function advanceTurnActivity(entry: SessionEntry): void {
|
||||
const createdAtMs = Date.parse(entry.createdAt);
|
||||
const previous =
|
||||
entry.lastTurnEndedAtMs ??
|
||||
(Number.isFinite(createdAtMs) ? createdAtMs : undefined);
|
||||
entry.lastTurnEndedAtMs =
|
||||
previous === undefined ? Date.now() : Math.max(Date.now(), previous + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the formal terminal event for an accepted prompt exactly once.
|
||||
* All terminal paths (agent settle, queued removal, deadline, session
|
||||
@@ -1992,6 +2036,13 @@ function publishPromptTerminal(
|
||||
// lands here. Queued terminals publish their event alone and must
|
||||
// neither set nor clear session-scoped turn state.
|
||||
const mutateTurnState = pendingEntry.state === 'running';
|
||||
if (mutateTurnState) {
|
||||
// Written before the terminal is published so a client that observed the
|
||||
// terminal and then starts a live-state request cannot read a stale
|
||||
// watermark. A queued-only terminal is not conversation activity and
|
||||
// leaves the watermark alone.
|
||||
advanceTurnActivity(entry);
|
||||
}
|
||||
if (!mutateTurnState && entry.turnErrorEvent) {
|
||||
// A queued terminal is still a turn boundary on the bus: ingesting it
|
||||
// folds and resets the live journal, erasing the guard's only evidence
|
||||
@@ -3534,6 +3585,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
sessionId: entry.sessionId,
|
||||
workspaceCwd: entry.workspaceCwd,
|
||||
createdAt: entry.createdAt,
|
||||
...(entry.lastTurnEndedAtMs !== undefined
|
||||
? { updatedAt: new Date(entry.lastTurnEndedAtMs).toISOString() }
|
||||
: {}),
|
||||
displayName: entry.displayName,
|
||||
...(entry.parentSessionId
|
||||
? { parentSessionId: entry.parentSessionId }
|
||||
@@ -5293,6 +5347,76 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
return response as unknown as T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Caps on the one variable-length field a child controls in its heap
|
||||
* report. V8 exposes 11 spaces on Node 22 and 13 on Node 24, all named well
|
||||
* under 64 characters, so a legitimate report never approaches either.
|
||||
*/
|
||||
const MAX_CHILD_HEAP_UNCLASSIFIED_NAMES = 64;
|
||||
const MAX_CHILD_HEAP_SPACE_NAME_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Validate a child's self-reported heap block at the trust boundary.
|
||||
*
|
||||
* Returns `undefined` for anything malformed rather than a partially filled
|
||||
* report: these figures exist to decide whether a child fits a heap ceiling,
|
||||
* and a report with some fields defaulted would understate a peak while
|
||||
* looking complete. Rejecting the whole block leaves the channel's last good
|
||||
* report in place, which is the honest fallback.
|
||||
*
|
||||
* `Number.isFinite` and not just `typeof`, because `typeof NaN === 'number'`
|
||||
* — the same reason the rss and cpu checks above are written this way.
|
||||
*/
|
||||
const parseChildHeapReport = (
|
||||
value: unknown,
|
||||
): ChildHeapReport | undefined => {
|
||||
if (typeof value !== 'object' || value === null) return undefined;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const counts: Array<keyof ChildHeapReport> = [
|
||||
'peakOldGenerationBytes',
|
||||
'peakLiveSetBytes',
|
||||
'peakTotalHeapBytes',
|
||||
'majorGcCount',
|
||||
'majorGcMs',
|
||||
];
|
||||
const parsed: Record<string, number> = {};
|
||||
for (const key of counts) {
|
||||
const n = raw[key];
|
||||
// Negative bytes or pause times are not a degraded reading, they are a
|
||||
// broken sender; treat them like any other malformed field.
|
||||
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) {
|
||||
return undefined;
|
||||
}
|
||||
parsed[key] = n;
|
||||
}
|
||||
// Bounded because the daemon caches this array on the channel for the
|
||||
// child's lifetime, and the child chooses its contents. It is the one
|
||||
// variable-length field in the report, and a workstream about bounding
|
||||
// daemon memory should not add an unbounded retained container. Real
|
||||
// values come from V8's heap-space list: around a dozen entries with
|
||||
// short names, so these caps are far above anything legitimate.
|
||||
const names = raw['unclassifiedSpaceNames'];
|
||||
if (
|
||||
!Array.isArray(names) ||
|
||||
names.length > MAX_CHILD_HEAP_UNCLASSIFIED_NAMES ||
|
||||
names.some(
|
||||
(name) =>
|
||||
typeof name !== 'string' ||
|
||||
name.length > MAX_CHILD_HEAP_SPACE_NAME_LENGTH,
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
peakOldGenerationBytes: parsed['peakOldGenerationBytes'],
|
||||
peakLiveSetBytes: parsed['peakLiveSetBytes'],
|
||||
peakTotalHeapBytes: parsed['peakTotalHeapBytes'],
|
||||
majorGcCount: parsed['majorGcCount'],
|
||||
majorGcMs: parsed['majorGcMs'],
|
||||
unclassifiedSpaceNames: names as string[],
|
||||
};
|
||||
};
|
||||
|
||||
// Daemon Status child-resource: poll the live child's `workspaceResource`
|
||||
// extMethod and cache rss/cpu on the channel. The daemon's metrics sampler
|
||||
// fires this fire-and-forget, then reads the cache synchronously — keeping the
|
||||
@@ -5312,6 +5436,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
const res = await requestWorkspaceStatus<{
|
||||
rssBytes?: unknown;
|
||||
cpuPercent?: unknown;
|
||||
heap?: unknown;
|
||||
}>(SERVE_STATUS_EXT_METHODS.workspaceResource, () => ({}));
|
||||
// A channel swap during the await would otherwise stamp a dead channel;
|
||||
// only write if this is still the live one.
|
||||
@@ -5330,6 +5455,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
// only on the child's send side.
|
||||
info.childCpuPercent = Math.min(100, Math.max(0, res.cpuPercent));
|
||||
}
|
||||
// Only overwrite on a well-formed report. A child that stops sending the
|
||||
// block keeps its last good marks rather than having them cleared: these
|
||||
// are lifetime high-water values, so dropping them would lose history the
|
||||
// child cannot resend.
|
||||
const heap = parseChildHeapReport(res.heap);
|
||||
if (heap) info.childHeap = heap;
|
||||
info.childResourceAt = Date.now();
|
||||
} catch (err) {
|
||||
// Child unreachable / mid-swap — keep the last good cache (or nothing
|
||||
@@ -5347,7 +5478,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
}
|
||||
};
|
||||
const getChildResourceSnapshot = ():
|
||||
| { rssBytes: number; cpuPercent: number; ageMs: number }
|
||||
| {
|
||||
rssBytes: number;
|
||||
cpuPercent: number;
|
||||
ageMs: number;
|
||||
heap?: ChildHeapReport;
|
||||
}
|
||||
| undefined => {
|
||||
const info = liveChannelInfo();
|
||||
if (!info || info.childResourceAt === undefined) return undefined;
|
||||
@@ -5361,6 +5497,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
return {
|
||||
rssBytes: info.childRssBytes ?? 0,
|
||||
cpuPercent: info.childCpuPercent ?? 0,
|
||||
// Deliberately not defaulted. Unlike rss/cpu, where 0 is a plausible
|
||||
// reading, a zeroed heap report would assert the child needed no old
|
||||
// generation — the one conclusion that must never be manufactured.
|
||||
heap: info.childHeap,
|
||||
// Bounded by the guard above, so a caller summing several children's
|
||||
// readings can say how far apart they were taken. Without it a sum of
|
||||
// readings up to `STALE_CHILD_RESOURCE_MS` apart looks instantaneous.
|
||||
|
||||
@@ -60,6 +60,45 @@ export interface RewindSnapshotInfo {
|
||||
diffStats: { filesChanged: number; insertions: number; deletions: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* An ACP child's lifetime V8 old-generation high-water marks, self-reported
|
||||
* through the `workspaceResource` extMethod.
|
||||
*
|
||||
* Observational. Nothing here sizes a child, refuses a spawn, or widens
|
||||
* `limits.memory.enforced` — these exist so a future child-heap policy can be
|
||||
* judged against real workloads instead of against a refusal count that cannot
|
||||
* answer whether a child would survive a smaller ceiling.
|
||||
*
|
||||
* Every figure covers the **old generation**, the thing
|
||||
* `--max-old-space-size` actually bounds, and not `old_space` alone: a child
|
||||
* can OOM against its ceiling with `old_space` at 3 MB while
|
||||
* `large_object_space` holds everything.
|
||||
*/
|
||||
export interface ChildHeapReport {
|
||||
/** High-water committed old-generation bytes. Rises with the ceiling the
|
||||
* child was given, so read it as an upper bound on what the workload needs
|
||||
* rather than as its requirement. */
|
||||
peakOldGenerationBytes: number;
|
||||
/** High-water old-generation bytes still live after a major GC, i.e. what
|
||||
* the workload retains. Independent of the ceiling, which is what makes it
|
||||
* the figure able to say a child cannot fit one. An upper bound rather than
|
||||
* an exact live set: GC entries arrive asynchronously, so allocation
|
||||
* between the collection and the read is counted. 0 when no major GC has
|
||||
* been observed — not a measured zero. */
|
||||
peakLiveSetBytes: number;
|
||||
/** High-water `total_heap_size`. A cross-check needing no space name; it
|
||||
* includes the young generation, so it cannot localise a missing space. */
|
||||
peakTotalHeapBytes: number;
|
||||
/** Major collections over the child's lifetime. */
|
||||
majorGcCount: number;
|
||||
/** Total major-GC pause time in ms — the cost side of a smaller ceiling. */
|
||||
majorGcMs: number;
|
||||
/** Heap spaces the child could classify as neither old- nor young-
|
||||
* generation. Non-empty means the sums above are incomplete and must not be
|
||||
* read as a full measurement. */
|
||||
unclassifiedSpaceNames: string[];
|
||||
}
|
||||
|
||||
export type BridgePromptContentBlock = ContentBlock | SessionMediaReference;
|
||||
|
||||
export type BridgePromptRequest = Omit<PromptRequest, 'prompt'> & {
|
||||
@@ -2084,6 +2123,12 @@ export interface AcpSessionBridge {
|
||||
* field — see {@link pendingPromptTotal} — so a caller aggregating
|
||||
* several children must treat it as unknown rather than as fresh. */
|
||||
ageMs?: number;
|
||||
/** The child's lifetime old-generation high-water marks. Absent when
|
||||
* the child does not report them — a child predating the fields, or
|
||||
* one spawned outside the daemon. Never substituted with zeros: a
|
||||
* measured zero and an unmeasured child are different claims, and
|
||||
* only the first may be read as "this child needed no heap". */
|
||||
heap?: ChildHeapReport;
|
||||
}
|
||||
| undefined;
|
||||
/** Poll the live child's resource extMethod and refresh the cache that
|
||||
|
||||
@@ -899,6 +899,339 @@ describe('SessionArtifactStore', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('updates workspace title and description when the same path is recorded again', async () => {
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's2-workspace-rerecord-title',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await fs.mkdir(path.join(workspace, 'reports'), { recursive: true });
|
||||
await fs.writeFile(path.join(workspace, 'reports/dashboard.html'), 'hello');
|
||||
|
||||
const created = await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Draft',
|
||||
description: 'First pass',
|
||||
workspacePath: 'reports/dashboard.html',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
const artifactId = created.changes[0]?.artifactId;
|
||||
|
||||
const updated = await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Final dashboard',
|
||||
description: 'Ready for review',
|
||||
workspacePath: 'reports/dashboard.html',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
expect(updated.changes).toHaveLength(1);
|
||||
expect(updated.changes[0]).toMatchObject({
|
||||
action: 'updated',
|
||||
artifactId,
|
||||
artifact: {
|
||||
id: artifactId,
|
||||
storage: 'workspace',
|
||||
title: 'Final dashboard',
|
||||
description: 'Ready for review',
|
||||
workspacePath: 'reports/dashboard.html',
|
||||
},
|
||||
});
|
||||
expect((await store.list()).artifacts).toMatchObject([
|
||||
{
|
||||
id: artifactId,
|
||||
title: 'Final dashboard',
|
||||
description: 'Ready for review',
|
||||
workspacePath: 'reports/dashboard.html',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a curated title when write_file re-records the same workspace path', async () => {
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's2-workspace-rerecord-preserve-title',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await fs.mkdir(path.join(workspace, 'reports'), { recursive: true });
|
||||
await fs.writeFile(path.join(workspace, 'reports/sales.csv'), 'a,b\n');
|
||||
|
||||
const created = await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Quarterly sales report',
|
||||
description: 'Curated',
|
||||
workspacePath: 'reports/sales.csv',
|
||||
toolName: 'record_artifact',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
const artifactId = created.changes[0]?.artifactId;
|
||||
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'sales.csv',
|
||||
workspacePath: 'reports/sales.csv',
|
||||
toolName: 'write_file',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
expect((await store.list()).artifacts).toMatchObject([
|
||||
{
|
||||
id: artifactId,
|
||||
title: 'Quarterly sales report',
|
||||
description: 'Curated',
|
||||
toolName: 'record_artifact',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a curated title after write_file then record_artifact then write_file', async () => {
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's2-workspace-rerecord-write-then-curate',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await fs.writeFile(path.join(workspace, 'report.html'), '<html>ok</html>');
|
||||
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'report.html',
|
||||
workspacePath: 'report.html',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-write',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Q3 Report',
|
||||
workspacePath: 'report.html',
|
||||
toolName: 'record_artifact',
|
||||
toolCallId: 'call-record',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'report.html',
|
||||
workspacePath: 'report.html',
|
||||
toolName: 'write_file',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
expect((await store.list()).artifacts).toMatchObject([
|
||||
{
|
||||
title: 'Q3 Report',
|
||||
toolName: 'record_artifact',
|
||||
toolCallId: 'call-record',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a curated title when a record_artifact hook re-records the same path', async () => {
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's2-workspace-rerecord-record-hook',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await fs.writeFile(path.join(workspace, 'report.html'), '<html>ok</html>');
|
||||
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Q3 Report',
|
||||
workspacePath: 'report.html',
|
||||
source: 'tool',
|
||||
toolName: 'record_artifact',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'report.html',
|
||||
workspacePath: 'report.html',
|
||||
source: 'hook',
|
||||
toolName: 'record_artifact',
|
||||
hookEventName: 'PostToolUse',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
expect((await store.list()).artifacts).toMatchObject([
|
||||
{
|
||||
title: 'Q3 Report',
|
||||
source: 'tool',
|
||||
toolName: 'record_artifact',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a title curated without toolName when write_file later auto-records', async () => {
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's2-workspace-rerecord-unattributed-title',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await fs.writeFile(path.join(workspace, 'notes.html'), '<html>ok</html>');
|
||||
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'notes.html',
|
||||
workspacePath: 'notes.html',
|
||||
toolName: 'write_file',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Release notes',
|
||||
workspacePath: 'notes.html',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'notes.html',
|
||||
workspacePath: 'notes.html',
|
||||
toolName: 'write_file',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
expect((await store.list()).artifacts).toMatchObject([
|
||||
{
|
||||
title: 'Release notes',
|
||||
},
|
||||
]);
|
||||
expect((await store.list()).artifacts[0]?.toolName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a hook-curated title when write_file later auto-records the same path', async () => {
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's2-workspace-rerecord-hook-title',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await fs.writeFile(path.join(workspace, 'sales.csv'), 'a,b\n');
|
||||
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Quarterly sales report',
|
||||
workspacePath: 'sales.csv',
|
||||
source: 'hook',
|
||||
toolName: 'write_file',
|
||||
hookEventName: 'PostToolUse',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'sales.csv',
|
||||
workspacePath: 'sales.csv',
|
||||
source: 'tool',
|
||||
toolName: 'write_file',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
expect((await store.list()).artifacts).toMatchObject([
|
||||
{
|
||||
title: 'Quarterly sales report',
|
||||
source: 'hook',
|
||||
toolName: 'write_file',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the existing description when a re-record omits it', async () => {
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's2-workspace-rerecord-keep-description',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await fs.writeFile(path.join(workspace, 'notes.html'), '<html>ok</html>');
|
||||
|
||||
await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Draft',
|
||||
description: 'Keep me',
|
||||
workspacePath: 'notes.html',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
const updated = await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'Final notes',
|
||||
workspacePath: 'notes.html',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
expect(updated.changes[0]?.artifact).toMatchObject({
|
||||
title: 'Final notes',
|
||||
description: 'Keep me',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the later title when the same workspace path appears twice in one batch', async () => {
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's2-workspace-batch-duplicate',
|
||||
workspaceCwd: workspace,
|
||||
});
|
||||
await fs.writeFile(path.join(workspace, 'dup.html'), '<html>ok</html>');
|
||||
|
||||
const created = await store.upsertMany(
|
||||
[
|
||||
{
|
||||
title: 'First',
|
||||
description: 'Old',
|
||||
workspacePath: 'dup.html',
|
||||
},
|
||||
{
|
||||
title: 'Second',
|
||||
description: 'New',
|
||||
workspacePath: 'dup.html',
|
||||
},
|
||||
],
|
||||
{ strict: true },
|
||||
);
|
||||
|
||||
expect(created.changes).toHaveLength(1);
|
||||
expect(created.changes[0]?.artifact).toMatchObject({
|
||||
title: 'Second',
|
||||
description: 'New',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts trusted published file urls outside the workspace', async () => {
|
||||
const store = new SessionArtifactStore({
|
||||
sessionId: 's2-published-file-url',
|
||||
|
||||
@@ -1663,8 +1663,20 @@ function mergeBatchArtifact(
|
||||
delete merged.workspacePath;
|
||||
return merged;
|
||||
}
|
||||
const refreshDisplay =
|
||||
existing.storage === 'workspace' &&
|
||||
next.storage === 'workspace' &&
|
||||
shouldRefreshWorkspaceDisplay(next, existing);
|
||||
return {
|
||||
...existing,
|
||||
title: refreshDisplay ? next.title : existing.title,
|
||||
description: refreshDisplay
|
||||
? (next.description ?? existing.description)
|
||||
: existing.description,
|
||||
toolName: refreshDisplay ? next.toolName : existing.toolName,
|
||||
source: refreshDisplay ? next.source : existing.source,
|
||||
hookEventName: refreshDisplay ? next.hookEventName : existing.hookEventName,
|
||||
toolCallId: refreshDisplay ? next.toolCallId : existing.toolCallId,
|
||||
status: next.status,
|
||||
sizeBytes: mergeSizeBytes(existing, next),
|
||||
metadata: mergeMetadata(existing, next),
|
||||
@@ -1753,6 +1765,20 @@ function mergeArtifact(
|
||||
next.description = incoming.description;
|
||||
delete next.workspacePath;
|
||||
delete next.hideWorkspacePath;
|
||||
} else if (
|
||||
existing.storage === 'workspace' &&
|
||||
incoming.storage === 'workspace' &&
|
||||
shouldRefreshWorkspaceDisplay(incoming, existing)
|
||||
) {
|
||||
// Workspace re-records keep the same locator identity. Explicit
|
||||
// record_artifact (or the same producer) may refresh the display
|
||||
// name; write_file/hook auto-records must not clobber it.
|
||||
next.title = incoming.title;
|
||||
next.description = incoming.description ?? existing.description;
|
||||
next.toolCallId = incoming.toolCallId;
|
||||
next.toolName = incoming.toolName;
|
||||
next.source = incoming.source;
|
||||
next.hookEventName = incoming.hookEventName;
|
||||
}
|
||||
|
||||
const changed = !publicArtifactsEqual(
|
||||
@@ -1778,6 +1804,23 @@ function shouldRecordEphemeralUnpin(
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRefreshWorkspaceDisplay(
|
||||
incoming: Pick<NormalizedArtifact, 'toolName' | 'source' | 'hookEventName'>,
|
||||
existing: Pick<NormalizedArtifact, 'toolName' | 'source' | 'hookEventName'>,
|
||||
): boolean {
|
||||
if (incoming.toolName === 'record_artifact' && incoming.source !== 'hook') {
|
||||
return true;
|
||||
}
|
||||
if (!incoming.toolName) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
incoming.toolName === existing.toolName &&
|
||||
incoming.source === existing.source &&
|
||||
incoming.hookEventName === existing.hookEventName
|
||||
);
|
||||
}
|
||||
|
||||
export function publicArtifactsEqual(
|
||||
a: DaemonSessionArtifact,
|
||||
b: DaemonSessionArtifact,
|
||||
|
||||
@@ -1078,7 +1078,8 @@ export type ServeExtensionInstallType =
|
||||
| 'link'
|
||||
| 'github-release'
|
||||
| 'npm'
|
||||
| 'archive-url';
|
||||
| 'archive-url'
|
||||
| 'snapshot';
|
||||
|
||||
export type ServeExtensionOriginSource =
|
||||
| 'QwenCode'
|
||||
@@ -1133,6 +1134,7 @@ export interface ServeExtensionEntry {
|
||||
originSource?: ServeExtensionOriginSource;
|
||||
ref?: string;
|
||||
autoUpdate?: boolean;
|
||||
credentialPersistence?: 'stored' | 'one_time';
|
||||
updateState?: ServeExtensionUpdateState;
|
||||
capabilities: ServeExtensionCapabilities;
|
||||
details?: ServeExtensionDetails;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { DWClientDownStream } from 'dingtalk-stream-sdk-nodejs';
|
||||
import type {
|
||||
ChannelOutputSegmentContext,
|
||||
@@ -2373,6 +2380,631 @@ describe('DingtalkChannel parsed-message logging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DingtalkChannel quoted media', () => {
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockMediaDownload(mimeType: string, bytes: Uint8Array): string[] {
|
||||
const downloadCodes: string[] = [];
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
(input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith('https://oapi.dingtalk.com/gettoken')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ errcode: 0, access_token: 'app-token' }),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
url === 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'
|
||||
) {
|
||||
const request = JSON.parse(String(init?.body)) as {
|
||||
downloadCode: string;
|
||||
};
|
||||
downloadCodes.push(request.downloadCode);
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ downloadUrl: 'https://example.com/media' }),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(bytes, {
|
||||
status: 200,
|
||||
headers: { 'content-type': mimeType },
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
return downloadCodes;
|
||||
}
|
||||
|
||||
function replyToMedia(
|
||||
channel: DingtalkChannelInstance,
|
||||
msgType: string,
|
||||
content: Record<string, unknown>,
|
||||
): void {
|
||||
replyToMediaWithText(channel, msgType, 'inspect this', content);
|
||||
}
|
||||
|
||||
function replyToMediaWithText(
|
||||
channel: DingtalkChannelInstance,
|
||||
msgType: string,
|
||||
replyText: string,
|
||||
content: Record<string, unknown>,
|
||||
): void {
|
||||
const downstream = {
|
||||
data: JSON.stringify({
|
||||
msgId: `quoted-${msgType}`,
|
||||
conversationType: '2',
|
||||
conversationId: 'cid-quoted-media',
|
||||
sessionWebhook:
|
||||
'https://oapi.dingtalk.com/robot/send?access_token=token',
|
||||
senderNick: 'Alice',
|
||||
senderStaffId: 'staff-1',
|
||||
senderId: 'sender-1',
|
||||
chatbotUserId: 'bot-1',
|
||||
isInAtList: true,
|
||||
text: {
|
||||
content: `@DingTalkTest ${replyText}`,
|
||||
isReplyMsg: true,
|
||||
repliedMsg: {
|
||||
msgId: `media-${msgType}`,
|
||||
msgType,
|
||||
senderId: 'sender-1',
|
||||
content,
|
||||
},
|
||||
},
|
||||
}),
|
||||
headers: { messageId: `quoted-${msgType}` },
|
||||
} as unknown as DWClientDownStream;
|
||||
|
||||
(
|
||||
channel as unknown as { onMessage(d: DWClientDownStream): void }
|
||||
).onMessage(downstream);
|
||||
}
|
||||
|
||||
function sendDirectMedia(
|
||||
channel: DingtalkChannelInstance,
|
||||
msgtype: string,
|
||||
content: Record<string, unknown>,
|
||||
): void {
|
||||
const downstream = {
|
||||
data: JSON.stringify({
|
||||
msgId: `direct-${msgtype}`,
|
||||
conversationType: '1',
|
||||
sessionWebhook:
|
||||
'https://oapi.dingtalk.com/robot/send?access_token=token',
|
||||
senderNick: 'Alice',
|
||||
senderStaffId: 'staff-1',
|
||||
senderId: 'sender-1',
|
||||
chatbotUserId: 'bot-1',
|
||||
msgtype,
|
||||
content,
|
||||
}),
|
||||
headers: { messageId: `direct-${msgtype}` },
|
||||
} as unknown as DWClientDownStream;
|
||||
|
||||
(
|
||||
channel as unknown as { onMessage(d: DWClientDownStream): void }
|
||||
).onMessage(downstream);
|
||||
}
|
||||
|
||||
it('downloads a replied picture and attaches it to the prompt', async () => {
|
||||
const downloadCodes = mockMediaDownload(
|
||||
'image/png',
|
||||
new Uint8Array([1, 2, 3]),
|
||||
);
|
||||
const channel = createChannel();
|
||||
|
||||
replyToMedia(channel, 'picture', { downloadCode: 'quoted-picture-code' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(downloadCodes).toEqual(['quoted-picture-code']);
|
||||
expect(channel.handleInbound).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: 'inspect this',
|
||||
referencedText: '[image]',
|
||||
attachments: [
|
||||
{
|
||||
type: 'image',
|
||||
data: Buffer.from([1, 2, 3]).toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads a replied file and attaches its local path to the prompt', async () => {
|
||||
const downloadCodes = mockMediaDownload(
|
||||
'application/json',
|
||||
new TextEncoder().encode('{"name":"demo"}'),
|
||||
);
|
||||
const channel = createChannel();
|
||||
|
||||
replyToMedia(channel, 'file', {
|
||||
downloadCode: 'quoted-file-code',
|
||||
fileName: 'package.json',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(downloadCodes).toEqual(['quoted-file-code']);
|
||||
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
|
||||
const filePath = envelope.attachments?.[0]?.filePath;
|
||||
if (filePath) tempDirs.add(dirname(filePath));
|
||||
expect(envelope).toMatchObject({
|
||||
text: 'inspect this',
|
||||
referencedText: '[file: package.json]',
|
||||
attachments: [
|
||||
{
|
||||
type: 'file',
|
||||
mimeType: 'application/json',
|
||||
fileName: 'package.json',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(filePath).toBeTruthy();
|
||||
expect(existsSync(filePath!)).toBe(true);
|
||||
expect(readFileSync(filePath!, 'utf8')).toBe('{"name":"demo"}');
|
||||
});
|
||||
|
||||
// R5-1: DingTalk audio/video content carries no fileName (the audio wire
|
||||
// shape is {downloadCode, duration}), so the store name is generated. It
|
||||
// must carry a mimeType-derived extension: the agent reaches the file via
|
||||
// `read_file`, whose type detection is extension-first, and an extensionless
|
||||
// name is refused as binary.
|
||||
it.each([
|
||||
['audio', 'audio/ogg', { duration: 5 }, /^dingtalk_audio_\d+\.ogg$/],
|
||||
['video', 'video/mp4', {}, /^dingtalk_video_\d+\.mp4$/],
|
||||
] as const)(
|
||||
'downloads replied %s media and attaches its local path to the prompt',
|
||||
async (msgType, mimeType, extraContent, expectedName) => {
|
||||
const downloadCodes = mockMediaDownload(
|
||||
mimeType,
|
||||
new Uint8Array([4, 5, 6]),
|
||||
);
|
||||
const channel = createChannel();
|
||||
|
||||
replyToMedia(channel, msgType, {
|
||||
downloadCode: `quoted-${msgType}-code`,
|
||||
...extraContent,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(downloadCodes).toEqual([`quoted-${msgType}-code`]);
|
||||
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
|
||||
const filePath = envelope.attachments?.[0]?.filePath;
|
||||
if (filePath) tempDirs.add(dirname(filePath));
|
||||
expect(envelope).toMatchObject({
|
||||
text: 'inspect this',
|
||||
referencedText: `[${msgType}]`,
|
||||
attachments: [{ type: msgType, mimeType }],
|
||||
});
|
||||
expect(envelope.attachments?.[0]?.fileName).toMatch(expectedName);
|
||||
expect(filePath).toBeTruthy();
|
||||
expect(existsSync(filePath!)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['picture', {}, '[image]'],
|
||||
['file', { fileName: 'missing.pdf' }, '[file: missing.pdf]'],
|
||||
])(
|
||||
'keeps the quoted %s placeholder without downloading when the code is absent',
|
||||
async (msgType, content, referencedText) => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockRejectedValue(new Error('unexpected download'));
|
||||
const channel = createChannel();
|
||||
|
||||
replyToMedia(channel, msgType, content);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(channel.handleInbound).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: 'inspect this',
|
||||
referencedText,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
vi.mocked(channel.handleInbound).mock.calls[0]![0],
|
||||
).not.toHaveProperty('attachments');
|
||||
},
|
||||
);
|
||||
|
||||
it('does not download a quoted message with an unmapped msgType even when it carries a downloadCode', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockRejectedValue(new Error('unexpected download'));
|
||||
const channel = createChannel();
|
||||
|
||||
replyToMedia(channel, 'richText', { downloadCode: 'quoted-rt-code' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
vi.mocked(channel.handleInbound).mock.calls[0]![0],
|
||||
).not.toHaveProperty('attachments');
|
||||
});
|
||||
|
||||
it('attaches both the own media and the quoted media of one message', async () => {
|
||||
const downloadCodes = mockMediaDownload(
|
||||
'image/png',
|
||||
new Uint8Array([1, 2, 3]),
|
||||
);
|
||||
const channel = createChannel();
|
||||
|
||||
const downstream = {
|
||||
data: JSON.stringify({
|
||||
msgId: 'quoted-combo',
|
||||
conversationType: '2',
|
||||
conversationId: 'cid-quoted-media',
|
||||
sessionWebhook:
|
||||
'https://oapi.dingtalk.com/robot/send?access_token=token',
|
||||
senderNick: 'Alice',
|
||||
senderStaffId: 'staff-1',
|
||||
senderId: 'sender-1',
|
||||
chatbotUserId: 'bot-1',
|
||||
isInAtList: true,
|
||||
msgtype: 'picture',
|
||||
content: { downloadCode: 'own-picture-code' },
|
||||
text: {
|
||||
content: '@DingTalkTest inspect both',
|
||||
isReplyMsg: true,
|
||||
repliedMsg: {
|
||||
msgId: 'media-file',
|
||||
msgType: 'file',
|
||||
senderId: 'sender-1',
|
||||
content: {
|
||||
downloadCode: 'quoted-file-code',
|
||||
fileName: 'report.pdf',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
headers: { messageId: 'quoted-combo' },
|
||||
} as unknown as DWClientDownStream;
|
||||
|
||||
(
|
||||
channel as unknown as { onMessage(d: DWClientDownStream): void }
|
||||
).onMessage(downstream);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(downloadCodes).toEqual(['own-picture-code', 'quoted-file-code']);
|
||||
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
|
||||
expect(envelope.attachments).toHaveLength(2);
|
||||
expect(envelope.attachments?.[0]).toEqual({
|
||||
type: 'image',
|
||||
data: Buffer.from([1, 2, 3]).toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
expect(envelope.attachments?.[1]).toMatchObject({
|
||||
type: 'file',
|
||||
fileName: 'report.pdf',
|
||||
});
|
||||
const filePath = envelope.attachments?.[1]?.filePath;
|
||||
if (filePath) tempDirs.add(dirname(filePath));
|
||||
});
|
||||
|
||||
// R4-1: ChannelBase resolves a single inline image per envelope (the first
|
||||
// data-only image attachment fills imageBase64) and silently drops every
|
||||
// later data-only attachment, so the quoted image must be file-backed when
|
||||
// the message's own image already occupies the slot.
|
||||
it('file-backs a quoted image when the message already carries its own image', async () => {
|
||||
const downloadCodes = mockMediaDownload(
|
||||
'image/png',
|
||||
new Uint8Array([1, 2, 3]),
|
||||
);
|
||||
const channel = createChannel();
|
||||
|
||||
const downstream = {
|
||||
data: JSON.stringify({
|
||||
msgId: 'quoted-two-images',
|
||||
conversationType: '2',
|
||||
conversationId: 'cid-quoted-media',
|
||||
sessionWebhook:
|
||||
'https://oapi.dingtalk.com/robot/send?access_token=token',
|
||||
senderNick: 'Alice',
|
||||
senderStaffId: 'staff-1',
|
||||
senderId: 'sender-1',
|
||||
chatbotUserId: 'bot-1',
|
||||
isInAtList: true,
|
||||
msgtype: 'picture',
|
||||
content: { downloadCode: 'own-picture-code' },
|
||||
text: {
|
||||
content: '@DingTalkTest inspect both',
|
||||
isReplyMsg: true,
|
||||
repliedMsg: {
|
||||
msgId: 'media-picture',
|
||||
msgType: 'picture',
|
||||
senderId: 'sender-1',
|
||||
content: { downloadCode: 'quoted-picture-code' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
headers: { messageId: 'quoted-two-images' },
|
||||
} as unknown as DWClientDownStream;
|
||||
|
||||
(
|
||||
channel as unknown as { onMessage(d: DWClientDownStream): void }
|
||||
).onMessage(downstream);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(downloadCodes).toEqual(['own-picture-code', 'quoted-picture-code']);
|
||||
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
|
||||
// extractContent yields the '(image)' placeholder for a picture msgtype.
|
||||
expect(envelope).toMatchObject({
|
||||
text: '(image)',
|
||||
referencedText: '[image]',
|
||||
});
|
||||
expect(envelope.attachments).toHaveLength(2);
|
||||
// The own image keeps the single inline slot ChannelBase resolves.
|
||||
expect(envelope.attachments?.[0]).toEqual({
|
||||
type: 'image',
|
||||
data: Buffer.from([1, 2, 3]).toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
// The quoted image must not be a second data-only attachment — that shape
|
||||
// is silently dropped by ChannelBase's single-image resolution.
|
||||
const quotedAttachment = envelope.attachments?.[1];
|
||||
expect(quotedAttachment).toMatchObject({
|
||||
type: 'image',
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
expect(quotedAttachment).not.toHaveProperty('data');
|
||||
const filePath = quotedAttachment?.filePath;
|
||||
if (filePath) tempDirs.add(dirname(filePath));
|
||||
expect(filePath).toBeTruthy();
|
||||
expect(existsSync(filePath!)).toBe(true);
|
||||
expect(readFileSync(filePath!)).toEqual(Buffer.from([1, 2, 3]));
|
||||
expect(quotedAttachment?.fileName).toMatch(/^dingtalk_image_\d+\.png$/);
|
||||
});
|
||||
|
||||
it('cleans the generated placeholder for a direct file message', async () => {
|
||||
mockMediaDownload('application/octet-stream', new Uint8Array([7, 8, 9]));
|
||||
const channel = createChannel();
|
||||
|
||||
sendDirectMedia(channel, 'file', {
|
||||
downloadCode: 'direct-file-code',
|
||||
fileName: 'notes.txt',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
|
||||
const filePath = envelope.attachments?.[0]?.filePath;
|
||||
if (filePath) tempDirs.add(dirname(filePath));
|
||||
expect(envelope.text).toBe('');
|
||||
expect(envelope.attachments).toMatchObject([
|
||||
{ type: 'file', fileName: 'notes.txt' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('cleans the generated placeholder for a direct audio message', async () => {
|
||||
mockMediaDownload('audio/ogg', new Uint8Array([7, 8, 9]));
|
||||
const channel = createChannel();
|
||||
|
||||
sendDirectMedia(channel, 'audio', { downloadCode: 'direct-audio-code' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
|
||||
const filePath = envelope.attachments?.[0]?.filePath;
|
||||
if (filePath) tempDirs.add(dirname(filePath));
|
||||
expect(envelope.text).toBe('');
|
||||
expect(envelope.attachments?.[0]?.fileName).toMatch(
|
||||
/^dingtalk_audio_\d+\.ogg$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('cleans the generated placeholder for a direct video message', async () => {
|
||||
mockMediaDownload('video/mp4', new Uint8Array([7, 8, 9]));
|
||||
const channel = createChannel();
|
||||
|
||||
sendDirectMedia(channel, 'video', { downloadCode: 'direct-video-code' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
|
||||
const filePath = envelope.attachments?.[0]?.filePath;
|
||||
if (filePath) tempDirs.add(dirname(filePath));
|
||||
expect(envelope.text).toBe('');
|
||||
expect(envelope.attachments).toMatchObject([{ type: 'video' }]);
|
||||
expect(envelope.attachments?.[0]?.fileName).toMatch(
|
||||
/^dingtalk_video_\d+\.mp4$/,
|
||||
);
|
||||
});
|
||||
|
||||
// R1-1: the placeholder cleanup was written for the DIRECT-media path,
|
||||
// where `extractContent` generates `(audio)` / `(file: name)` itself. On the
|
||||
// quoted path `envelope.text` is the user's own reply, so a reply reading
|
||||
// exactly like a placeholder was blanked and the agent got an attachment
|
||||
// with no prompt. A group `@Bot (audio)` arrives here as exactly `(audio)`.
|
||||
it.each([
|
||||
['audio', {}, '(audio)'],
|
||||
['video', {}, '(video)'],
|
||||
['file', { fileName: 'report.pdf' }, '(file: report.pdf)'],
|
||||
])(
|
||||
'keeps a quoted-%s reply whose text looks like a placeholder',
|
||||
async (msgType, extra, replyText) => {
|
||||
mockMediaDownload('application/octet-stream', new Uint8Array([1, 2, 3]));
|
||||
const channel = createChannel();
|
||||
|
||||
replyToMediaWithText(channel, msgType, replyText, {
|
||||
downloadCode: `quoted-${msgType}-code`,
|
||||
...extra,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
|
||||
const filePath = envelope.attachments?.[0]?.filePath;
|
||||
if (filePath) tempDirs.add(dirname(filePath));
|
||||
expect(envelope.text).toBe(replyText);
|
||||
expect(envelope.attachments).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
// R1-2: these are synchronous throw sites. An escape rejected
|
||||
// `processMessage`, whose catch sends the generic error reply and never
|
||||
// calls `handleInbound` — and the msgId is already deduped, so the retry is
|
||||
// dropped and the prompt is lost for good.
|
||||
it('still delivers the text when an over-long quoted file name fails the store', async () => {
|
||||
mockMediaDownload('application/octet-stream', new Uint8Array([1, 2, 3]));
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const channel = createChannel();
|
||||
const channelFilesRoot = join(tmpdir(), 'channel-files');
|
||||
const dirsBefore = new Set(
|
||||
existsSync(channelFilesRoot) ? readdirSync(channelFilesRoot) : [],
|
||||
);
|
||||
|
||||
replyToMedia(channel, 'file', {
|
||||
downloadCode: 'quoted-file-code',
|
||||
fileName: 'a'.repeat(300),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
|
||||
expect(envelope.text).toBe('inspect this');
|
||||
expect(envelope).not.toHaveProperty('attachments');
|
||||
// The failed store must not leak its store directory into tmpdir.
|
||||
const leaked = (
|
||||
existsSync(channelFilesRoot) ? readdirSync(channelFilesRoot) : []
|
||||
).filter((entry) => !dirsBefore.has(entry));
|
||||
expect(leaked).toEqual([]);
|
||||
});
|
||||
|
||||
it('still delivers the text when the quoted file name is not a string', async () => {
|
||||
mockMediaDownload('application/octet-stream', new Uint8Array([1, 2, 3]));
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const channel = createChannel();
|
||||
|
||||
replyToMedia(channel, 'file', {
|
||||
downloadCode: 'quoted-file-code',
|
||||
fileName: 12345,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
|
||||
expect(envelope.text).toBe('inspect this');
|
||||
// The attachment is still delivered, under a generated name.
|
||||
expect(envelope.attachments?.[0]?.fileName).toMatch(
|
||||
/^dingtalk_file_\d+\.bin$/,
|
||||
);
|
||||
const filePath = envelope.attachments?.[0]?.filePath;
|
||||
if (filePath) tempDirs.add(dirname(filePath));
|
||||
});
|
||||
|
||||
it('keeps processing the prompt when a quoted-media download fails', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockRejectedValue(new Error('offline'));
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
const channel = createChannel();
|
||||
|
||||
replyToMedia(channel, 'file', {
|
||||
downloadCode: 'unavailable-file-code',
|
||||
fileName: 'offline.pdf',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
expect(channel.handleInbound).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: 'inspect this',
|
||||
referencedText: '[file: offline.pdf]',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
vi.mocked(channel.handleInbound).mock.calls[0]![0],
|
||||
).not.toHaveProperty('attachments');
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
'[DingTalk:test-dingtalk] Cannot download media: access token refresh failed.\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps processing the prompt when the media download API fails', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith('https://oapi.dingtalk.com/gettoken')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ errcode: 0, access_token: 'app-token' }),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(new Response('unavailable', { status: 503 }));
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
const channel = createChannel();
|
||||
|
||||
replyToMedia(channel, 'file', {
|
||||
downloadCode: 'unavailable-file-code',
|
||||
fileName: 'unavailable.pdf',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(channel.handleInbound).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(channel.handleInbound).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: 'inspect this',
|
||||
referencedText: '[file: unavailable.pdf]',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
vi.mocked(channel.handleInbound).mock.calls[0]![0],
|
||||
).not.toHaveProperty('attachments');
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
'[DingTalk] downloadMedia API failed: HTTP 503 unavailable\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DingtalkChannel downstream logging', () => {
|
||||
it('replaces raw SDK Buffer logging with a structured downstream summary', () => {
|
||||
createChannel();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { basename, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -130,6 +130,19 @@ const DIRECT_MSG_API =
|
||||
const PROACTIVE_MSG_KEY = 'sampleMarkdown'; // DingTalk's built-in {title, text} markdown template key
|
||||
const TOKEN_API = 'https://oapi.dingtalk.com/gettoken';
|
||||
const PROACTIVE_FETCH_TIMEOUT_MS = 15_000;
|
||||
// Extensions for generated media store names, keyed by the download's mime
|
||||
// type. The agent reads stored media via `read_file`, whose type detection is
|
||||
// extension-first: an extensionless name falls through to the binary content
|
||||
// sampler and real image/audio/video bytes are refused.
|
||||
const GENERATED_MEDIA_EXT: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/mpeg': 'mp3',
|
||||
'video/mp4': 'mp4',
|
||||
};
|
||||
const mentionTarget = Symbol('mentionTarget');
|
||||
const IMAGE_INSTRUCTIONS = [
|
||||
'',
|
||||
@@ -1376,6 +1389,11 @@ export class DingtalkChannel extends ChannelBase {
|
||||
private extractQuotedContext(data: DingTalkMessageData): {
|
||||
referencedText?: string;
|
||||
isReplyToBot: boolean;
|
||||
media?: {
|
||||
downloadCode: string;
|
||||
mediaType: 'image' | 'file' | 'audio' | 'video';
|
||||
fileName?: string;
|
||||
};
|
||||
} {
|
||||
// Newer format: text.repliedMsg
|
||||
if (data.text?.isReplyMsg && data.text.repliedMsg) {
|
||||
@@ -1386,7 +1404,21 @@ export class DingtalkChannel extends ChannelBase {
|
||||
// Note: DingTalk doesn't include content for interactiveCard replies
|
||||
// (bot responses sent via webhook). Only user message quotes have text.
|
||||
const text = this.summarizeRepliedContent(replied);
|
||||
return { referencedText: text || undefined, isReplyToBot };
|
||||
const downloadCode = replied.content?.downloadCode;
|
||||
const mediaType = this.mediaTypeFromMsgType(replied.msgType);
|
||||
return {
|
||||
referencedText: text || undefined,
|
||||
isReplyToBot,
|
||||
...(downloadCode && mediaType
|
||||
? {
|
||||
media: {
|
||||
downloadCode,
|
||||
mediaType,
|
||||
fileName: replied.content?.fileName,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy format: quoteMessage
|
||||
@@ -1448,6 +1480,21 @@ export class DingtalkChannel extends ChannelBase {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a DingTalk message type to the media type used for downloads. Shared
|
||||
* by the direct-media (`extractContent`) and quoted-media
|
||||
* (`extractQuotedContext`) paths so the mapping cannot drift between them.
|
||||
*/
|
||||
private mediaTypeFromMsgType(
|
||||
msgType: string | undefined,
|
||||
): 'image' | 'file' | 'audio' | 'video' | undefined {
|
||||
if (msgType === 'picture') return 'image';
|
||||
if (msgType === 'file' || msgType === 'audio' || msgType === 'video') {
|
||||
return msgType;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text and media download codes from an incoming DingTalk message.
|
||||
* Handles text, richText, picture, file, audio, and video message types.
|
||||
@@ -1457,6 +1504,7 @@ export class DingtalkChannel extends ChannelBase {
|
||||
downloadCodes: string[];
|
||||
mediaType?: 'image' | 'file' | 'audio' | 'video';
|
||||
fileName?: string;
|
||||
placeholder?: string;
|
||||
} {
|
||||
const msgtype = data.msgtype || 'text';
|
||||
|
||||
@@ -1487,18 +1535,20 @@ export class DingtalkChannel extends ChannelBase {
|
||||
return {
|
||||
text: '(image)',
|
||||
downloadCodes: code ? [code] : [],
|
||||
mediaType: 'image',
|
||||
mediaType: this.mediaTypeFromMsgType(msgtype),
|
||||
};
|
||||
}
|
||||
|
||||
if (msgtype === 'file') {
|
||||
const code = data.content?.downloadCode;
|
||||
const fileName = data.content?.fileName || undefined;
|
||||
const placeholder = `(file: ${fileName || 'file'})`;
|
||||
return {
|
||||
text: `(file: ${fileName || 'file'})`,
|
||||
text: placeholder,
|
||||
downloadCodes: code ? [code] : [],
|
||||
mediaType: 'file',
|
||||
mediaType: this.mediaTypeFromMsgType(msgtype),
|
||||
fileName,
|
||||
placeholder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1508,7 +1558,8 @@ export class DingtalkChannel extends ChannelBase {
|
||||
return {
|
||||
text: recognition || '(audio)',
|
||||
downloadCodes: code ? [code] : [],
|
||||
mediaType: 'audio',
|
||||
mediaType: this.mediaTypeFromMsgType(msgtype),
|
||||
placeholder: recognition ? undefined : '(audio)',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1517,7 +1568,8 @@ export class DingtalkChannel extends ChannelBase {
|
||||
return {
|
||||
text: '(video)',
|
||||
downloadCodes: code ? [code] : [],
|
||||
mediaType: 'video',
|
||||
mediaType: this.mediaTypeFromMsgType(msgtype),
|
||||
placeholder: '(video)',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1528,12 +1580,20 @@ export class DingtalkChannel extends ChannelBase {
|
||||
/**
|
||||
* Download a media file and attach it to the envelope.
|
||||
* Images → base64 in envelope; files → saved to temp dir with path in text.
|
||||
*
|
||||
* `cleanPlaceholderText` is the placeholder `extractContent` generated for
|
||||
* this message's own media — `(audio)`, `(video)`, `(file: name)`. Only the
|
||||
* direct-media call site has one, and only that call may erase it: on the
|
||||
* quoted-media path `envelope.text` is the user's own reply, and a reply
|
||||
* that happens to read exactly like a placeholder must survive (a group
|
||||
* `@Bot (audio)` reaches here as exactly `(audio)` after mention removal).
|
||||
*/
|
||||
private async attachMedia(
|
||||
envelope: Envelope,
|
||||
downloadCode: string,
|
||||
mediaType: 'image' | 'file' | 'audio' | 'video',
|
||||
fileName?: string,
|
||||
cleanPlaceholderText?: string,
|
||||
): Promise<void> {
|
||||
let token: string;
|
||||
try {
|
||||
@@ -1555,7 +1615,16 @@ export class DingtalkChannel extends ChannelBase {
|
||||
const media = await downloadMedia(downloadCode, robotCode, token);
|
||||
if (!media) return;
|
||||
|
||||
if (mediaType === 'image') {
|
||||
// ChannelBase fills a single imageBase64 slot from the FIRST data-only
|
||||
// image attachment and silently drops every later one, so an image
|
||||
// arriving after the slot is taken (e.g. a quoted picture alongside the
|
||||
// message's own picture) falls through to the file-backed path — the
|
||||
// `saved to:` prompt line is what keeps it reachable for the agent.
|
||||
const inlineImageSlotFree = !(envelope.attachments || []).some(
|
||||
(attachment) => attachment.type === 'image' && attachment.data,
|
||||
);
|
||||
|
||||
if (mediaType === 'image' && inlineImageSlotFree) {
|
||||
const mimeType = media.mimeType.startsWith('image/')
|
||||
? media.mimeType
|
||||
: 'image/jpeg';
|
||||
@@ -1568,19 +1637,52 @@ export class DingtalkChannel extends ChannelBase {
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Save non-image files to temp dir so the agent can read them
|
||||
const dir = join(tmpdir(), 'channel-files', randomUUID());
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const safeName =
|
||||
basename(fileName || '') || `dingtalk_${mediaType}_${Date.now()}`;
|
||||
const filePath = join(dir, safeName);
|
||||
writeFileSync(filePath, media.buffer);
|
||||
// Save the media to temp dir so the agent can read it.
|
||||
//
|
||||
// R1-2: these are synchronous throw sites — ENOSPC on a write of up to
|
||||
// 50 MB, ENAMETOOLONG from a quoted fileName over 255 bytes (`basename`
|
||||
// does not truncate), a TypeError from a truthy non-string fileName. An
|
||||
// escape rejects `processMessage`, whose catch sends the generic error
|
||||
// reply and never calls `handleInbound`; the msgId is already in
|
||||
// `seenMessages`, so DingTalk's retry is deduped and the user's prompt
|
||||
// is lost for good. Degrade the way a failed download already does:
|
||||
// skip the attachment, keep the text.
|
||||
let dir: string | undefined;
|
||||
let filePath: string;
|
||||
let safeName: string;
|
||||
try {
|
||||
dir = join(tmpdir(), 'channel-files', randomUUID());
|
||||
mkdirSync(dir, { recursive: true });
|
||||
safeName =
|
||||
basename(typeof fileName === 'string' ? fileName : '') ||
|
||||
`dingtalk_${mediaType}_${Date.now()}.${
|
||||
GENERATED_MEDIA_EXT[media.mimeType] ?? 'bin'
|
||||
}`;
|
||||
filePath = join(dir, safeName);
|
||||
writeFileSync(filePath, media.buffer);
|
||||
} catch (error) {
|
||||
// The store directory (and any partial file) is useless without the
|
||||
// attachment — remove it so failed stores do not accumulate in tmpdir.
|
||||
if (dir) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best effort; the degraded delivery below is the contract.
|
||||
}
|
||||
}
|
||||
process.stderr.write(
|
||||
`[DingTalk:${this.name}] Cannot store media, delivering the text without it: ${sanitizeLogText(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
300,
|
||||
)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up placeholder text like "(audio)", "(video)", "(file: name)"
|
||||
// Clean up the placeholder this message's own media produced.
|
||||
if (
|
||||
envelope.text === `(file: ${fileName || 'file'})` ||
|
||||
envelope.text === '(audio)' ||
|
||||
envelope.text === '(video)'
|
||||
cleanPlaceholderText !== undefined &&
|
||||
envelope.text === cleanPlaceholderText
|
||||
) {
|
||||
envelope.text = '';
|
||||
}
|
||||
@@ -1745,6 +1847,15 @@ export class DingtalkChannel extends ChannelBase {
|
||||
content.downloadCodes[0]!,
|
||||
content.mediaType,
|
||||
content.fileName,
|
||||
content.placeholder,
|
||||
);
|
||||
}
|
||||
if (quoted.media) {
|
||||
await this.attachMedia(
|
||||
envelope,
|
||||
quoted.media.downloadCode,
|
||||
quoted.media.mediaType,
|
||||
quoted.media.fileName,
|
||||
);
|
||||
}
|
||||
await this.handleInbound(envelope);
|
||||
|
||||
@@ -243,6 +243,11 @@ import { Session, buildAvailableCommandsSnapshot } from './session/Session.js';
|
||||
import { HistoryReplayer } from './session/history-replayer.js';
|
||||
import { renderPreparedGoalUpdate } from './session/recovered-goal-update.js';
|
||||
import { ActiveWorkReporter } from './active-work-reporter.js';
|
||||
import {
|
||||
shouldProbeChildHeap,
|
||||
startChildHeapProbe,
|
||||
type ChildHeapProbe,
|
||||
} from './child-heap-probe.js';
|
||||
import {
|
||||
getModelConfiguration,
|
||||
type ModelReasoningConfiguration,
|
||||
@@ -3896,6 +3901,16 @@ class QwenAgent implements Agent {
|
||||
}
|
||||
})();
|
||||
private prevChildCpuAt = Date.now();
|
||||
/**
|
||||
* Lifetime old-generation high-water marks, for the daemon's
|
||||
* `workspaceResource` poll. `undefined` outside a daemon-spawned child — see
|
||||
* {@link shouldProbeChildHeap} for why the gate is where it is.
|
||||
*
|
||||
* Started at construction rather than on the first poll: the peaks that
|
||||
* matter include the ones reached before anyone asks.
|
||||
*/
|
||||
private readonly childHeapProbe: ChildHeapProbe | undefined =
|
||||
shouldProbeChildHeap(process.env) ? startChildHeapProbe() : undefined;
|
||||
|
||||
/**
|
||||
* Workspace-shared MCP transport pool. Eagerly constructed; lazy
|
||||
@@ -8624,9 +8639,18 @@ class QwenAgent implements Agent {
|
||||
} catch {
|
||||
/* restricted container — report 0 rss */
|
||||
}
|
||||
// Spread rather than always-present fields: a child without the probe
|
||||
// (no daemon marker) omits them entirely, so the daemon can tell "not
|
||||
// measured" from a measured zero. Reporting 0 here would read as "this
|
||||
// child needs no heap", which is the one wrong answer. The probe
|
||||
// itself returns undefined until its first successful read, so a child
|
||||
// whose every V8 call throws (restricted container) omits heap the
|
||||
// same way instead of publishing a zeroed, coverage-complete report.
|
||||
const childHeap = this.childHeapProbe?.snapshot();
|
||||
return {
|
||||
rssBytes,
|
||||
cpuPercent,
|
||||
...(childHeap ? { heap: childHeap } : {}),
|
||||
};
|
||||
}
|
||||
case SERVE_STATUS_EXT_METHODS.sessionContext: {
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type * as v8 from 'node:v8';
|
||||
import { constants as perfConstants } from 'node:perf_hooks';
|
||||
import {
|
||||
shouldProbeChildHeap,
|
||||
startChildHeapProbe,
|
||||
type ChildHeapProbe,
|
||||
type ChildHeapProbeSnapshot,
|
||||
} from './child-heap-probe.js';
|
||||
|
||||
function space(name: string, size: number, used = size): v8.HeapSpaceInfo {
|
||||
return {
|
||||
space_name: name,
|
||||
space_size: size,
|
||||
space_used_size: used,
|
||||
space_available_size: Math.max(0, size - used),
|
||||
physical_space_size: size,
|
||||
};
|
||||
}
|
||||
|
||||
const heap = (total: number): v8.HeapInfo =>
|
||||
({ total_heap_size: total }) as v8.HeapInfo;
|
||||
|
||||
function snapshotOf(probe: ChildHeapProbe): ChildHeapProbeSnapshot {
|
||||
const snapshot = probe.snapshot();
|
||||
if (!snapshot) throw new Error('expected at least one successful sample');
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
describe('shouldProbeChildHeap', () => {
|
||||
it('probes only a daemon-spawned child', () => {
|
||||
expect(shouldProbeChildHeap({ QWEN_CODE_SERVE: '1' })).toBe(true);
|
||||
expect(shouldProbeChildHeap({})).toBe(false);
|
||||
});
|
||||
|
||||
it('treats the marker as an exact value, not a truthy string', () => {
|
||||
// '0' and 'false' are truthy strings. The daemon marker has already had
|
||||
// this bug once, so the negative cases are pinned rather than assumed:
|
||||
// an operator setting QWEN_CODE_SERVE=0 means the opposite of what a
|
||||
// truthiness test concludes.
|
||||
for (const value of ['0', 'false', '', 'true', 'yes', '01', ' 1']) {
|
||||
expect(shouldProbeChildHeap({ QWEN_CODE_SERVE: value })).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('startChildHeapProbe', () => {
|
||||
it('sums old-generation spaces rather than old_space alone', () => {
|
||||
// The regression that motivates the whole module: a child can OOM against
|
||||
// its ceiling with old_space at a few MB while large_object_space holds
|
||||
// everything.
|
||||
const probe = startChildHeapProbe({
|
||||
heapSpaceStatistics: () => [
|
||||
space('old_space', 3 * 1024 * 1024),
|
||||
space('large_object_space', 183 * 1024 * 1024),
|
||||
space('new_space', 64 * 1024 * 1024),
|
||||
],
|
||||
heapStatistics: () => heap(0),
|
||||
});
|
||||
try {
|
||||
expect(snapshotOf(probe).peakOldGenerationBytes).toBe(186 * 1024 * 1024);
|
||||
} finally {
|
||||
probe.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes the young generation and read-only space from the sum', () => {
|
||||
const probe = startChildHeapProbe({
|
||||
heapSpaceStatistics: () => [
|
||||
space('old_space', 100),
|
||||
space('new_space', 5_000),
|
||||
space('new_large_object_space', 5_000),
|
||||
space('read_only_space', 5_000),
|
||||
],
|
||||
heapStatistics: () => heap(0),
|
||||
});
|
||||
try {
|
||||
const snapshot = snapshotOf(probe);
|
||||
expect(snapshot.peakOldGenerationBytes).toBe(100);
|
||||
expect(snapshot.unclassifiedSpaceNames).toEqual([]);
|
||||
} finally {
|
||||
probe.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an unknown space name instead of dropping it silently', () => {
|
||||
const probe = startChildHeapProbe({
|
||||
heapSpaceStatistics: () => [
|
||||
space('old_space', 100),
|
||||
space('some_future_v8_space', 999),
|
||||
],
|
||||
heapStatistics: () => heap(0),
|
||||
});
|
||||
try {
|
||||
const snapshot = snapshotOf(probe);
|
||||
expect(snapshot.unclassifiedSpaceNames).toEqual(['some_future_v8_space']);
|
||||
// Not summed — the point of the field is to say the sum is incomplete,
|
||||
// not to guess which side the unknown space belongs on.
|
||||
expect(snapshot.peakOldGenerationBytes).toBe(100);
|
||||
} finally {
|
||||
probe.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the high-water mark when usage falls back', () => {
|
||||
let committed = 500;
|
||||
const probe = startChildHeapProbe({
|
||||
heapSpaceStatistics: () => [space('old_space', committed)],
|
||||
heapStatistics: () => heap(0),
|
||||
});
|
||||
try {
|
||||
committed = 9_000;
|
||||
expect(snapshotOf(probe).peakOldGenerationBytes).toBe(9_000);
|
||||
committed = 10;
|
||||
expect(snapshotOf(probe).peakOldGenerationBytes).toBe(9_000);
|
||||
} finally {
|
||||
probe.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the last good values when V8 readings throw', () => {
|
||||
let failing = false;
|
||||
const probe = startChildHeapProbe({
|
||||
heapSpaceStatistics: () => {
|
||||
if (failing) throw new Error('restricted container');
|
||||
return [space('old_space', 4_096)];
|
||||
},
|
||||
heapStatistics: () => {
|
||||
if (failing) throw new Error('restricted container');
|
||||
return heap(8_192);
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(snapshotOf(probe).peakOldGenerationBytes).toBe(4_096);
|
||||
failing = true;
|
||||
const snapshot = snapshotOf(probe);
|
||||
expect(snapshot.peakOldGenerationBytes).toBe(4_096);
|
||||
expect(snapshot.peakTotalHeapBytes).toBe(8_192);
|
||||
} finally {
|
||||
probe.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports nothing until the first successful sample', () => {
|
||||
// The restricted-container steady state: every read throws. A zeroed
|
||||
// report here would read downstream as measured, needs-nothing, and
|
||||
// coverage-complete — the manufactured zero the bridge and status layers
|
||||
// refuse to emit — so the probe reports absence instead, exactly like a
|
||||
// child without the probe.
|
||||
let failing = true;
|
||||
const probe = startChildHeapProbe({
|
||||
heapSpaceStatistics: () => {
|
||||
if (failing) throw new Error('restricted container');
|
||||
return [space('old_space', 4_096)];
|
||||
},
|
||||
heapStatistics: () => {
|
||||
if (failing) throw new Error('restricted container');
|
||||
return heap(8_192);
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(probe.snapshot()).toBeUndefined();
|
||||
failing = false;
|
||||
const snapshot = snapshotOf(probe);
|
||||
expect(snapshot.peakOldGenerationBytes).toBe(4_096);
|
||||
expect(snapshot.peakTotalHeapBytes).toBe(8_192);
|
||||
} finally {
|
||||
probe.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the live set at zero until a major GC is observed', () => {
|
||||
// `peakLiveSetBytes` is only meaningful immediately after a major
|
||||
// collection; a used-size read at any other moment includes garbage. With
|
||||
// no major GC observed it must read 0 rather than report an interval
|
||||
// sample as if it were a live set.
|
||||
const probe = startChildHeapProbe({
|
||||
heapSpaceStatistics: () => [space('old_space', 8_000, 7_000)],
|
||||
heapStatistics: () => heap(0),
|
||||
});
|
||||
try {
|
||||
const snapshot = snapshotOf(probe);
|
||||
expect(snapshot.peakOldGenerationBytes).toBe(8_000);
|
||||
expect(snapshot.peakLiveSetBytes).toBe(0);
|
||||
expect(snapshot.majorGcCount).toBe(0);
|
||||
} finally {
|
||||
probe.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('updates the live set and GC figures from major-GC entries', () => {
|
||||
// The observer callback is the only writer of these three fields, so
|
||||
// deliver entries through the same detail.kind check production runs: a
|
||||
// wrong kind comparison or a callback that never runs stays green without
|
||||
// this pin.
|
||||
let gcCallback: ((entries: PerformanceEntry[]) => void) | undefined;
|
||||
let disconnected = false;
|
||||
const gcEntry = (kind: number, duration: number) =>
|
||||
({ detail: { kind }, duration }) as unknown as PerformanceEntry;
|
||||
const probe = startChildHeapProbe({
|
||||
heapSpaceStatistics: () => [space('old_space', 8_000, 7_000)],
|
||||
heapStatistics: () => heap(9_000),
|
||||
gcObserver: (callback) => {
|
||||
gcCallback = callback;
|
||||
return {
|
||||
disconnect: () => {
|
||||
disconnected = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
try {
|
||||
// A minor collection delivers an entry too; it must not move the live
|
||||
// set or the counters.
|
||||
gcCallback?.([gcEntry(perfConstants.NODE_PERFORMANCE_GC_MINOR, 3)]);
|
||||
expect(snapshotOf(probe).peakLiveSetBytes).toBe(0);
|
||||
expect(snapshotOf(probe).majorGcCount).toBe(0);
|
||||
|
||||
gcCallback?.([gcEntry(perfConstants.NODE_PERFORMANCE_GC_MAJOR, 5)]);
|
||||
const snapshot = snapshotOf(probe);
|
||||
expect(snapshot.peakLiveSetBytes).toBe(7_000);
|
||||
expect(snapshot.majorGcCount).toBe(1);
|
||||
expect(snapshot.majorGcMs).toBe(5);
|
||||
} finally {
|
||||
probe.stop();
|
||||
}
|
||||
expect(disconnected).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies every heap space the running Node actually reports', () => {
|
||||
// Deliberately against the real V8, not a fixture. A fixture can only
|
||||
// contain space names somebody already thought of, so only the live call
|
||||
// catches a taxonomy this repo supports and the sets do not — the failure
|
||||
// that would silently under-count every other figure.
|
||||
const probe = startChildHeapProbe();
|
||||
try {
|
||||
expect(snapshotOf(probe).unclassifiedSpaceNames).toEqual([]);
|
||||
} finally {
|
||||
probe.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('tracks a real allocation upward against the real V8', () => {
|
||||
// Compared against a baseline taken before allocating, not against zero.
|
||||
// `> 0` would pass on a probe that never sampled again after construction.
|
||||
const probe = startChildHeapProbe();
|
||||
try {
|
||||
const before = snapshotOf(probe).peakOldGenerationBytes;
|
||||
const held: unknown[] = [];
|
||||
// ~16 MiB per array, into large_object_space — the space an
|
||||
// old_space-only implementation would miss entirely.
|
||||
for (let i = 0; i < 12; i++) held.push(new Array(2_000_000).fill(i));
|
||||
const after = snapshotOf(probe).peakOldGenerationBytes;
|
||||
expect(after).toBeGreaterThan(before + 100 * 1024 * 1024);
|
||||
expect(held.length).toBe(12);
|
||||
} finally {
|
||||
probe.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops sampling after stop()', () => {
|
||||
let reads = 0;
|
||||
const probe = startChildHeapProbe({
|
||||
heapSpaceStatistics: () => {
|
||||
reads++;
|
||||
return [space('old_space', 1)];
|
||||
},
|
||||
heapStatistics: () => heap(1),
|
||||
intervalMs: 1,
|
||||
});
|
||||
probe.stop();
|
||||
const after = reads;
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
expect(reads).toBe(after);
|
||||
resolve();
|
||||
}, 30);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
constants as perfConstants,
|
||||
PerformanceObserver,
|
||||
} from 'node:perf_hooks';
|
||||
import * as v8 from 'node:v8';
|
||||
import { QWEN_CODE_SERVE_ENV } from '../config/acp-channel-fallback.js';
|
||||
|
||||
/**
|
||||
* Spaces that count against `--max-old-space-size`. Summed.
|
||||
*
|
||||
* `old_space` alone is not the bound and reporting it would be actively
|
||||
* misleading: a child driven to a real OOM at `--max-old-space-size=256` held
|
||||
* `old_space` at 3 MB throughout while every byte accumulated in
|
||||
* `large_object_space`. V8's large-object threshold measures at 128 KiB, which
|
||||
* transcripts, journals, and replay rings all clear, so the daemon's most
|
||||
* likely growth is exactly what an `old_space`-only figure cannot see.
|
||||
*/
|
||||
const OLD_GENERATION_SPACES: ReadonlySet<string> = new Set([
|
||||
'old_space',
|
||||
'large_object_space',
|
||||
'code_space',
|
||||
'code_large_object_space',
|
||||
'trusted_space',
|
||||
'trusted_large_object_space',
|
||||
'shared_space',
|
||||
'shared_large_object_space',
|
||||
'shared_trusted_space',
|
||||
'shared_trusted_large_object_space',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Spaces knowingly excluded from the old-generation sum.
|
||||
*
|
||||
* A second set rather than an "everything else is unknown" rule. With one set,
|
||||
* `new_space` and `read_only_space` would land in `unclassifiedSpaceNames` on
|
||||
* every healthy child, and a field that is never empty carries no signal.
|
||||
*/
|
||||
const NON_OLD_GENERATION_SPACES: ReadonlySet<string> = new Set([
|
||||
'new_space',
|
||||
'new_large_object_space',
|
||||
'read_only_space',
|
||||
]);
|
||||
|
||||
/**
|
||||
* A floor under long GC-free stretches, not the primary trigger. Deliberately
|
||||
* coarse: GC entries are what track the peaks, so this only has to stop an
|
||||
* idle-then-busy child from carrying a stale committed mark.
|
||||
*/
|
||||
const SAMPLE_INTERVAL_MS = 30_000;
|
||||
|
||||
export interface ChildHeapProbeSnapshot {
|
||||
/**
|
||||
* Lifetime high-water of committed old-generation bytes.
|
||||
*
|
||||
* Limit-dependent: V8 defers major GC in proportion to the ceiling it was
|
||||
* given, so the same workload commits roughly twice as much under a 16 GB
|
||||
* ceiling as under a ~600 MB one. Read it as an upper bound — a peak at or
|
||||
* below a candidate ceiling is strong evidence the child would survive it,
|
||||
* but a peak above one proves nothing on its own.
|
||||
*/
|
||||
peakOldGenerationBytes: number;
|
||||
/**
|
||||
* Lifetime high-water of old-generation bytes still live after a major GC.
|
||||
*
|
||||
* Limit-independent — measured identical to the decimal under 16 GB and
|
||||
* ~600 MB ceilings — because it is what the workload retains rather than how
|
||||
* much rope V8 was given. That is what lets it refuse: a child whose
|
||||
* retained data exceeds a ceiling cannot run under it, whatever GC does
|
||||
* about the garbage.
|
||||
*
|
||||
* Read as an **upper bound** on the live set, not an exact one. GC entries
|
||||
* reach a `PerformanceObserver` asynchronously and in batches, so the read
|
||||
* happens at the first opportunity after the collection rather than at the
|
||||
* instant it ends, and anything allocated in between is counted. The error
|
||||
* is small in practice — the two-ceiling comparison above was taken this way
|
||||
* and still separated cleanly from the committed figure — but it runs
|
||||
* upward, so a consumer refusing on this figure risks a false refusal rather
|
||||
* than a missed one.
|
||||
*/
|
||||
peakLiveSetBytes: number;
|
||||
/**
|
||||
* Lifetime high-water of `getHeapStatistics().total_heap_size`.
|
||||
*
|
||||
* A cross-check that needs no space name, and a deliberately weak one: it
|
||||
* includes the new generation, so the gap between it and the old-generation
|
||||
* sum is never zero and cannot localise a missing space. It bounds how much
|
||||
* the sum could be missing; `unclassifiedSpaceNames` says whether it is.
|
||||
*/
|
||||
peakTotalHeapBytes: number;
|
||||
/** Major collections observed over the child's lifetime. */
|
||||
majorGcCount: number;
|
||||
/**
|
||||
* Total major-GC pause time. Lowering a ceiling trades memory for GC time,
|
||||
* so a policy that reports only whether children "fit" could make every
|
||||
* child materially slower with nothing on the wire saying so.
|
||||
*/
|
||||
majorGcMs: number;
|
||||
/**
|
||||
* Heap space names in neither known set, so neither summed nor knowingly
|
||||
* excluded. Empty on every taxonomy known today.
|
||||
*
|
||||
* Node 22 reports 11 heap spaces and Node 24 reports 13 — `map_space` was
|
||||
* removed and the `trusted_*` spaces were added — and this repo supports
|
||||
* both, so a name-based sum has to survive a taxonomy it has never seen. The
|
||||
* direction it fails in is what makes this field necessary: an unknown space
|
||||
* silently dropped from the sum makes a child look like it fits a ceiling it
|
||||
* does not. Any consumer must treat a non-empty list as coverage unknown
|
||||
* rather than as a complete measurement.
|
||||
*/
|
||||
unclassifiedSpaceNames: string[];
|
||||
}
|
||||
|
||||
export interface ChildHeapProbe {
|
||||
/**
|
||||
* The lifetime marks, or `undefined` until at least one space read has
|
||||
* succeeded. A child whose every `getHeapSpaceStatistics()` call throws —
|
||||
* the restricted-container case the `catch` in `sample` exists for — must
|
||||
* not report a zeroed object with `unclassifiedSpaceNames: []`: downstream
|
||||
* that reads as measured, needs-nothing, and coverage-complete, the
|
||||
* manufactured zero the bridge and status layers refuse to emit. Omitting
|
||||
* the report matches what a child without the probe already sends.
|
||||
*/
|
||||
snapshot(): ChildHeapProbeSnapshot | undefined;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this process should measure its own old-generation heap.
|
||||
*
|
||||
* Only daemon-spawned ACP children do. `QWEN_CODE_SERVE` is the marker the
|
||||
* daemon already stamps on the children it spawns, so gating on it leaves the
|
||||
* interactive CLI, the IDE companion, direct-embed bridges, and standalone ACP
|
||||
* with no GC observer and no consumer for one — and a workspace cannot forge
|
||||
* it through `.env` or `settings.env`.
|
||||
*
|
||||
* Compared against the exact string `'1'`, matching
|
||||
* `acp-channel-fallback.ts`. The looser check this avoids is a bug the marker
|
||||
* has already had once: `'0'` and `'false'` are truthy strings, and an
|
||||
* operator who sets `QWEN_CODE_SERVE=0` means the opposite of what a
|
||||
* truthiness test would conclude.
|
||||
*/
|
||||
export function shouldProbeChildHeap(env: NodeJS.ProcessEnv): boolean {
|
||||
return env[QWEN_CODE_SERVE_ENV] === '1';
|
||||
}
|
||||
|
||||
interface ChildHeapProbeOptions {
|
||||
/** Injectable for tests; defaults to the real V8 readings. */
|
||||
heapSpaceStatistics?: () => v8.HeapSpaceInfo[];
|
||||
heapStatistics?: () => v8.HeapInfo;
|
||||
intervalMs?: number;
|
||||
/**
|
||||
* Injectable for tests; defaults to a `PerformanceObserver` on `gc`
|
||||
* entries. Throws where the runtime has no such entry type.
|
||||
*/
|
||||
gcObserver?: (callback: (entries: PerformanceEntry[]) => void) => {
|
||||
disconnect(): void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulates lifetime old-generation high-water marks inside an ACP child.
|
||||
*
|
||||
* The marks accumulate here, in the child, rather than being sampled by the
|
||||
* daemon's poll, for two independent reasons. The poll runs every 5 s and is
|
||||
* gated on an active SSE/WS watcher, so with no client attached it takes no
|
||||
* sample at all while the child keeps running — observed on a live daemon
|
||||
* reporting one active child and zero samples. And a peak can be entirely
|
||||
* invisible to a cadence: a 1 s sampler recorded 0 MB across a workload whose
|
||||
* GC-triggered peak was 125 MB.
|
||||
*
|
||||
* Nothing here is reset. The marks describe the whole lifetime of the process,
|
||||
* which is the right scope for "did this child ever need more than the
|
||||
* ceiling"; a channel swap replaces the child, so lifetime and channel
|
||||
* generation coincide.
|
||||
*/
|
||||
export function startChildHeapProbe(
|
||||
options: ChildHeapProbeOptions = {},
|
||||
): ChildHeapProbe {
|
||||
const readSpaces =
|
||||
options.heapSpaceStatistics ?? (() => v8.getHeapSpaceStatistics());
|
||||
const readHeap = options.heapStatistics ?? (() => v8.getHeapStatistics());
|
||||
|
||||
let peakOldGenerationBytes = 0;
|
||||
let peakLiveSetBytes = 0;
|
||||
let peakTotalHeapBytes = 0;
|
||||
let majorGcCount = 0;
|
||||
let majorGcMs = 0;
|
||||
let sampled = false;
|
||||
const unclassified = new Set<string>();
|
||||
|
||||
// Best-effort throughout, mirroring how the `workspaceResource` handler
|
||||
// already wraps `memoryUsage()` and `cpuUsage()`: a throw in a restricted
|
||||
// container leaves the accumulators at their last good values instead of
|
||||
// failing the poll that reads them.
|
||||
const sample = (afterMajorGc: boolean): void => {
|
||||
try {
|
||||
let committed = 0;
|
||||
let live = 0;
|
||||
for (const space of readSpaces()) {
|
||||
const name = space.space_name;
|
||||
if (OLD_GENERATION_SPACES.has(name)) {
|
||||
committed += space.space_size;
|
||||
live += space.space_used_size;
|
||||
} else if (!NON_OLD_GENERATION_SPACES.has(name)) {
|
||||
unclassified.add(name);
|
||||
}
|
||||
}
|
||||
if (committed > peakOldGenerationBytes)
|
||||
peakOldGenerationBytes = committed;
|
||||
// Only after a major GC. A used-size read at any other moment includes
|
||||
// uncollected garbage, which would reintroduce exactly the
|
||||
// limit-dependence that makes this the refusal figure.
|
||||
if (afterMajorGc && live > peakLiveSetBytes) peakLiveSetBytes = live;
|
||||
// Gated on the space read specifically: it is what feeds the old-gen
|
||||
// figures and `unclassifiedSpaceNames`, so its success is what makes a
|
||||
// report worth publishing at all.
|
||||
sampled = true;
|
||||
} catch {
|
||||
/* restricted container — keep the last good values */
|
||||
}
|
||||
try {
|
||||
const total = readHeap().total_heap_size;
|
||||
if (total > peakTotalHeapBytes) peakTotalHeapBytes = total;
|
||||
} catch {
|
||||
/* as above */
|
||||
}
|
||||
};
|
||||
|
||||
sample(false);
|
||||
|
||||
let observer: { disconnect(): void } | undefined;
|
||||
try {
|
||||
const observe =
|
||||
options.gcObserver ??
|
||||
((callback: (entries: PerformanceEntry[]) => void) => {
|
||||
const performanceObserver = new PerformanceObserver((list) =>
|
||||
callback(list.getEntries()),
|
||||
);
|
||||
performanceObserver.observe({ entryTypes: ['gc'] });
|
||||
return performanceObserver;
|
||||
});
|
||||
observer = observe((entries) => {
|
||||
for (const entry of entries) {
|
||||
const isMajor =
|
||||
(entry as { detail?: { kind?: number } }).detail?.kind ===
|
||||
perfConstants.NODE_PERFORMANCE_GC_MAJOR;
|
||||
if (isMajor) {
|
||||
majorGcCount += 1;
|
||||
majorGcMs += entry.duration;
|
||||
}
|
||||
sample(isMajor);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// No GC entries on this runtime. The interval below still produces
|
||||
// committed and total marks; `peakLiveSetBytes` stays 0, which reads as
|
||||
// "no major GC was observed" rather than as a measured zero.
|
||||
observer = undefined;
|
||||
}
|
||||
|
||||
const timer = setInterval(
|
||||
() => sample(false),
|
||||
options.intervalMs ?? SAMPLE_INTERVAL_MS,
|
||||
);
|
||||
// Never hold the child open on account of observation.
|
||||
timer.unref?.();
|
||||
|
||||
return {
|
||||
snapshot() {
|
||||
// Refresh the committed and total marks so a caller polling an idle
|
||||
// child is not told a value older than the interval.
|
||||
sample(false);
|
||||
// Nothing successfully measured yet — report absence, not zeros.
|
||||
if (!sampled) return undefined;
|
||||
return {
|
||||
peakOldGenerationBytes,
|
||||
peakLiveSetBytes,
|
||||
peakTotalHeapBytes,
|
||||
majorGcCount,
|
||||
majorGcMs,
|
||||
unclassifiedSpaceNames: [...unclassified],
|
||||
};
|
||||
},
|
||||
stop() {
|
||||
clearInterval(timer);
|
||||
observer?.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -625,7 +625,9 @@ describe('Session', () => {
|
||||
getHistoryTail: vi.fn(() => getHistoryMock()),
|
||||
getHistoryTailShallow: vi.fn(() => getHistoryMock()),
|
||||
getHistoryShallow: vi.fn().mockReturnValue([]),
|
||||
getHistoryFunctionResponseIds: vi.fn().mockReturnValue(new Set<string>()),
|
||||
getHistoryToolCallFingerprints: vi
|
||||
.fn()
|
||||
.mockReturnValue(new Map<string, string>()),
|
||||
getLastModelMessageText: vi.fn().mockReturnValue(''),
|
||||
setHistory: vi.fn(),
|
||||
truncateHistory: vi.fn(),
|
||||
@@ -3137,6 +3139,23 @@ describe('Session', () => {
|
||||
expect(session.getRewindableUserTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('counts cleared media placeholders as rewindable prompts (twin divergence)', () => {
|
||||
// The TUI twin (isUserTextContent in ui/utils/historyMapping.ts)
|
||||
// excludes microcompaction media-clear placeholders from its rewind
|
||||
// prompt count. The ACP twin must keep counting them: ACP rewind
|
||||
// maps against per-prompt file-history snapshots, which ARE created
|
||||
// for media-only prompts.
|
||||
const history: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: '[Old inline media cleared: image/png]' }],
|
||||
},
|
||||
];
|
||||
vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history);
|
||||
|
||||
expect(session.getRewindableUserTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects unreachable user turns', () => {
|
||||
const history: Content[] = [{ role: 'user', parts: [{ text: 'first' }] }];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
@@ -8434,11 +8453,18 @@ describe('Session', () => {
|
||||
expect(finishedSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops an ACP prompt after a repeated duplicate provider id without sending an empty follow-up', async () => {
|
||||
it('stops an ACP prompt with a visible loop-detected error after a repeated duplicate provider id', async () => {
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
|
||||
vi.mocked(mockChat.getHistoryFunctionResponseIds)
|
||||
.mockReturnValueOnce(new Set<string>())
|
||||
.mockReturnValue(new Set(['shell_1']));
|
||||
vi.mocked(mockChat.getHistoryToolCallFingerprints)
|
||||
.mockReturnValueOnce(new Map<string, string>())
|
||||
.mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
'shell_1',
|
||||
core.getToolCallFingerprint('read_file', { file_path: 'b.ts' }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
const [duplicatePart] = core.normalizeModelToolCallIds(
|
||||
[
|
||||
{
|
||||
@@ -8517,9 +8543,17 @@ describe('Session', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text', text: 'read the file' }],
|
||||
await expect(
|
||||
session.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text', text: 'read the file' }],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
data: {
|
||||
code: 'LOOP_DETECTED',
|
||||
errorKind: 'loop_detected',
|
||||
loopType: core.LoopType.GLOBAL_TOOL_CALL_DUPLICATE,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(3);
|
||||
@@ -8530,6 +8564,21 @@ describe('Session', () => {
|
||||
expect(
|
||||
duplicateFollowUp.message[0].functionResponse?.response?.['error'],
|
||||
).toContain('Duplicate provider tool call id "shell_1"');
|
||||
expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Stopping ACP turn after repeated duplicate provider tool-call id: shell_1',
|
||||
),
|
||||
);
|
||||
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
parts: [
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining(
|
||||
'terminated because the model exceeded tool-call safety limits',
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('stops an ACP prompt after repeated invalid tool parameters with fresh ids', async () => {
|
||||
@@ -9990,8 +10039,13 @@ describe('Session', () => {
|
||||
|
||||
it('clears duplicate provider id tracking between ACP prompts', async () => {
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
|
||||
vi.mocked(mockChat.getHistoryFunctionResponseIds).mockReturnValue(
|
||||
new Set(['shell_1']),
|
||||
vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
'shell_1',
|
||||
core.getToolCallFingerprint('read_file', { file_path: 'b.ts' }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
const [duplicatePart] = core.normalizeModelToolCallIds(
|
||||
[
|
||||
@@ -23789,7 +23843,6 @@ describe('Session', () => {
|
||||
parts: Part[];
|
||||
stopAfterPermissionCancel: boolean;
|
||||
loopDetected?: boolean;
|
||||
repeatedDuplicateProviderToolCall?: boolean;
|
||||
repeatedToolFailureBatch?: {
|
||||
complete: boolean;
|
||||
observations: Array<{
|
||||
@@ -25627,8 +25680,15 @@ describe('Session', () => {
|
||||
return mockAllowedTool(name, execute);
|
||||
});
|
||||
const historyIds = new Set(['duplicate_read']);
|
||||
vi.mocked(mockChat.getHistoryFunctionResponseIds).mockReturnValue(
|
||||
historyIds,
|
||||
vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
'duplicate_read',
|
||||
core.getToolCallFingerprint(core.ToolNames.READ_FILE, {
|
||||
file_path: 'duplicate.ts',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
const [duplicatePart] = core.normalizeModelToolCallIds(
|
||||
[
|
||||
@@ -28066,8 +28126,13 @@ describe('Session', () => {
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: true,
|
||||
});
|
||||
vi.mocked(mockChat.getHistoryFunctionResponseIds).mockReturnValue(
|
||||
new Set(['shell_1']),
|
||||
vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
'shell_1',
|
||||
core.getToolCallFingerprint('read_file', { file_path: 'b.ts' }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
const [duplicatePart] = core.normalizeModelToolCallIds(
|
||||
[
|
||||
@@ -28142,6 +28207,64 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('executes an id-colliding functionCall whose args differ from the handled call', async () => {
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
llmContent: 'fresh result',
|
||||
returnDisplay: 'fresh result',
|
||||
});
|
||||
const build = vi.fn().mockReturnValue({
|
||||
params: { file_path: 'c.ts' },
|
||||
execute,
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
getDescription: vi.fn().mockReturnValue('Read file'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
mockToolRegistry.getTool.mockReturnValue({
|
||||
name: 'read_file',
|
||||
kind: core.Kind.Read,
|
||||
displayName: 'Read File',
|
||||
description: 'Read file',
|
||||
build,
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: true,
|
||||
});
|
||||
vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
'shell_1',
|
||||
core.getToolCallFingerprint('read_file', { file_path: 'b.ts' }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
const [collidingPart] = core.normalizeModelToolCallIds(
|
||||
[
|
||||
{
|
||||
functionCall: {
|
||||
id: 'shell_1',
|
||||
name: 'read_file',
|
||||
args: { file_path: 'c.ts' },
|
||||
},
|
||||
},
|
||||
],
|
||||
new Set(['shell_1']),
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
const result = await (
|
||||
session as unknown as ToolCallInternals
|
||||
).runToolCalls(new AbortController().signal, 'prompt-id-collision', [
|
||||
collidingPart.functionCall!,
|
||||
]);
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(result.loopDetected).toBeUndefined();
|
||||
expect(result.parts).toHaveLength(1);
|
||||
expect(result.parts[0].functionResponse?.id).toBe('shell_1__qwen_dup_2');
|
||||
expect(result.parts[0].functionResponse?.response).toEqual({
|
||||
output: 'fresh result',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops repeated duplicate provider functionCall ids after the first synthetic response', async () => {
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
llmContent: 'should not run',
|
||||
@@ -28163,8 +28286,13 @@ describe('Session', () => {
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: true,
|
||||
});
|
||||
vi.mocked(mockChat.getHistoryFunctionResponseIds).mockReturnValue(
|
||||
new Set(['shell_1']),
|
||||
vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
'shell_1',
|
||||
core.getToolCallFingerprint('read_file', { file_path: 'b.ts' }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
const [duplicatePart] = core.normalizeModelToolCallIds(
|
||||
[
|
||||
@@ -28186,12 +28314,26 @@ describe('Session', () => {
|
||||
).runToolCalls(new AbortController().signal, 'prompt-history-dup', [
|
||||
duplicateCall,
|
||||
]);
|
||||
const toolLoopState: DaemonToolLoopState = {
|
||||
totalToolCalls: 0,
|
||||
invalidToolParamErrors: new Map<string, number>(),
|
||||
toolCallKeyCounts: new Map<string, number>(),
|
||||
maxToolCallKeyRepeat: 0,
|
||||
loopDetected: false,
|
||||
repeatedToolFailureMode: 'off',
|
||||
repeatedToolFailureState: createRepeatedToolFailureGuardState(),
|
||||
};
|
||||
const secondResult = await (
|
||||
session as unknown as ToolCallInternals
|
||||
).runToolCalls(new AbortController().signal, 'prompt-history-dup', [
|
||||
duplicateCall,
|
||||
{ id: 'fresh_shell', name: 'read_file', args: { file_path: 'c.ts' } },
|
||||
]);
|
||||
).runToolCalls(
|
||||
new AbortController().signal,
|
||||
'prompt-history-dup',
|
||||
[
|
||||
duplicateCall,
|
||||
{ id: 'fresh_shell', name: 'read_file', args: { file_path: 'c.ts' } },
|
||||
],
|
||||
toolLoopState,
|
||||
);
|
||||
|
||||
expect(mockToolRegistry.getTool).not.toHaveBeenCalled();
|
||||
expect(build).not.toHaveBeenCalled();
|
||||
@@ -28206,7 +28348,11 @@ describe('Session', () => {
|
||||
),
|
||||
});
|
||||
expect(secondResult.parts).toHaveLength(0);
|
||||
expect(secondResult.repeatedDuplicateProviderToolCall).toBe(true);
|
||||
expect(secondResult.loopDetected).toBe(true);
|
||||
expect(toolLoopState.loopDetected).toBe(true);
|
||||
expect(toolLoopState.loopType).toBe(
|
||||
core.LoopType.GLOBAL_TOOL_CALL_DUPLICATE,
|
||||
);
|
||||
expect(mockChatRecordingService.recordToolResult).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
@@ -28214,8 +28360,21 @@ describe('Session', () => {
|
||||
});
|
||||
|
||||
it('suppresses duplicate TodoWrite calls without emitting plan updates', async () => {
|
||||
vi.mocked(mockChat.getHistoryFunctionResponseIds).mockReturnValue(
|
||||
new Set(['todo_1']),
|
||||
vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
'todo_1',
|
||||
core.getToolCallFingerprint(core.ToolNames.TODO_WRITE, {
|
||||
todos: [
|
||||
{
|
||||
id: 'task-1',
|
||||
content: 'Do not replay this',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
const [duplicatePart] = core.normalizeModelToolCallIds(
|
||||
[
|
||||
@@ -28299,9 +28458,14 @@ describe('Session', () => {
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: true,
|
||||
});
|
||||
const historyIds = new Set(['dup_mid']);
|
||||
vi.mocked(mockChat.getHistoryFunctionResponseIds).mockReturnValue(
|
||||
historyIds,
|
||||
const seededFingerprints = new Map([
|
||||
[
|
||||
'dup_mid',
|
||||
core.getToolCallFingerprint('read_file', { file_path: 'b.ts' }),
|
||||
],
|
||||
]);
|
||||
vi.mocked(mockChat.getHistoryToolCallFingerprints).mockReturnValue(
|
||||
seededFingerprints,
|
||||
);
|
||||
const [duplicatePart] = core.normalizeModelToolCallIds(
|
||||
[
|
||||
@@ -28338,7 +28502,19 @@ describe('Session', () => {
|
||||
'Duplicate provider tool call id "dup_mid"',
|
||||
),
|
||||
});
|
||||
expect(historyIds).toEqual(new Set(['dup_mid']));
|
||||
// The accessor-owned map must stay untouched: runToolCalls records
|
||||
// admitted calls only into its own defensive copy. Without this pin,
|
||||
// dropping the copy would silently leak admitted-call entries into
|
||||
// the accessor's map (a real replay misclassification once the
|
||||
// accessor is ever memoized) while every test stays green.
|
||||
expect(seededFingerprints).toEqual(
|
||||
new Map([
|
||||
[
|
||||
'dup_mid',
|
||||
core.getToolCallFingerprint('read_file', { file_path: 'b.ts' }),
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not dedupe function calls with empty ids in one batch', async () => {
|
||||
|
||||
@@ -170,7 +170,10 @@ import {
|
||||
promptIdContext,
|
||||
todoWorkChainContext,
|
||||
dedupeToolCallsById,
|
||||
getFunctionCallFingerprint,
|
||||
getProviderToolCallId,
|
||||
isReplayOfHandledToolCall,
|
||||
recordHandledToolCall,
|
||||
parsePositiveIntegerEnv,
|
||||
DEFAULT_TOKEN_LIMIT,
|
||||
hasImageParts,
|
||||
@@ -479,7 +482,6 @@ function getAbortAwareEndTurnStopReason(
|
||||
type RunToolResult = {
|
||||
parts: Part[];
|
||||
stopAfterPermissionCancel: boolean;
|
||||
repeatedDuplicateProviderToolCall?: boolean;
|
||||
loopDetected?: boolean;
|
||||
repeatedToolFailureBatch?: RepeatedToolFailureBatch;
|
||||
memoryWriteCandidates?: MemoryWriteCandidate[];
|
||||
@@ -3579,6 +3581,13 @@ export class Session implements SessionContext {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deliberate twin divergence: the TUI twin (isUserTextContent in
|
||||
// packages/cli/src/ui/utils/historyMapping.ts) excludes microcompaction
|
||||
// media-clear placeholders ('[Old inline media cleared: ...]') from the
|
||||
// rewind prompt count because a cleared media-only entry never produced
|
||||
// a TUI user turn. Here the placeholders MUST stay counted: ACP rewind
|
||||
// maps against per-prompt file-history snapshots, which ARE created for
|
||||
// media-only prompts. Do not mirror that exclusion into this twin.
|
||||
return content.parts.some((part) => 'text' in part && part.text);
|
||||
}
|
||||
|
||||
@@ -6603,13 +6612,6 @@ export class Session implements SessionContext {
|
||||
debugLogger.debug('Stopping ACP turn after daemon loop detection.');
|
||||
return { message: null, hadMidTurnUserInput: false };
|
||||
}
|
||||
if (toolRun.repeatedDuplicateProviderToolCall) {
|
||||
this.todoStopGuard.suspend();
|
||||
debugLogger.debug(
|
||||
'Stopping ACP turn after dropping repeated duplicate provider tool-call response.',
|
||||
);
|
||||
return { message: null, hadMidTurnUserInput: false };
|
||||
}
|
||||
const drained = await this.#drainMidTurnInput(abortSignal, {
|
||||
watchQueuedPrompt: toolLoopState.repeatedToolFailureMode !== 'off',
|
||||
onFullTurnModel,
|
||||
@@ -8948,27 +8950,50 @@ export class Session implements SessionContext {
|
||||
};
|
||||
type Batch = ExecutableBatch | DuplicateBatch;
|
||||
const batches: Batch[] = [];
|
||||
const handledProviderToolCallIds = new Set(
|
||||
this.#getCurrentChat().getHistoryFunctionResponseIds(),
|
||||
// The accessor returns a fresh map per call; copy anyway so a future
|
||||
// cached accessor cannot turn per-batch recording into shared-state
|
||||
// mutation.
|
||||
const handledToolCallFingerprints = new Map(
|
||||
this.#getCurrentChat().getHistoryToolCallFingerprints(),
|
||||
);
|
||||
const isReplayOfHandledCall = (fc: FunctionCall): boolean => {
|
||||
const providerCallId = getProviderToolCallId(fc) ?? fc.id;
|
||||
return providerCallId
|
||||
? isReplayOfHandledToolCall(
|
||||
handledToolCallFingerprints,
|
||||
providerCallId,
|
||||
getFunctionCallFingerprint(fc),
|
||||
)
|
||||
: false;
|
||||
};
|
||||
const repeatedDuplicateCall = findRepeatedDuplicateProviderToolCall(
|
||||
dedupedFunctionCalls,
|
||||
(fc) => getProviderToolCallId(fc) ?? fc.id,
|
||||
handledProviderToolCallIds,
|
||||
isReplayOfHandledCall,
|
||||
this.duplicateProviderToolCallResponseIds,
|
||||
);
|
||||
if (repeatedDuplicateCall) {
|
||||
const providerCallId =
|
||||
getProviderToolCallId(repeatedDuplicateCall) ??
|
||||
repeatedDuplicateCall.id;
|
||||
debugLogger.debug(
|
||||
`[Session.runToolCalls] Dropping batch after repeated duplicate provider tool-call id: ` +
|
||||
`${providerCallId} (tool: ${repeatedDuplicateCall.name ?? 'unknown_tool'})`,
|
||||
);
|
||||
const message =
|
||||
`Stopping ACP turn after repeated duplicate provider tool-call id: ` +
|
||||
`${providerCallId} (tool: ${repeatedDuplicateCall.name ?? 'unknown_tool'}).`;
|
||||
if (toolLoopState) {
|
||||
recordDaemonLoopDetected(
|
||||
this.config,
|
||||
promptId,
|
||||
LoopType.GLOBAL_TOOL_CALL_DUPLICATE,
|
||||
message,
|
||||
toolLoopState,
|
||||
);
|
||||
} else {
|
||||
debugLogger.warn(message);
|
||||
}
|
||||
return await finalizeRunToolResult({
|
||||
parts: [],
|
||||
stopAfterPermissionCancel: false,
|
||||
repeatedDuplicateProviderToolCall: true,
|
||||
loopDetected: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9058,7 +9083,7 @@ export class Session implements SessionContext {
|
||||
for (const fc of dedupedFunctionCalls) {
|
||||
const providerCallId = getProviderToolCallId(fc) ?? fc.id;
|
||||
if (providerCallId) {
|
||||
if (handledProviderToolCallIds.has(providerCallId)) {
|
||||
if (isReplayOfHandledCall(fc)) {
|
||||
const callId = executionCallIds.get(fc)!;
|
||||
pushDuplicateBatch(fc, {
|
||||
callId,
|
||||
@@ -9070,7 +9095,11 @@ export class Session implements SessionContext {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
handledProviderToolCallIds.add(providerCallId);
|
||||
recordHandledToolCall(
|
||||
handledToolCallFingerprints,
|
||||
providerCallId,
|
||||
getFunctionCallFingerprint(fc),
|
||||
);
|
||||
}
|
||||
|
||||
// Canonical names match core's isToolCallConcurrencySafe predicate,
|
||||
@@ -9358,7 +9387,6 @@ export class Session implements SessionContext {
|
||||
return await finalizeRunToolResult({
|
||||
parts,
|
||||
stopAfterPermissionCancel: true,
|
||||
repeatedDuplicateProviderToolCall: false,
|
||||
memoryWriteCandidates,
|
||||
});
|
||||
}
|
||||
@@ -9396,7 +9424,6 @@ export class Session implements SessionContext {
|
||||
return await finalizeRunToolResult({
|
||||
parts,
|
||||
stopAfterPermissionCancel: true,
|
||||
repeatedDuplicateProviderToolCall: false,
|
||||
memoryWriteCandidates,
|
||||
});
|
||||
}
|
||||
@@ -9406,7 +9433,6 @@ export class Session implements SessionContext {
|
||||
return await finalizeRunToolResult({
|
||||
parts,
|
||||
stopAfterPermissionCancel: false,
|
||||
repeatedDuplicateProviderToolCall: false,
|
||||
memoryWriteCandidates,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AgentViewAttachLeaseManager,
|
||||
MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS,
|
||||
} from './attach-lease.js';
|
||||
|
||||
describe('AgentViewAttachLeaseManager', () => {
|
||||
it('acquires a lease for an unattached session', () => {
|
||||
const clock = fakeClock('2026-07-17T00:00:00.000Z');
|
||||
const manager = new AgentViewAttachLeaseManager({
|
||||
now: clock.now,
|
||||
createLeaseId: () => 'lease-1',
|
||||
defaultTtlMs: 1000,
|
||||
});
|
||||
|
||||
expect(manager.acquire('session-1', { clientId: 'terminal-1' })).toEqual({
|
||||
ok: true,
|
||||
lease: {
|
||||
sessionId: 'session-1',
|
||||
leaseId: 'lease-1',
|
||||
clientId: 'terminal-1',
|
||||
acquiredAt: '2026-07-17T00:00:00.000Z',
|
||||
lastHeartbeatAt: '2026-07-17T00:00:00.000Z',
|
||||
expiresAt: '2026-07-17T00:00:01.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a random lease id when no id factory is provided', () => {
|
||||
const manager = new AgentViewAttachLeaseManager();
|
||||
const result = manager.acquire('session-1');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
lease: {
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
});
|
||||
if (result.ok) {
|
||||
expect(result.lease.leaseId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('isolates leases across sessions', () => {
|
||||
const clock = fakeClock('2026-07-17T00:00:00.000Z');
|
||||
let idCounter = 0;
|
||||
const manager = new AgentViewAttachLeaseManager({
|
||||
now: clock.now,
|
||||
createLeaseId: () => `lease-${++idCounter}`,
|
||||
});
|
||||
|
||||
const first = manager.acquire('session-a', { clientId: 'terminal-a' });
|
||||
const second = manager.acquire('session-b', { clientId: 'terminal-b' });
|
||||
|
||||
expect(first.ok && first.lease.leaseId).toBe('lease-1');
|
||||
expect(second.ok && second.lease.leaseId).toBe('lease-2');
|
||||
|
||||
// Session B's lease must not disturb session A's.
|
||||
expect(manager.get('session-a')?.leaseId).toBe('lease-1');
|
||||
expect(manager.heartbeat('session-a', 'lease-1')?.leaseId).toBe('lease-1');
|
||||
|
||||
// Releasing session A must not affect session B.
|
||||
expect(manager.release('session-a', 'lease-1')).toBe(true);
|
||||
expect(manager.get('session-a')).toBeUndefined();
|
||||
expect(manager.get('session-b')?.leaseId).toBe('lease-2');
|
||||
});
|
||||
|
||||
it('rejects a second acquire while a lease is active', () => {
|
||||
const clock = fakeClock('2026-07-17T00:00:00.000Z');
|
||||
const manager = new AgentViewAttachLeaseManager({
|
||||
now: clock.now,
|
||||
createLeaseId: () => 'lease-1',
|
||||
});
|
||||
const first = manager.acquire('session-1');
|
||||
|
||||
expect(
|
||||
manager.acquire('session-1', {
|
||||
leaseId: 'lease-2',
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: 'already_attached',
|
||||
lease: {
|
||||
sessionId: first.lease.sessionId,
|
||||
acquiredAt: first.lease.acquiredAt,
|
||||
lastHeartbeatAt: first.lease.lastHeartbeatAt,
|
||||
expiresAt: first.lease.expiresAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not disclose the active lease id on contested acquire', () => {
|
||||
const manager = new AgentViewAttachLeaseManager({
|
||||
createLeaseId: () => 'lease-1',
|
||||
});
|
||||
manager.acquire('session-1');
|
||||
|
||||
const result = manager.acquire('session-1', { leaseId: 'lease-2' });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect('leaseId' in result.lease).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates a lease id when the provided id is empty', () => {
|
||||
const manager = new AgentViewAttachLeaseManager({
|
||||
createLeaseId: () => 'generated-lease',
|
||||
});
|
||||
|
||||
expect(manager.acquire('session-1', { leaseId: '' })).toMatchObject({
|
||||
ok: true,
|
||||
lease: {
|
||||
leaseId: 'generated-lease',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('releases only the matching lease', () => {
|
||||
const manager = new AgentViewAttachLeaseManager({
|
||||
createLeaseId: () => 'lease-1',
|
||||
});
|
||||
manager.acquire('session-1');
|
||||
|
||||
expect(manager.release('session-1', 'other-lease')).toBe(false);
|
||||
expect(manager.get('session-1')).toMatchObject({ leaseId: 'lease-1' });
|
||||
expect(manager.release('session-1', 'lease-1')).toBe(true);
|
||||
expect(manager.get('session-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects non-positive, oversized, and non-finite ttls', () => {
|
||||
const manager = new AgentViewAttachLeaseManager();
|
||||
|
||||
expect(() => manager.acquire('session-1', { ttlMs: 0 })).toThrow(
|
||||
'Attach lease ttlMs must be positive.',
|
||||
);
|
||||
expect(() => manager.acquire('session-1', { ttlMs: Number.NaN })).toThrow(
|
||||
RangeError,
|
||||
);
|
||||
expect(() =>
|
||||
manager.acquire('session-1', {
|
||||
ttlMs: MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS + 1,
|
||||
}),
|
||||
).toThrow('Attach lease ttlMs must not exceed');
|
||||
expect(() => manager.acquire('')).toThrow(
|
||||
'Agent View session id is required.',
|
||||
);
|
||||
expect(manager.get('session-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('honors per-call ttls and expires lazily through get and release', () => {
|
||||
const clock = fakeClock('2026-07-17T00:00:00.000Z');
|
||||
const manager = new AgentViewAttachLeaseManager({
|
||||
now: clock.now,
|
||||
createLeaseId: () => 'lease-1',
|
||||
defaultTtlMs: 1000,
|
||||
});
|
||||
manager.acquire('session-1', { ttlMs: 2000 });
|
||||
|
||||
clock.advance(1500);
|
||||
expect(manager.get('session-1')).toMatchObject({ leaseId: 'lease-1' });
|
||||
expect(
|
||||
manager.heartbeat('session-1', 'lease-1', { ttlMs: 3000 }),
|
||||
).toMatchObject({ expiresAt: '2026-07-17T00:00:04.500Z' });
|
||||
|
||||
clock.advance(3000);
|
||||
expect(manager.get('session-1')).toBeUndefined();
|
||||
expect(manager.release('session-1', 'lease-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('expires stale leases and allows reacquire', () => {
|
||||
const clock = fakeClock('2026-07-17T00:00:00.000Z');
|
||||
const leaseIds = ['lease-1', 'lease-2'];
|
||||
const manager = new AgentViewAttachLeaseManager({
|
||||
now: clock.now,
|
||||
createLeaseId: () => leaseIds.shift() ?? 'missing-lease',
|
||||
defaultTtlMs: 1000,
|
||||
});
|
||||
const first = manager.acquire('session-1');
|
||||
|
||||
clock.advance(1000);
|
||||
|
||||
expect(manager.expire()).toEqual([first.lease]);
|
||||
expect(manager.acquire('session-1')).toMatchObject({
|
||||
ok: true,
|
||||
lease: {
|
||||
leaseId: 'lease-2',
|
||||
acquiredAt: '2026-07-17T00:00:01.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('expires corrupted leases with invalid timestamps', () => {
|
||||
const manager = new AgentViewAttachLeaseManager({
|
||||
createLeaseId: () => 'lease-1',
|
||||
});
|
||||
manager.acquire('session-1');
|
||||
(
|
||||
manager as unknown as {
|
||||
leases: Map<string, { expiresAt: string }>;
|
||||
}
|
||||
).leases.get('session-1')!.expiresAt = 'not-a-date';
|
||||
|
||||
expect(manager.expire()).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
leaseId: 'lease-1',
|
||||
expiresAt: 'not-a-date',
|
||||
}),
|
||||
]);
|
||||
expect(manager.get('session-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('heartbeat extends the matching lease', () => {
|
||||
const clock = fakeClock('2026-07-17T00:00:00.000Z');
|
||||
const manager = new AgentViewAttachLeaseManager({
|
||||
now: clock.now,
|
||||
createLeaseId: () => 'lease-1',
|
||||
defaultTtlMs: 1000,
|
||||
});
|
||||
manager.acquire('session-1');
|
||||
clock.advance(500);
|
||||
|
||||
expect(manager.heartbeat('session-1', 'wrong-lease')).toBeUndefined();
|
||||
expect(manager.heartbeat('session-1', 'lease-1')).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
leaseId: 'lease-1',
|
||||
acquiredAt: '2026-07-17T00:00:00.000Z',
|
||||
lastHeartbeatAt: '2026-07-17T00:00:00.500Z',
|
||||
expiresAt: '2026-07-17T00:00:01.500Z',
|
||||
});
|
||||
|
||||
clock.advance(999);
|
||||
expect(manager.get('session-1')).toMatchObject({ leaseId: 'lease-1' });
|
||||
});
|
||||
});
|
||||
|
||||
function fakeClock(start: string): {
|
||||
now: () => Date;
|
||||
advance: (ms: number) => void;
|
||||
} {
|
||||
let nowMs = Date.parse(start);
|
||||
return {
|
||||
now: () => new Date(nowMs),
|
||||
advance: (ms: number) => {
|
||||
nowMs += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const DEFAULT_AGENT_VIEW_ATTACH_LEASE_TTL_MS = 30_000;
|
||||
export const MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS = 3_600_000;
|
||||
|
||||
export interface AgentViewAttachLease {
|
||||
sessionId: string;
|
||||
leaseId: string;
|
||||
clientId?: string;
|
||||
acquiredAt: string;
|
||||
lastHeartbeatAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export type AgentViewAttachLeaseConflict = Omit<
|
||||
AgentViewAttachLease,
|
||||
'leaseId'
|
||||
>;
|
||||
|
||||
export type AgentViewAttachLeaseAcquireResult =
|
||||
| { ok: true; lease: AgentViewAttachLease }
|
||||
| {
|
||||
ok: false;
|
||||
reason: 'already_attached';
|
||||
lease: AgentViewAttachLeaseConflict;
|
||||
};
|
||||
|
||||
export interface AgentViewAttachLeaseAcquireOptions {
|
||||
clientId?: string;
|
||||
leaseId?: string;
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
export interface AgentViewAttachLeaseHeartbeatOptions {
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
export interface AgentViewAttachLeaseManagerOptions {
|
||||
defaultTtlMs?: number;
|
||||
now?: () => Date;
|
||||
createLeaseId?: () => string;
|
||||
}
|
||||
|
||||
export class AgentViewAttachLeaseManager {
|
||||
private readonly leases = new Map<string, AgentViewAttachLease>();
|
||||
private readonly defaultTtlMs: number;
|
||||
private readonly now: () => Date;
|
||||
private readonly createLeaseId: () => string;
|
||||
|
||||
constructor(options: AgentViewAttachLeaseManagerOptions = {}) {
|
||||
this.defaultTtlMs =
|
||||
options.defaultTtlMs ?? DEFAULT_AGENT_VIEW_ATTACH_LEASE_TTL_MS;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.createLeaseId = options.createLeaseId ?? randomUUID;
|
||||
}
|
||||
|
||||
acquire(
|
||||
sessionId: string,
|
||||
options: AgentViewAttachLeaseAcquireOptions = {},
|
||||
): AgentViewAttachLeaseAcquireResult {
|
||||
this.requireSessionId(sessionId);
|
||||
this.expire();
|
||||
|
||||
const existing = this.leases.get(sessionId);
|
||||
if (existing) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'already_attached',
|
||||
lease: redactLeaseId(existing),
|
||||
};
|
||||
}
|
||||
|
||||
const acquiredAt = this.now();
|
||||
const lease: AgentViewAttachLease = {
|
||||
sessionId,
|
||||
leaseId: options.leaseId || this.createLeaseId(),
|
||||
...(options.clientId ? { clientId: options.clientId } : {}),
|
||||
acquiredAt: acquiredAt.toISOString(),
|
||||
lastHeartbeatAt: acquiredAt.toISOString(),
|
||||
expiresAt: this.expiresAt(acquiredAt, options.ttlMs).toISOString(),
|
||||
};
|
||||
this.leases.set(sessionId, lease);
|
||||
return { ok: true, lease };
|
||||
}
|
||||
|
||||
heartbeat(
|
||||
sessionId: string,
|
||||
leaseId: string,
|
||||
options: AgentViewAttachLeaseHeartbeatOptions = {},
|
||||
): AgentViewAttachLease | undefined {
|
||||
this.requireSessionId(sessionId);
|
||||
this.expire();
|
||||
|
||||
const lease = this.leases.get(sessionId);
|
||||
if (!lease || lease.leaseId !== leaseId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const now = this.now();
|
||||
const next: AgentViewAttachLease = {
|
||||
...lease,
|
||||
lastHeartbeatAt: now.toISOString(),
|
||||
expiresAt: this.expiresAt(now, options.ttlMs).toISOString(),
|
||||
};
|
||||
this.leases.set(sessionId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
release(sessionId: string, leaseId: string): boolean {
|
||||
this.requireSessionId(sessionId);
|
||||
this.expire();
|
||||
|
||||
const lease = this.leases.get(sessionId);
|
||||
if (!lease || lease.leaseId !== leaseId) {
|
||||
return false;
|
||||
}
|
||||
this.leases.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
expire(): AgentViewAttachLease[] {
|
||||
const nowMs = this.now().getTime();
|
||||
const expired: AgentViewAttachLease[] = [];
|
||||
|
||||
for (const [sessionId, lease] of this.leases) {
|
||||
const expiresAtMs = Date.parse(lease.expiresAt);
|
||||
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) {
|
||||
this.leases.delete(sessionId);
|
||||
expired.push(lease);
|
||||
}
|
||||
}
|
||||
|
||||
return expired;
|
||||
}
|
||||
|
||||
get(sessionId: string): AgentViewAttachLease | undefined {
|
||||
this.requireSessionId(sessionId);
|
||||
this.expire();
|
||||
return this.leases.get(sessionId);
|
||||
}
|
||||
|
||||
private expiresAt(now: Date, ttlMs: number | undefined): Date {
|
||||
const resolvedTtlMs = ttlMs ?? this.defaultTtlMs;
|
||||
if (!Number.isFinite(resolvedTtlMs) || resolvedTtlMs <= 0) {
|
||||
throw new RangeError('Attach lease ttlMs must be positive.');
|
||||
}
|
||||
if (resolvedTtlMs > MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS) {
|
||||
throw new RangeError(
|
||||
`Attach lease ttlMs must not exceed ${MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS}.`,
|
||||
);
|
||||
}
|
||||
return new Date(now.getTime() + resolvedTtlMs);
|
||||
}
|
||||
|
||||
private requireSessionId(sessionId: string): void {
|
||||
if (sessionId.length === 0) {
|
||||
throw new Error('Agent View session id is required.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function redactLeaseId(
|
||||
lease: AgentViewAttachLease,
|
||||
): AgentViewAttachLeaseConflict {
|
||||
return {
|
||||
sessionId: lease.sessionId,
|
||||
...(lease.clientId ? { clientId: lease.clientId } : {}),
|
||||
acquiredAt: lease.acquiredAt,
|
||||
lastHeartbeatAt: lease.lastHeartbeatAt,
|
||||
expiresAt: lease.expiresAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { detachCurrentSessionToAgentView } from './managed-detach.js';
|
||||
|
||||
describe('detachCurrentSessionToAgentView', () => {
|
||||
it('asks the supervisor to adopt the current idle session', async () => {
|
||||
const globalDir = '/tmp/qwen-agent-view-detach';
|
||||
const adopt = vi.fn(async () => ({ sessionId, adopted: true }));
|
||||
const sessionId = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const config = {
|
||||
getSessionId: () => sessionId,
|
||||
getProjectRoot: () => path.join(globalDir, 'project'),
|
||||
getTargetDir: () => path.join(globalDir, 'project', 'src'),
|
||||
getApprovalMode: () => 'default',
|
||||
getSandbox: () => undefined,
|
||||
};
|
||||
|
||||
const result = await detachCurrentSessionToAgentView(config, {
|
||||
globalDir,
|
||||
terminal: { columns: 100, rows: 40 },
|
||||
ensureSupervisor: async () => ({
|
||||
adopt,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ sessionId });
|
||||
expect(adopt).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
projectCwd: path.resolve(globalDir, 'project'),
|
||||
activeCwd: path.resolve(globalDir, 'project', 'src'),
|
||||
approvalMode: 'default',
|
||||
sandbox: undefined,
|
||||
terminal: { columns: 100, rows: 40 },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not stringify a missing approval mode', async () => {
|
||||
const adopt = vi.fn(async () => ({ sessionId, adopted: true }));
|
||||
const sessionId = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const config = {
|
||||
getSessionId: () => sessionId,
|
||||
getProjectRoot: () => '/project',
|
||||
getTargetDir: () => '/project',
|
||||
getApprovalMode: () => undefined,
|
||||
getSandbox: () => undefined,
|
||||
};
|
||||
|
||||
await detachCurrentSessionToAgentView(config, {
|
||||
ensureSupervisor: async () => ({ adopt }),
|
||||
});
|
||||
|
||||
expect(adopt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
approvalMode: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes a string sandbox mode through without JSON quoting', async () => {
|
||||
const adopt = vi.fn(async () => ({ sessionId, adopted: true }));
|
||||
const sessionId = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const config = {
|
||||
getSessionId: () => sessionId,
|
||||
getProjectRoot: () => '/project',
|
||||
getTargetDir: () => '/project',
|
||||
getApprovalMode: () => undefined,
|
||||
getSandbox: () => 'linux',
|
||||
};
|
||||
|
||||
await detachCurrentSessionToAgentView(config, {
|
||||
ensureSupervisor: async () => ({ adopt }),
|
||||
});
|
||||
|
||||
expect(adopt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandbox: 'linux',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not stringify a null sandbox mode', async () => {
|
||||
const adopt = vi.fn(async () => ({ sessionId, adopted: true }));
|
||||
const sessionId = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const config = {
|
||||
getSessionId: () => sessionId,
|
||||
getProjectRoot: () => '/project',
|
||||
getTargetDir: () => '/project',
|
||||
getApprovalMode: () => undefined,
|
||||
getSandbox: () => null,
|
||||
};
|
||||
|
||||
await detachCurrentSessionToAgentView(config, {
|
||||
ensureSupervisor: async () => ({ adopt }),
|
||||
});
|
||||
|
||||
expect(adopt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandbox: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('JSON-stringifies an object sandbox config', async () => {
|
||||
const adopt = vi.fn(async () => ({ sessionId, adopted: true }));
|
||||
const sessionId = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const config = {
|
||||
getSessionId: () => sessionId,
|
||||
getProjectRoot: () => '/project',
|
||||
getTargetDir: () => '/project',
|
||||
getApprovalMode: () => undefined,
|
||||
getSandbox: () => ({ command: 'docker', image: 'qwen-sandbox' }),
|
||||
};
|
||||
|
||||
await detachCurrentSessionToAgentView(config, {
|
||||
ensureSupervisor: async () => ({ adopt }),
|
||||
});
|
||||
|
||||
expect(adopt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandbox: '{"command":"docker","image":"qwen-sandbox"}',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import { ensureAgentViewSupervisor } from './supervisor-runner.js';
|
||||
import type { AgentViewSupervisorClientHandle } from './supervisor-runner.js';
|
||||
|
||||
interface DetachableConfig {
|
||||
getSessionId(): string;
|
||||
getProjectRoot(): string;
|
||||
getTargetDir(): string;
|
||||
getApprovalMode(): unknown;
|
||||
getSandbox(): unknown;
|
||||
}
|
||||
|
||||
interface DetachTerminalSize {
|
||||
columns: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
interface DetachOptions {
|
||||
globalDir?: string;
|
||||
terminal?: Partial<DetachTerminalSize>;
|
||||
ensureSupervisor?: (options: {
|
||||
globalDir?: string;
|
||||
}) => Promise<Pick<AgentViewSupervisorClientHandle, 'adopt'>>;
|
||||
}
|
||||
|
||||
export async function detachCurrentSessionToAgentView(
|
||||
config: DetachableConfig,
|
||||
options: DetachOptions = {},
|
||||
): Promise<{ sessionId: string }> {
|
||||
const sessionId = config.getSessionId();
|
||||
const projectCwd = path.resolve(config.getProjectRoot());
|
||||
const activeCwd = path.resolve(config.getTargetDir());
|
||||
const supervisor = await (
|
||||
options.ensureSupervisor ?? ensureAgentViewSupervisor
|
||||
)(storeOptions(options));
|
||||
|
||||
await supervisor.adopt({
|
||||
sessionId,
|
||||
projectCwd,
|
||||
activeCwd,
|
||||
approvalMode: stringifyOptional(config.getApprovalMode()),
|
||||
sandbox: stringifySandbox(config.getSandbox()),
|
||||
terminal: {
|
||||
columns: options.terminal?.columns ?? process.stdout.columns ?? 80,
|
||||
rows: options.terminal?.rows ?? process.stdout.rows ?? 24,
|
||||
},
|
||||
});
|
||||
return { sessionId };
|
||||
}
|
||||
|
||||
function stringifyOptional(value: unknown): string | undefined {
|
||||
return value === undefined || value === null ? undefined : String(value);
|
||||
}
|
||||
|
||||
function stringifySandbox(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'boolean') return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function storeOptions(options: DetachOptions): { globalDir?: string } {
|
||||
return options.globalDir ? { globalDir: options.globalDir } : {};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const PTY_HOST_AUTH_TOKEN_ENV = 'QWEN_AGENT_VIEW_PTY_HOST_TOKEN';
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AgentViewLaunchFile } from './protocol.js';
|
||||
import { PTY_HOST_AUTH_TOKEN_ENV } from './pty-host-env.js';
|
||||
import {
|
||||
AgentViewLaunchConfigError,
|
||||
AgentViewPtyUnavailableError,
|
||||
BoundedOutputRing,
|
||||
checkAgentViewPtyAvailability,
|
||||
launchAgentViewPtyHost,
|
||||
validateAgentViewLaunchConfig,
|
||||
type AgentViewPtyImplementation,
|
||||
type AgentViewPtyProcess,
|
||||
type AgentViewPtySpawnOptions,
|
||||
} from './pty-host.js';
|
||||
|
||||
describe('BoundedOutputRing', () => {
|
||||
it('retains only the newest bytes', () => {
|
||||
const ring = new BoundedOutputRing(5);
|
||||
|
||||
ring.append('abc');
|
||||
ring.append('def');
|
||||
|
||||
expect(ring.toString()).toBe('bcdef');
|
||||
expect(ring.totalBytes).toBe(6);
|
||||
expect(ring.retainedBytes).toBe(5);
|
||||
expect(ring.droppedBytes).toBe(1);
|
||||
});
|
||||
|
||||
it('truncates oversized chunks to the tail', () => {
|
||||
const ring = new BoundedOutputRing(4);
|
||||
|
||||
ring.append('123456');
|
||||
|
||||
expect(ring.toString()).toBe('3456');
|
||||
expect(ring.totalBytes).toBe(6);
|
||||
expect(ring.retainedBytes).toBe(4);
|
||||
});
|
||||
|
||||
it('does not retain partial UTF-8 characters when trimming', () => {
|
||||
const ring = new BoundedOutputRing(4);
|
||||
|
||||
ring.append('a你b');
|
||||
|
||||
expect(ring.toString()).toBe('你b');
|
||||
expect(ring.toString()).not.toContain('\uFFFD');
|
||||
expect(ring.retainedBytes).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('does not retain partial UTF-8 characters from oversized chunks', () => {
|
||||
const ring = new BoundedOutputRing(5);
|
||||
|
||||
ring.append('🙂你');
|
||||
|
||||
expect(ring.toString()).toBe('你');
|
||||
expect(ring.toString()).not.toContain('\uFFFD');
|
||||
expect(ring.retainedBytes).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('does not retain partial UTF-8 characters across chunks', () => {
|
||||
const ring = new BoundedOutputRing(4);
|
||||
|
||||
ring.append(Buffer.from([0x41, 0xe2, 0x82]));
|
||||
ring.append(Buffer.from([0xac, 0x42, 0x43]));
|
||||
|
||||
expect(ring.toString()).toBe('BC');
|
||||
expect(ring.toString()).not.toContain('\uFFFD');
|
||||
expect(ring.retainedBytes).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('does not retain partial UTF-8 characters when sub-capacity chunks overflow', () => {
|
||||
const ring = new BoundedOutputRing(6);
|
||||
|
||||
ring.append('ab你');
|
||||
ring.append('你x');
|
||||
|
||||
expect(ring.toString()).toBe('你x');
|
||||
expect(ring.toString()).not.toContain('\uFFFD');
|
||||
expect(ring.retainedBytes).toBeLessThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('keeps leading continuation bytes when the window never overflowed', () => {
|
||||
const ring = new BoundedOutputRing(23);
|
||||
|
||||
ring.append(Buffer.from('aa35d7e816b5', 'hex'));
|
||||
|
||||
expect(ring.toBuffer().toString('hex')).toBe('aa35d7e816b5');
|
||||
expect(ring.droppedBytes).toBe(0);
|
||||
});
|
||||
|
||||
it('copies the retained tail of oversized chunks off the source buffer', () => {
|
||||
const ring = new BoundedOutputRing(4);
|
||||
const source = Buffer.alloc(1024, 0x61);
|
||||
|
||||
ring.append(source);
|
||||
// A retained subarray view would observe this mutation.
|
||||
source.fill(0x62);
|
||||
|
||||
expect(ring.toString()).toBe('aaaa');
|
||||
});
|
||||
|
||||
it('coalesces small chunks while preserving the byte cap', () => {
|
||||
const ring = new BoundedOutputRing(1024 * 1024);
|
||||
|
||||
for (let index = 0; index < 10_000; index++) {
|
||||
ring.append('x');
|
||||
}
|
||||
|
||||
expect(ring.retainedBytes).toBe(10_000);
|
||||
expect(ring.toString()).toBe('x'.repeat(10_000));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PTY availability', () => {
|
||||
it('reports injected PTY availability', async () => {
|
||||
await expect(
|
||||
checkAgentViewPtyAvailability(async () => createFakePty()),
|
||||
).resolves.toEqual({
|
||||
available: true,
|
||||
implementationName: 'injected',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports missing PTY without throwing', async () => {
|
||||
await expect(
|
||||
checkAgentViewPtyAvailability(async () => null),
|
||||
).resolves.toEqual({
|
||||
available: false,
|
||||
reason: 'missing',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAgentViewLaunchConfig', () => {
|
||||
it('accepts a minimal launch config', () => {
|
||||
const result = validateAgentViewLaunchConfig(createLaunch());
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed launch config fields', () => {
|
||||
const result = validateAgentViewLaunchConfig({
|
||||
...createLaunch(),
|
||||
argv: [],
|
||||
env: { OK: 'yes', BAD: 1 },
|
||||
terminal: { columns: 0, rows: 24 },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: expect.arrayContaining([
|
||||
'argv must not be empty',
|
||||
'env must contain only string values',
|
||||
'terminal.columns must be a positive integer',
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchAgentViewPtyHost', () => {
|
||||
it('rejects commands containing empty segments', async () => {
|
||||
const pty = createFakePty();
|
||||
|
||||
await expect(
|
||||
launchAgentViewPtyHost(createLaunch(), {
|
||||
pty,
|
||||
fakeCommand: ['fake-worker', ''],
|
||||
}),
|
||||
).rejects.toThrow('command must contain at least one non-empty string');
|
||||
|
||||
expect(pty.spawnCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('spawns the provided fake command in a PTY and captures output', async () => {
|
||||
const pty = createFakePty();
|
||||
const handle = await launchAgentViewPtyHost(createLaunch(), {
|
||||
pty,
|
||||
fakeCommand: ['fake-worker', '--script', 'ready'],
|
||||
maxOutputBytes: 8,
|
||||
});
|
||||
|
||||
expect(pty.spawnCalls).toEqual([
|
||||
{
|
||||
file: 'fake-worker',
|
||||
args: ['--script', 'ready'],
|
||||
options: expect.objectContaining({
|
||||
cwd: '/repo/work',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
handleFlowControl: false,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(handle.workerPid).toBe(1234);
|
||||
|
||||
pty.process.emitData('hello');
|
||||
pty.process.emitData(' world');
|
||||
pty.process.emitExit({ exitCode: 0 });
|
||||
|
||||
await expect(handle.exited).resolves.toEqual({ exitCode: 0 });
|
||||
expect(handle.output.toString()).toBe('lo world');
|
||||
});
|
||||
|
||||
it('uses launch argv when no fake command is provided', async () => {
|
||||
const pty = createFakePty();
|
||||
|
||||
await launchAgentViewPtyHost(createLaunch(), { pty });
|
||||
|
||||
expect(pty.spawnCalls[0]?.file).toBe('qwen');
|
||||
expect(pty.spawnCalls[0]?.args).toEqual(['--agent-view-worker']);
|
||||
});
|
||||
|
||||
it('exposes PTY write, data subscription, and resize controls', async () => {
|
||||
const pty = createFakePty();
|
||||
const handle = await launchAgentViewPtyHost(createLaunch(), { pty });
|
||||
const data: string[] = [];
|
||||
const disposable = handle.onData((chunk) => data.push(chunk));
|
||||
|
||||
handle.write(Buffer.from('hello '));
|
||||
handle.write(Buffer.from([0xe4, 0xbd]));
|
||||
handle.write(Buffer.from([0xa0, 0xe5, 0xa5, 0xbd]));
|
||||
handle.resize({ columns: 120, rows: 40 });
|
||||
handle.pause?.();
|
||||
handle.resume?.();
|
||||
pty.process.emitData('output');
|
||||
disposable?.dispose();
|
||||
pty.process.emitData('ignored');
|
||||
|
||||
expect(pty.process.input).toBe('hello 你好');
|
||||
expect(pty.process.resizes).toEqual([{ columns: 120, rows: 40 }]);
|
||||
expect(pty.process.pauses).toBe(1);
|
||||
expect(pty.process.resumes).toBe(1);
|
||||
expect(data).toEqual(['output']);
|
||||
});
|
||||
|
||||
it('passes no signal to the pty on Windows', async () => {
|
||||
const pty = createFakePty();
|
||||
const handle = await launchAgentViewPtyHost(createLaunch(), { pty });
|
||||
|
||||
const original = process.platform;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
handle.kill('SIGKILL');
|
||||
handle.shutdown?.();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: original,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
expect(pty.process.killCalls).toEqual([undefined, undefined]);
|
||||
});
|
||||
|
||||
it('resets the input decoder between attach sessions', async () => {
|
||||
const pty = createFakePty();
|
||||
const handle = await launchAgentViewPtyHost(createLaunch(), { pty });
|
||||
|
||||
// 0xE4 0xBD are the first two bytes of U+4F60 (你); without a reset
|
||||
// they would leak into the next session as a replacement character.
|
||||
handle.write(Buffer.from([0xe4, 0xbd]));
|
||||
handle.resetInput?.();
|
||||
handle.write(Buffer.from('A'));
|
||||
|
||||
expect(pty.process.input).toBe('A');
|
||||
});
|
||||
|
||||
it('passes worker env while stripping host-only secrets', async () => {
|
||||
const pty = createFakePty();
|
||||
const previousToken = process.env[PTY_HOST_AUTH_TOKEN_ENV];
|
||||
const previousTerm = process.env['TERM'];
|
||||
const previousMarker = process.env['QWEN_AGENT_VIEW_AMBIENT_MARKER'];
|
||||
const previousTmux = process.env['TMUX'];
|
||||
const previousColumns = process.env['COLUMNS'];
|
||||
process.env[PTY_HOST_AUTH_TOKEN_ENV] = 'host-secret';
|
||||
process.env['TERM'] = 'ambient-term';
|
||||
process.env['QWEN_AGENT_VIEW_AMBIENT_MARKER'] = 'ambient-value';
|
||||
process.env['TMUX'] = '/tmp/tmux-501/default,123,0';
|
||||
process.env['COLUMNS'] = '200';
|
||||
try {
|
||||
await launchAgentViewPtyHost(createLaunch(), { pty });
|
||||
} finally {
|
||||
if (previousToken === undefined) {
|
||||
delete process.env[PTY_HOST_AUTH_TOKEN_ENV];
|
||||
} else {
|
||||
process.env[PTY_HOST_AUTH_TOKEN_ENV] = previousToken;
|
||||
}
|
||||
if (previousTerm === undefined) {
|
||||
delete process.env['TERM'];
|
||||
} else {
|
||||
process.env['TERM'] = previousTerm;
|
||||
}
|
||||
if (previousMarker === undefined) {
|
||||
delete process.env['QWEN_AGENT_VIEW_AMBIENT_MARKER'];
|
||||
} else {
|
||||
process.env['QWEN_AGENT_VIEW_AMBIENT_MARKER'] = previousMarker;
|
||||
}
|
||||
if (previousTmux === undefined) {
|
||||
delete process.env['TMUX'];
|
||||
} else {
|
||||
process.env['TMUX'] = previousTmux;
|
||||
}
|
||||
if (previousColumns === undefined) {
|
||||
delete process.env['COLUMNS'];
|
||||
} else {
|
||||
process.env['COLUMNS'] = previousColumns;
|
||||
}
|
||||
}
|
||||
|
||||
expect(pty.spawnCalls[0]?.options.env).toEqual(
|
||||
expect.objectContaining({
|
||||
QWEN_AGENT_VIEW_WORKER: '1',
|
||||
QWEN_AGENT_VIEW_AMBIENT_MARKER: 'ambient-value',
|
||||
TERM: 'xterm-256color',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
pty.spawnCalls[0]?.options.env[PTY_HOST_AUTH_TOKEN_ENV],
|
||||
).toBeUndefined();
|
||||
expect(pty.spawnCalls[0]?.options.env['TMUX']).toBeUndefined();
|
||||
expect(pty.spawnCalls[0]?.options.env['COLUMNS']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips host-only secrets even when the launch env re-adds them', async () => {
|
||||
const pty = createFakePty();
|
||||
|
||||
await launchAgentViewPtyHost(
|
||||
{
|
||||
...createLaunch(),
|
||||
env: {
|
||||
QWEN_AGENT_VIEW_WORKER: '1',
|
||||
[PTY_HOST_AUTH_TOKEN_ENV]: 'injected-token',
|
||||
TMUX: '/tmp/tmux-501/default,456,0',
|
||||
TMUX_PANE: '%1',
|
||||
STY: '12345.pts-0.host',
|
||||
WINDOW: '2',
|
||||
WINDOWID: '77594631',
|
||||
TERMCAP: 'SC|screen|VT 100/ANSI X3.64 virtual terminal',
|
||||
COLUMNS: '80',
|
||||
LINES: '60',
|
||||
},
|
||||
},
|
||||
{ pty },
|
||||
);
|
||||
|
||||
expect(pty.spawnCalls[0]?.options.env).toEqual(
|
||||
expect.objectContaining({ QWEN_AGENT_VIEW_WORKER: '1' }),
|
||||
);
|
||||
for (const key of [
|
||||
PTY_HOST_AUTH_TOKEN_ENV,
|
||||
'TMUX',
|
||||
'TMUX_PANE',
|
||||
'STY',
|
||||
'WINDOW',
|
||||
'WINDOWID',
|
||||
'TERMCAP',
|
||||
'COLUMNS',
|
||||
'LINES',
|
||||
]) {
|
||||
expect(pty.spawnCalls[0]?.options.env[key]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('strips the inherited sideband identity but honors the launch env', async () => {
|
||||
const pty = createFakePty();
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
const outerKeys = [
|
||||
'QWEN_AGENT_VIEW_WORKER',
|
||||
'QWEN_AGENT_VIEW_SESSION_ID',
|
||||
'QWEN_AGENT_VIEW_SIDEBAND',
|
||||
'QWEN_AGENT_VIEW_TOKEN',
|
||||
'QWEN_AGENT_VIEW_ACTIVE_CWD',
|
||||
];
|
||||
for (const key of outerKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
process.env[key] = `outer-${key}`;
|
||||
}
|
||||
try {
|
||||
await launchAgentViewPtyHost(
|
||||
{
|
||||
...createLaunch(),
|
||||
env: {
|
||||
QWEN_AGENT_VIEW_WORKER: '1',
|
||||
QWEN_AGENT_VIEW_TOKEN: 'inner-token',
|
||||
},
|
||||
},
|
||||
{ pty },
|
||||
);
|
||||
} finally {
|
||||
for (const key of outerKeys) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = pty.spawnCalls[0]?.options.env ?? {};
|
||||
expect(env['QWEN_AGENT_VIEW_TOKEN']).toBe('inner-token');
|
||||
expect(env['QWEN_AGENT_VIEW_WORKER']).toBe('1');
|
||||
expect(env['QWEN_AGENT_VIEW_SESSION_ID']).toBeUndefined();
|
||||
expect(env['QWEN_AGENT_VIEW_SIDEBAND']).toBeUndefined();
|
||||
expect(env['QWEN_AGENT_VIEW_ACTIVE_CWD']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets the launch env override inherited process env values', async () => {
|
||||
const pty = createFakePty();
|
||||
const key = 'QWEN_AGENT_VIEW_MERGE_TEST';
|
||||
const previous = process.env[key];
|
||||
process.env[key] = 'inherited';
|
||||
try {
|
||||
await launchAgentViewPtyHost(
|
||||
{ ...createLaunch(), env: { [key]: 'from-launch' } },
|
||||
{ pty },
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = previous;
|
||||
}
|
||||
}
|
||||
|
||||
expect(pty.spawnCalls[0]?.options.env[key]).toBe('from-launch');
|
||||
});
|
||||
|
||||
it('spawns the PTY with an explicit xterm-256color terminal name', async () => {
|
||||
const pty = createFakePty();
|
||||
|
||||
await launchAgentViewPtyHost(createLaunch(), { pty });
|
||||
|
||||
// node-pty overrides env.TERM with the spawn name, so both must agree.
|
||||
expect(pty.spawnCalls[0]?.options.name).toBe('xterm-256color');
|
||||
expect(pty.spawnCalls[0]?.options.env['TERM']).toBe('xterm-256color');
|
||||
});
|
||||
|
||||
it('strips an inherited sideband token the launch env does not replace', async () => {
|
||||
const pty = createFakePty();
|
||||
const previous = process.env['QWEN_AGENT_VIEW_TOKEN'];
|
||||
process.env['QWEN_AGENT_VIEW_TOKEN'] = 'outer-token';
|
||||
try {
|
||||
await launchAgentViewPtyHost(createLaunch(), { pty });
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env['QWEN_AGENT_VIEW_TOKEN'];
|
||||
} else {
|
||||
process.env['QWEN_AGENT_VIEW_TOKEN'] = previous;
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
pty.spawnCalls[0]?.options.env['QWEN_AGENT_VIEW_TOKEN'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'passes kill signals through to the PTY process',
|
||||
async () => {
|
||||
const pty = createFakePty();
|
||||
const handle = await launchAgentViewPtyHost(createLaunch(), { pty });
|
||||
|
||||
handle.kill('SIGKILL');
|
||||
|
||||
expect(pty.process.killCalls).toEqual(['SIGKILL']);
|
||||
},
|
||||
);
|
||||
|
||||
it('stops capturing output after dispose', async () => {
|
||||
const pty = createFakePty();
|
||||
const handle = await launchAgentViewPtyHost(createLaunch(), { pty });
|
||||
pty.process.emitData('before');
|
||||
|
||||
handle.dispose();
|
||||
pty.process.emitData('leak');
|
||||
|
||||
expect(handle.output.toString()).toBe('before');
|
||||
});
|
||||
|
||||
it('kills the PTY process when disposed', async () => {
|
||||
const pty = createFakePty();
|
||||
const handle = await launchAgentViewPtyHost(createLaunch(), { pty });
|
||||
|
||||
handle.dispose();
|
||||
|
||||
expect(pty.process.killedWith).toBe(
|
||||
process.platform === 'win32' ? undefined : 'SIGTERM',
|
||||
);
|
||||
expect(pty.process.killCalls).toEqual(
|
||||
process.platform === 'win32' ? [undefined] : ['SIGTERM'],
|
||||
);
|
||||
await expect(handle.exited).resolves.toEqual({ exitCode: 1 });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'gracefully shuts down the PTY process with SIGTERM',
|
||||
async () => {
|
||||
const pty = createFakePty();
|
||||
const handle = await launchAgentViewPtyHost(createLaunch(), { pty });
|
||||
|
||||
handle.shutdown?.();
|
||||
|
||||
expect(pty.process.killedWith).toBe('SIGTERM');
|
||||
},
|
||||
);
|
||||
|
||||
it('loads PTY through the configured loader', async () => {
|
||||
const pty = createFakePty();
|
||||
const handle = await launchAgentViewPtyHost(createLaunch(), {
|
||||
loadPty: async () => pty,
|
||||
});
|
||||
|
||||
expect(handle.workerPid).toBe(1234);
|
||||
expect(pty.spawnCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws a typed error when PTY is unavailable', async () => {
|
||||
await expect(
|
||||
launchAgentViewPtyHost(createLaunch(), { pty: null }),
|
||||
).rejects.toBeInstanceOf(AgentViewPtyUnavailableError);
|
||||
});
|
||||
|
||||
it('throws a typed error for invalid launch config', async () => {
|
||||
await expect(
|
||||
launchAgentViewPtyHost({ ...createLaunch(), terminal: undefined }),
|
||||
).rejects.toBeInstanceOf(AgentViewLaunchConfigError);
|
||||
});
|
||||
});
|
||||
|
||||
function createLaunch(): AgentViewLaunchFile {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
sessionId: 'session-1',
|
||||
argv: ['qwen', '--agent-view-worker'],
|
||||
env: { QWEN_AGENT_VIEW_WORKER: '1' },
|
||||
entrypoint: 'qwen',
|
||||
projectCwd: '/repo',
|
||||
activeCwd: '/repo/work',
|
||||
includeDirectories: [],
|
||||
terminal: {
|
||||
columns: 100,
|
||||
rows: 30,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakePty(): AgentViewPtyImplementation & {
|
||||
process: FakePtyProcess;
|
||||
spawnCalls: Array<{
|
||||
file: string;
|
||||
args: readonly string[] | string;
|
||||
options: AgentViewPtySpawnOptions;
|
||||
}>;
|
||||
} {
|
||||
const process = new FakePtyProcess();
|
||||
const spawnCalls: Array<{
|
||||
file: string;
|
||||
args: readonly string[] | string;
|
||||
options: AgentViewPtySpawnOptions;
|
||||
}> = [];
|
||||
|
||||
return {
|
||||
name: 'injected',
|
||||
process,
|
||||
spawnCalls,
|
||||
module: {
|
||||
spawn(file, args, options): AgentViewPtyProcess {
|
||||
spawnCalls.push({ file, args, options });
|
||||
return process;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakePtyProcess implements AgentViewPtyProcess {
|
||||
readonly pid = 1234;
|
||||
private dataCallbacks: Array<(data: string) => void> = [];
|
||||
private exitCallbacks: Array<
|
||||
(event: { exitCode: number; signal?: number }) => void
|
||||
> = [];
|
||||
input = '';
|
||||
resizes: Array<{ columns: number; rows: number }> = [];
|
||||
killedWith: string | undefined;
|
||||
killCalls: Array<string | undefined> = [];
|
||||
pauses = 0;
|
||||
resumes = 0;
|
||||
|
||||
write(data: string): void {
|
||||
this.input += data;
|
||||
}
|
||||
|
||||
onData(callback: (data: string) => void) {
|
||||
this.dataCallbacks.push(callback);
|
||||
return {
|
||||
dispose: () => {
|
||||
this.dataCallbacks = this.dataCallbacks.filter(
|
||||
(item) => item !== callback,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onExit(callback: (event: { exitCode: number; signal?: number }) => void) {
|
||||
this.exitCallbacks.push(callback);
|
||||
return {
|
||||
dispose: () => {
|
||||
this.exitCallbacks = this.exitCallbacks.filter(
|
||||
(item) => item !== callback,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
kill(signal?: string): void {
|
||||
this.killCalls.push(signal);
|
||||
this.killedWith = signal;
|
||||
}
|
||||
|
||||
resize(columns: number, rows: number): void {
|
||||
this.resizes.push({ columns, rows });
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.pauses += 1;
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.resumes += 1;
|
||||
}
|
||||
|
||||
emitData(data: string): void {
|
||||
for (const callback of this.dataCallbacks) {
|
||||
callback(data);
|
||||
}
|
||||
}
|
||||
|
||||
emitExit(event: { exitCode: number; signal?: number }): void {
|
||||
for (const callback of this.exitCallbacks) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
import { PTY_HOST_AUTH_TOKEN_ENV } from './pty-host-env.js';
|
||||
import { AGENT_VIEW_WORKER_ENV_KEYS } from './worker-sideband.js';
|
||||
import type { AgentViewLaunchFile } from './protocol.js';
|
||||
|
||||
export const DEFAULT_AGENT_VIEW_PTY_OUTPUT_BYTES = 1024 * 1024;
|
||||
const INTERNAL_ONLY_WORKER_ENV_KEYS = new Set([
|
||||
PTY_HOST_AUTH_TOKEN_ENV,
|
||||
'TMUX',
|
||||
'TMUX_PANE',
|
||||
'STY',
|
||||
'WINDOW',
|
||||
'WINDOWID',
|
||||
'TERMCAP',
|
||||
'COLUMNS',
|
||||
'LINES',
|
||||
]);
|
||||
|
||||
export interface AgentViewPtySpawnOptions {
|
||||
cwd: string;
|
||||
name: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
env: Record<string, string>;
|
||||
handleFlowControl: boolean;
|
||||
}
|
||||
|
||||
export interface AgentViewPtyDisposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface AgentViewPtyProcess {
|
||||
readonly pid: number;
|
||||
write(data: string): void;
|
||||
onData(callback: (data: string) => void): AgentViewPtyDisposable | void;
|
||||
onExit(
|
||||
callback: (event: { exitCode: number; signal?: number }) => void,
|
||||
): AgentViewPtyDisposable | void;
|
||||
resize(cols: number, rows: number): void;
|
||||
kill(signal?: string): void;
|
||||
pause?(): void;
|
||||
resume?(): void;
|
||||
}
|
||||
|
||||
export interface AgentViewPtyModule {
|
||||
spawn(
|
||||
file: string,
|
||||
args: readonly string[] | string,
|
||||
options: AgentViewPtySpawnOptions,
|
||||
): AgentViewPtyProcess;
|
||||
}
|
||||
|
||||
export interface AgentViewPtyImplementation {
|
||||
module: AgentViewPtyModule;
|
||||
name: 'lydell-node-pty' | 'node-pty' | 'injected';
|
||||
}
|
||||
|
||||
export type AgentViewPtyAvailability =
|
||||
| { available: true; implementationName: AgentViewPtyImplementation['name'] }
|
||||
| { available: false; reason: 'missing' };
|
||||
|
||||
export type AgentViewLaunchValidationResult =
|
||||
| { ok: true; launch: AgentViewLaunchFile }
|
||||
| { ok: false; errors: string[] };
|
||||
|
||||
export interface AgentViewPtyHostOptions {
|
||||
fakeCommand?: readonly string[];
|
||||
maxOutputBytes?: number;
|
||||
pty?: AgentViewPtyImplementation | null;
|
||||
loadPty?: () => Promise<AgentViewPtyImplementation | null>;
|
||||
}
|
||||
|
||||
export interface AgentViewPtyHostExit {
|
||||
exitCode: number;
|
||||
signal?: number;
|
||||
}
|
||||
|
||||
export interface AgentViewPtyHostHandle {
|
||||
pid: number;
|
||||
workerPid: number;
|
||||
command: readonly string[];
|
||||
endpoint?: string;
|
||||
authToken?: string;
|
||||
output: BoundedOutputRing;
|
||||
exited: Promise<AgentViewPtyHostExit>;
|
||||
getOutput?(): Promise<string>;
|
||||
write(data: Buffer): void;
|
||||
resetInput?(): void;
|
||||
onData(callback: (data: string) => void): AgentViewPtyDisposable | void;
|
||||
resize(size: { columns: number; rows: number }): void;
|
||||
kill(signal?: string): void;
|
||||
pause?(): void;
|
||||
resume?(): void;
|
||||
shutdown?(): void | Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export class AgentViewPtyUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('Agent View PTY is unavailable in this runtime.');
|
||||
this.name = 'AgentViewPtyUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentViewLaunchConfigError extends Error {
|
||||
constructor(readonly errors: readonly string[]) {
|
||||
super(`Invalid Agent View launch config: ${errors.join('; ')}`);
|
||||
this.name = 'AgentViewLaunchConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BoundedOutputRing {
|
||||
private static readonly MAX_CHUNK_BYTES = 8192;
|
||||
|
||||
private chunks: Buffer[] = [];
|
||||
private retainedBytesValue = 0;
|
||||
private totalBytesValue = 0;
|
||||
|
||||
constructor(readonly maxBytes: number = DEFAULT_AGENT_VIEW_PTY_OUTPUT_BYTES) {
|
||||
if (!Number.isInteger(maxBytes) || maxBytes < 1) {
|
||||
throw new RangeError('maxBytes must be a positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
get retainedBytes(): number {
|
||||
return this.retainedBytesValue;
|
||||
}
|
||||
|
||||
get totalBytes(): number {
|
||||
return this.totalBytesValue;
|
||||
}
|
||||
|
||||
get droppedBytes(): number {
|
||||
return this.totalBytesValue - this.retainedBytesValue;
|
||||
}
|
||||
|
||||
append(data: string | Buffer): void {
|
||||
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
||||
this.totalBytesValue += chunk.byteLength;
|
||||
|
||||
if (chunk.byteLength >= this.maxBytes) {
|
||||
// Copy instead of retaining a subarray view: a view would pin the
|
||||
// entire backing ArrayBuffer of the (potentially huge) source chunk.
|
||||
const retained = trimUtf8Start(
|
||||
Buffer.from(chunk.subarray(chunk.byteLength - this.maxBytes)),
|
||||
);
|
||||
this.chunks = [retained];
|
||||
this.retainedBytesValue = retained.byteLength;
|
||||
return;
|
||||
}
|
||||
|
||||
this.appendChunk(chunk);
|
||||
this.retainedBytesValue += chunk.byteLength;
|
||||
this.trim();
|
||||
}
|
||||
|
||||
toBuffer(): Buffer {
|
||||
return Buffer.concat(this.chunks, this.retainedBytesValue);
|
||||
}
|
||||
|
||||
toString(encoding: BufferEncoding = 'utf8'): string {
|
||||
return this.toBuffer().toString(encoding);
|
||||
}
|
||||
|
||||
private trim(): void {
|
||||
let trimmed = false;
|
||||
while (this.retainedBytesValue > this.maxBytes) {
|
||||
trimmed = true;
|
||||
const excess = this.retainedBytesValue - this.maxBytes;
|
||||
const first = this.chunks[0];
|
||||
if (!first) {
|
||||
this.retainedBytesValue = 0;
|
||||
return;
|
||||
}
|
||||
if (first.byteLength <= excess) {
|
||||
this.chunks.shift();
|
||||
this.retainedBytesValue -= first.byteLength;
|
||||
} else {
|
||||
const retained = trimUtf8Start(first.subarray(excess));
|
||||
if (retained.byteLength === 0) {
|
||||
this.chunks.shift();
|
||||
} else {
|
||||
this.chunks[0] = retained;
|
||||
}
|
||||
this.retainedBytesValue -= first.byteLength - retained.byteLength;
|
||||
}
|
||||
}
|
||||
// Only a size trim can leave continuation bytes at the window start;
|
||||
// without one, dropping them would discard data with room still free.
|
||||
if (trimmed) this.trimLeadingUtf8ContinuationBytes();
|
||||
}
|
||||
|
||||
private appendChunk(chunk: Buffer): void {
|
||||
const previous = this.chunks[this.chunks.length - 1];
|
||||
if (
|
||||
previous &&
|
||||
previous.byteLength + chunk.byteLength <=
|
||||
BoundedOutputRing.MAX_CHUNK_BYTES
|
||||
) {
|
||||
this.chunks[this.chunks.length - 1] = Buffer.concat([previous, chunk]);
|
||||
return;
|
||||
}
|
||||
this.chunks.push(chunk);
|
||||
}
|
||||
|
||||
private trimLeadingUtf8ContinuationBytes(): void {
|
||||
while (this.chunks.length > 0) {
|
||||
const first = this.chunks[0]!;
|
||||
const retained = trimUtf8Start(first);
|
||||
if (retained.byteLength === first.byteLength) {
|
||||
return;
|
||||
}
|
||||
if (retained.byteLength === 0) {
|
||||
this.chunks.shift();
|
||||
} else {
|
||||
this.chunks[0] = retained;
|
||||
}
|
||||
this.retainedBytesValue -= first.byteLength - retained.byteLength;
|
||||
if (retained.byteLength > 0) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function trimUtf8Start(buffer: Buffer): Buffer {
|
||||
let offset = 0;
|
||||
while (
|
||||
offset < buffer.byteLength &&
|
||||
isUtf8ContinuationByte(buffer[offset]!)
|
||||
) {
|
||||
offset++;
|
||||
}
|
||||
return offset === 0 ? buffer : buffer.subarray(offset);
|
||||
}
|
||||
|
||||
function isUtf8ContinuationByte(value: number): boolean {
|
||||
return value >= 0x80 && value <= 0xbf;
|
||||
}
|
||||
|
||||
export async function checkAgentViewPtyAvailability(
|
||||
loadPty: () => Promise<AgentViewPtyImplementation | null> = loadAgentViewPty,
|
||||
): Promise<AgentViewPtyAvailability> {
|
||||
const pty = await loadPty();
|
||||
if (!pty) {
|
||||
return { available: false, reason: 'missing' };
|
||||
}
|
||||
return { available: true, implementationName: pty.name };
|
||||
}
|
||||
|
||||
export async function loadAgentViewPty(): Promise<AgentViewPtyImplementation | null> {
|
||||
if ('bun' in process.versions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lydell = await importPty('@lydell/node-pty', 'lydell-node-pty');
|
||||
if (lydell) {
|
||||
return lydell;
|
||||
}
|
||||
return importPty('node-pty', 'node-pty');
|
||||
}
|
||||
|
||||
export function validateAgentViewLaunchConfig(
|
||||
value: unknown,
|
||||
): AgentViewLaunchValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return { ok: false, errors: ['launch config must be an object'] };
|
||||
}
|
||||
|
||||
requireLiteral(value, 'schemaVersion', 1, errors);
|
||||
requireNonEmptyString(value, 'sessionId', errors);
|
||||
requireStringArray(value, 'argv', errors, { nonEmpty: true });
|
||||
requireStringRecord(value, 'env', errors);
|
||||
requireNonEmptyString(value, 'entrypoint', errors);
|
||||
requireNonEmptyString(value, 'projectCwd', errors);
|
||||
requireNonEmptyString(value, 'activeCwd', errors);
|
||||
requireStringArray(value, 'includeDirectories', errors);
|
||||
validateOptionalString(value, 'model', errors);
|
||||
validateOptionalString(value, 'approvalMode', errors);
|
||||
validateOptionalString(value, 'sandbox', errors);
|
||||
validateOptionalString(value, 'settingsDigest', errors);
|
||||
validateOptionalString(value, 'mcpDigest', errors);
|
||||
validateTerminal(value['terminal'], errors);
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
return { ok: true, launch: value as unknown as AgentViewLaunchFile };
|
||||
}
|
||||
|
||||
export async function launchAgentViewPtyHost(
|
||||
rawLaunch: unknown,
|
||||
options: AgentViewPtyHostOptions = {},
|
||||
): Promise<AgentViewPtyHostHandle> {
|
||||
const validation = validateAgentViewLaunchConfig(rawLaunch);
|
||||
if (!validation.ok) {
|
||||
throw new AgentViewLaunchConfigError(validation.errors);
|
||||
}
|
||||
|
||||
const pty =
|
||||
options.pty === undefined
|
||||
? await (options.loadPty ?? loadAgentViewPty)()
|
||||
: options.pty;
|
||||
if (!pty) {
|
||||
throw new AgentViewPtyUnavailableError();
|
||||
}
|
||||
|
||||
const launch = validation.launch;
|
||||
const command = options.fakeCommand ?? launch.argv;
|
||||
validateCommand(command);
|
||||
|
||||
const output = new BoundedOutputRing(
|
||||
options.maxOutputBytes ?? DEFAULT_AGENT_VIEW_PTY_OUTPUT_BYTES,
|
||||
);
|
||||
// Strip the outer session's sideband identity from the inherited env so a
|
||||
// nested host cannot leak its token/endpoint into the inner worker; the
|
||||
// launch env intentionally carries the inner worker's own sideband keys.
|
||||
const inheritedEnv = stringProcessEnv(process.env);
|
||||
for (const key of AGENT_VIEW_WORKER_ENV_KEYS) {
|
||||
delete inheritedEnv[key];
|
||||
}
|
||||
const workerEnv: Record<string, string> = {
|
||||
...inheritedEnv,
|
||||
...launch.env,
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
for (const key of INTERNAL_ONLY_WORKER_ENV_KEYS) {
|
||||
delete workerEnv[key];
|
||||
}
|
||||
const ptyProcess = pty.module.spawn(command[0], command.slice(1), {
|
||||
cwd: launch.activeCwd,
|
||||
name: 'xterm-256color',
|
||||
cols: launch.terminal.columns,
|
||||
rows: launch.terminal.rows,
|
||||
env: workerEnv,
|
||||
handleFlowControl: false,
|
||||
});
|
||||
let inputDecoder = new StringDecoder('utf8');
|
||||
|
||||
const disposables: AgentViewPtyDisposable[] = [];
|
||||
let settled = false;
|
||||
let resolveExit: (exit: AgentViewPtyHostExit) => void = () => {};
|
||||
const resolveExitOnce = (exit: AgentViewPtyHostExit) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveExit(exit);
|
||||
};
|
||||
const dataDisposable = ptyProcess.onData((data) => {
|
||||
output.append(data);
|
||||
});
|
||||
if (dataDisposable) {
|
||||
disposables.push(dataDisposable);
|
||||
}
|
||||
|
||||
const exited = new Promise<AgentViewPtyHostExit>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
const exitDisposable = ptyProcess.onExit((event) => {
|
||||
resolveExitOnce(event);
|
||||
});
|
||||
if (exitDisposable) {
|
||||
disposables.push(exitDisposable);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
pid: process.pid,
|
||||
workerPid: ptyProcess.pid,
|
||||
command: [...command],
|
||||
output,
|
||||
exited,
|
||||
write(data: Buffer): void {
|
||||
ptyProcess.write(inputDecoder.write(data));
|
||||
},
|
||||
onData(callback: (data: string) => void): AgentViewPtyDisposable | void {
|
||||
return ptyProcess.onData(callback);
|
||||
},
|
||||
resize(size: { columns: number; rows: number }): void {
|
||||
ptyProcess.resize(size.columns, size.rows);
|
||||
},
|
||||
kill(signal?: string): void {
|
||||
// WindowsTerminal.kill throws for any signal string; the argument-less
|
||||
// kill terminates the conpty process tree instead.
|
||||
ptyProcess.kill(process.platform === 'win32' ? undefined : signal);
|
||||
},
|
||||
pause(): void {
|
||||
ptyProcess.pause?.();
|
||||
},
|
||||
resume(): void {
|
||||
ptyProcess.resume?.();
|
||||
},
|
||||
shutdown(): void {
|
||||
ptyProcess.kill(process.platform === 'win32' ? undefined : 'SIGTERM');
|
||||
},
|
||||
resetInput(): void {
|
||||
inputDecoder = new StringDecoder('utf8');
|
||||
},
|
||||
dispose(): void {
|
||||
// Match shutdown(): node-pty's signal-less kill falls back to SIGHUP on
|
||||
// POSIX, which nohup-style workers ignore.
|
||||
ptyProcess.kill(process.platform === 'win32' ? undefined : 'SIGTERM');
|
||||
resolveExitOnce({ exitCode: 1 });
|
||||
for (const disposable of disposables.splice(0)) {
|
||||
disposable.dispose();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function importPty(
|
||||
specifier: '@lydell/node-pty' | 'node-pty',
|
||||
name: AgentViewPtyImplementation['name'],
|
||||
): Promise<AgentViewPtyImplementation | null> {
|
||||
try {
|
||||
const module = await import(specifier);
|
||||
const ptyModule = asPtyModule(module);
|
||||
return ptyModule ? { module: ptyModule, name } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asPtyModule(module: unknown): AgentViewPtyModule | undefined {
|
||||
if (!isRecord(module) || typeof module['spawn'] !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return module as unknown as AgentViewPtyModule;
|
||||
}
|
||||
|
||||
function validateCommand(command: readonly string[]): void {
|
||||
if (command.length === 0 || command.some((part) => part.length === 0)) {
|
||||
throw new AgentViewLaunchConfigError([
|
||||
'command must contain at least one non-empty string',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function stringProcessEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(env).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireLiteral(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
expected: unknown,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (record[key] !== expected) {
|
||||
errors.push(`${key} must be ${String(expected)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireNonEmptyString(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (typeof record[key] !== 'string' || record[key].length === 0) {
|
||||
errors.push(`${key} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalString(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (record[key] !== undefined && typeof record[key] !== 'string') {
|
||||
errors.push(`${key} must be a string when present`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireStringArray(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
errors: string[],
|
||||
options: { nonEmpty?: boolean } = {},
|
||||
): void {
|
||||
const value = record[key];
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${key} must be an array of strings`);
|
||||
return;
|
||||
}
|
||||
if (options.nonEmpty && value.length === 0) {
|
||||
errors.push(`${key} must not be empty`);
|
||||
}
|
||||
if (value.some((item) => typeof item !== 'string')) {
|
||||
errors.push(`${key} must contain only strings`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireStringRecord(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
const value = record[key];
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${key} must be an object with string values`);
|
||||
return;
|
||||
}
|
||||
if (Object.values(value).some((item) => typeof item !== 'string')) {
|
||||
errors.push(`${key} must contain only string values`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTerminal(value: unknown, errors: string[]): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push('terminal must be an object');
|
||||
return;
|
||||
}
|
||||
if (!isPositiveInteger(value['columns'])) {
|
||||
errors.push('terminal.columns must be a positive integer');
|
||||
}
|
||||
if (!isPositiveInteger(value['rows'])) {
|
||||
errors.push('terminal.rows must be a positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): boolean {
|
||||
return Number.isInteger(value) && Number(value) > 0;
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AGENT_VIEW_WORKER_ENV_KEYS,
|
||||
createAgentViewWorkerSidebandEnv,
|
||||
isAgentViewWorkerEnv,
|
||||
QWEN_AGENT_VIEW_ACTIVE_CWD,
|
||||
QWEN_AGENT_VIEW_SESSION_ID,
|
||||
QWEN_AGENT_VIEW_SIDEBAND,
|
||||
QWEN_AGENT_VIEW_TOKEN,
|
||||
QWEN_AGENT_VIEW_WORKER,
|
||||
readAgentViewWorkerSidebandEnv,
|
||||
readAgentViewWorkerControlEvents,
|
||||
reportAgentViewWorkerState,
|
||||
resetAgentViewWorkerStateReportForTests,
|
||||
sendAgentViewWorkerEvent,
|
||||
startAgentViewWorkerHeartbeat,
|
||||
} from './worker-sideband.js';
|
||||
|
||||
const mockCallAgentViewSupervisor = vi.hoisted(() =>
|
||||
vi.fn(async (): Promise<unknown> => ({ accepted: true })),
|
||||
);
|
||||
|
||||
vi.mock('./supervisor-client.js', () => ({
|
||||
callAgentViewSupervisor: mockCallAgentViewSupervisor,
|
||||
}));
|
||||
|
||||
describe('worker sideband env', () => {
|
||||
beforeEach(() => {
|
||||
mockCallAgentViewSupervisor.mockClear();
|
||||
mockCallAgentViewSupervisor.mockResolvedValue({ accepted: true });
|
||||
resetAgentViewWorkerStateReportForTests();
|
||||
});
|
||||
|
||||
it('builds the worker-mode environment variables', () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: 'unix:/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
expect(env).toEqual({
|
||||
[QWEN_AGENT_VIEW_WORKER]: '1',
|
||||
[QWEN_AGENT_VIEW_SESSION_ID]: 'session-1',
|
||||
[QWEN_AGENT_VIEW_SIDEBAND]: 'unix:/tmp/qwen-agent-view.sock',
|
||||
[QWEN_AGENT_VIEW_TOKEN]: 'token-1',
|
||||
[QWEN_AGENT_VIEW_ACTIVE_CWD]: '/repo',
|
||||
});
|
||||
expect(AGENT_VIEW_WORKER_ENV_KEYS).toContain(QWEN_AGENT_VIEW_WORKER);
|
||||
});
|
||||
|
||||
it('detects worker mode only when explicitly enabled', () => {
|
||||
expect(isAgentViewWorkerEnv({ [QWEN_AGENT_VIEW_WORKER]: '1' })).toBe(true);
|
||||
expect(isAgentViewWorkerEnv({ [QWEN_AGENT_VIEW_WORKER]: 'true' })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isAgentViewWorkerEnv({})).toBe(false);
|
||||
});
|
||||
|
||||
it('reads a complete sideband environment', () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: 'pipe:qwen',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
expect(readAgentViewWorkerSidebandEnv(env)).toEqual({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: 'pipe:qwen',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined outside worker mode or when required fields are absent', () => {
|
||||
expect(readAgentViewWorkerSidebandEnv({})).toBeUndefined();
|
||||
for (const missingKey of [
|
||||
QWEN_AGENT_VIEW_WORKER,
|
||||
QWEN_AGENT_VIEW_SESSION_ID,
|
||||
QWEN_AGENT_VIEW_SIDEBAND,
|
||||
QWEN_AGENT_VIEW_TOKEN,
|
||||
QWEN_AGENT_VIEW_ACTIVE_CWD,
|
||||
] as const) {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: 'pipe:qwen',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
delete env[missingKey];
|
||||
expect(readAgentViewWorkerSidebandEnv(env)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends worker events through the configured sideband endpoint', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendAgentViewWorkerEvent(
|
||||
{
|
||||
type: 'ready',
|
||||
cwd: '/repo',
|
||||
capabilities: ['ready'],
|
||||
},
|
||||
env,
|
||||
),
|
||||
).resolves.toEqual({ accepted: true });
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith(
|
||||
'/tmp/qwen-agent-view.sock',
|
||||
'workerEvent',
|
||||
{
|
||||
type: 'ready',
|
||||
cwd: '/repo',
|
||||
capabilities: ['ready'],
|
||||
sessionId: 'session-1',
|
||||
token: 'token-1',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('sends detach requests through the configured sideband endpoint', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
await sendAgentViewWorkerEvent({ type: 'detach' }, env);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith(
|
||||
'/tmp/qwen-agent-view.sock',
|
||||
'workerEvent',
|
||||
{
|
||||
type: 'detach',
|
||||
sessionId: 'session-1',
|
||||
token: 'token-1',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('reads worker control events through the configured sideband endpoint', async () => {
|
||||
mockCallAgentViewSupervisor.mockResolvedValueOnce({
|
||||
events: [
|
||||
{
|
||||
type: 'redraw',
|
||||
sequence: 1,
|
||||
at: '2026-07-17T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
type: 'prompt',
|
||||
sequence: 2,
|
||||
text: 'next step',
|
||||
at: '2026-07-17T00:00:01.000Z',
|
||||
},
|
||||
{
|
||||
type: 'answer',
|
||||
sequence: 3,
|
||||
text: 'yes',
|
||||
outcome: 'proceed_once',
|
||||
payload: { answers: { 0: 'yes' } },
|
||||
at: '2026-07-17T00:00:02.000Z',
|
||||
},
|
||||
{
|
||||
type: 'prompt',
|
||||
sequence: 4,
|
||||
at: '2026-07-17T00:00:03.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
await expect(readAgentViewWorkerControlEvents(env)).resolves.toEqual([
|
||||
{
|
||||
type: 'redraw',
|
||||
sequence: 1,
|
||||
at: '2026-07-17T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
type: 'prompt',
|
||||
sequence: 2,
|
||||
text: 'next step',
|
||||
at: '2026-07-17T00:00:01.000Z',
|
||||
},
|
||||
{
|
||||
type: 'answer',
|
||||
sequence: 3,
|
||||
text: 'yes',
|
||||
outcome: 'proceed_once',
|
||||
payload: { answers: { 0: 'yes' } },
|
||||
at: '2026-07-17T00:00:02.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith(
|
||||
'/tmp/qwen-agent-view.sock',
|
||||
'workerControl',
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
token: 'token-1',
|
||||
},
|
||||
{ timeoutMs: 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores malformed worker control responses', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
for (const response of [null, {}, { events: 'invalid' }]) {
|
||||
mockCallAgentViewSupervisor.mockResolvedValueOnce(response);
|
||||
await expect(readAgentViewWorkerControlEvents(env)).resolves.toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports worker state through the configured sideband endpoint', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
await reportAgentViewWorkerState(
|
||||
{
|
||||
sessionState: 'needs_input',
|
||||
cwd: '/repo',
|
||||
summary: 'Waiting for Bash',
|
||||
waitingFor: 'Bash',
|
||||
},
|
||||
env,
|
||||
);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith(
|
||||
'/tmp/qwen-agent-view.sock',
|
||||
'workerEvent',
|
||||
{
|
||||
type: 'state',
|
||||
sessionState: 'needs_input',
|
||||
cwd: '/repo',
|
||||
summary: 'Waiting for Bash',
|
||||
waitingFor: 'Bash',
|
||||
sessionId: 'session-1',
|
||||
token: 'token-1',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('does not resend identical worker state reports', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
await reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
await reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deduplicates worker state reports per session', async () => {
|
||||
const firstEnv = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
const secondEnv = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-2',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-2',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
await reportAgentViewWorkerState({ sessionState: 'working' }, firstEnv);
|
||||
await reportAgentViewWorkerState({ sessionState: 'working' }, secondEnv);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sends same-state reports when details change', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
await reportAgentViewWorkerState(
|
||||
{ sessionState: 'working', summary: 'Running build' },
|
||||
env,
|
||||
);
|
||||
await reportAgentViewWorkerState(
|
||||
{ sessionState: 'working', summary: 'Waiting for approval' },
|
||||
env,
|
||||
);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('defaults worker state report cwd to the sideband active cwd', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
await reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith(
|
||||
'/tmp/qwen-agent-view.sock',
|
||||
'workerEvent',
|
||||
expect.objectContaining({
|
||||
cwd: '/repo',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries identical worker state reports after a send failure', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
mockCallAgentViewSupervisor
|
||||
.mockRejectedValueOnce(new Error('supervisor unavailable'))
|
||||
.mockResolvedValueOnce({ accepted: true });
|
||||
|
||||
await reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
await reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not deduplicate concurrent state reports before send succeeds', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
let rejectFirst: (error: Error) => void = () => {};
|
||||
mockCallAgentViewSupervisor
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<unknown>((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ accepted: true });
|
||||
|
||||
const first = reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
const second = reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
rejectFirst(new Error('supervisor unavailable'));
|
||||
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('serializes concurrent state reports before recording dedupe keys', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
let resolveFirst: (value: unknown) => void = () => {};
|
||||
mockCallAgentViewSupervisor
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<unknown>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ accepted: true })
|
||||
.mockResolvedValueOnce({ accepted: true });
|
||||
|
||||
const first = reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
const second = reportAgentViewWorkerState(
|
||||
{ sessionState: 'needs_input' },
|
||||
env,
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFirst({ accepted: true });
|
||||
await Promise.all([first, second]);
|
||||
await reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
|
||||
expect(
|
||||
mockCallAgentViewSupervisor.mock.calls.map(
|
||||
(call) =>
|
||||
((call as unknown[])[2] as { sessionState?: string } | undefined)
|
||||
?.sessionState,
|
||||
),
|
||||
).toEqual(['working', 'needs_input', 'working']);
|
||||
});
|
||||
|
||||
it('skips worker events, control reads, and heartbeats outside worker mode', async () => {
|
||||
await expect(
|
||||
sendAgentViewWorkerEvent({ type: 'heartbeat' }, {}),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(readAgentViewWorkerControlEvents({})).resolves.toEqual([]);
|
||||
expect(startAgentViewWorkerHeartbeat({})).toBeUndefined();
|
||||
|
||||
expect(mockCallAgentViewSupervisor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-sends a state after an intervening failed report', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
await reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
mockCallAgentViewSupervisor.mockRejectedValueOnce(new Error('offline'));
|
||||
await reportAgentViewWorkerState({ sessionState: 'needs_input' }, env);
|
||||
await reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('sends one event for concurrent identical state reports', async () => {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
let release: (value: unknown) => void = () => {};
|
||||
mockCallAgentViewSupervisor.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const first = reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
const second = reportAgentViewWorkerState({ sessionState: 'working' }, env);
|
||||
release({ accepted: true });
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips worker state reports outside worker mode', async () => {
|
||||
await reportAgentViewWorkerState({ sessionState: 'idle' }, {});
|
||||
|
||||
expect(mockCallAgentViewSupervisor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends heartbeat events until disposed', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
|
||||
const heartbeat = startAgentViewWorkerHeartbeat(env, 100);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith(
|
||||
'/tmp/qwen-agent-view.sock',
|
||||
'workerEvent',
|
||||
{
|
||||
type: 'heartbeat',
|
||||
sessionId: 'session-1',
|
||||
token: 'token-1',
|
||||
},
|
||||
);
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(2);
|
||||
|
||||
heartbeat?.dispose();
|
||||
mockCallAgentViewSupervisor.mockClear();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(mockCallAgentViewSupervisor).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores heartbeat send failures', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const env = createAgentViewWorkerSidebandEnv({
|
||||
sessionId: 'session-1',
|
||||
sidebandEndpoint: '/tmp/qwen-agent-view.sock',
|
||||
token: 'token-1',
|
||||
activeCwd: '/repo',
|
||||
});
|
||||
mockCallAgentViewSupervisor.mockRejectedValueOnce(
|
||||
new Error('supervisor unavailable'),
|
||||
);
|
||||
|
||||
const heartbeat = startAgentViewWorkerHeartbeat(env, 100);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(1);
|
||||
heartbeat?.dispose();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { callAgentViewSupervisor } from './supervisor-client.js';
|
||||
import type {
|
||||
AgentViewWorkerControlEvent,
|
||||
AgentViewSessionState,
|
||||
AgentViewWorkerEvent,
|
||||
} from './protocol.js';
|
||||
|
||||
export const QWEN_AGENT_VIEW_WORKER = 'QWEN_AGENT_VIEW_WORKER';
|
||||
export const QWEN_AGENT_VIEW_SESSION_ID = 'QWEN_AGENT_VIEW_SESSION_ID';
|
||||
export const QWEN_AGENT_VIEW_SIDEBAND = 'QWEN_AGENT_VIEW_SIDEBAND';
|
||||
export const QWEN_AGENT_VIEW_TOKEN = 'QWEN_AGENT_VIEW_TOKEN';
|
||||
export const QWEN_AGENT_VIEW_ACTIVE_CWD = 'QWEN_AGENT_VIEW_ACTIVE_CWD';
|
||||
|
||||
export const AGENT_VIEW_WORKER_ENV_KEYS = [
|
||||
QWEN_AGENT_VIEW_WORKER,
|
||||
QWEN_AGENT_VIEW_SESSION_ID,
|
||||
QWEN_AGENT_VIEW_SIDEBAND,
|
||||
QWEN_AGENT_VIEW_TOKEN,
|
||||
QWEN_AGENT_VIEW_ACTIVE_CWD,
|
||||
] as const;
|
||||
|
||||
export type AgentViewWorkerEnvKey = (typeof AGENT_VIEW_WORKER_ENV_KEYS)[number];
|
||||
|
||||
export interface AgentViewWorkerSidebandEnv {
|
||||
sessionId: string;
|
||||
sidebandEndpoint: string;
|
||||
token: string;
|
||||
activeCwd: string;
|
||||
}
|
||||
|
||||
type AgentViewWorkerEventWithoutSession =
|
||||
| Omit<Extract<AgentViewWorkerEvent, { type: 'ready' }>, 'sessionId'>
|
||||
| Omit<Extract<AgentViewWorkerEvent, { type: 'heartbeat' }>, 'sessionId'>
|
||||
| Omit<Extract<AgentViewWorkerEvent, { type: 'detach' }>, 'sessionId'>
|
||||
| Omit<Extract<AgentViewWorkerEvent, { type: 'state' }>, 'sessionId'>;
|
||||
|
||||
export interface AgentViewWorkerStateReport {
|
||||
sessionState: AgentViewSessionState;
|
||||
cwd?: string;
|
||||
summary?: string;
|
||||
waitingFor?: string;
|
||||
lastResult?: string;
|
||||
}
|
||||
|
||||
export interface AgentViewWorkerHeartbeat {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const lastStateReportKeys = new Map<string, string>();
|
||||
const stateReportChains = new Map<string, Promise<void>>();
|
||||
|
||||
export function createAgentViewWorkerSidebandEnv(
|
||||
config: AgentViewWorkerSidebandEnv,
|
||||
): Record<AgentViewWorkerEnvKey, string> {
|
||||
return {
|
||||
[QWEN_AGENT_VIEW_WORKER]: '1',
|
||||
[QWEN_AGENT_VIEW_SESSION_ID]: config.sessionId,
|
||||
[QWEN_AGENT_VIEW_SIDEBAND]: config.sidebandEndpoint,
|
||||
[QWEN_AGENT_VIEW_TOKEN]: config.token,
|
||||
[QWEN_AGENT_VIEW_ACTIVE_CWD]: config.activeCwd,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAgentViewWorkerEnv(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return env[QWEN_AGENT_VIEW_WORKER] === '1';
|
||||
}
|
||||
|
||||
export function readAgentViewWorkerSidebandEnv(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): AgentViewWorkerSidebandEnv | undefined {
|
||||
if (!isAgentViewWorkerEnv(env)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sessionId = env[QWEN_AGENT_VIEW_SESSION_ID];
|
||||
const sidebandEndpoint = env[QWEN_AGENT_VIEW_SIDEBAND];
|
||||
const token = env[QWEN_AGENT_VIEW_TOKEN];
|
||||
const activeCwd = env[QWEN_AGENT_VIEW_ACTIVE_CWD];
|
||||
|
||||
if (!sessionId || !sidebandEndpoint || !token || !activeCwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
sidebandEndpoint,
|
||||
token,
|
||||
activeCwd,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendAgentViewWorkerEvent(
|
||||
event: AgentViewWorkerEventWithoutSession,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<unknown> {
|
||||
const sideband = readAgentViewWorkerSidebandEnv(env);
|
||||
if (!sideband) return undefined;
|
||||
return callAgentViewSupervisor(sideband.sidebandEndpoint, 'workerEvent', {
|
||||
...event,
|
||||
sessionId: sideband.sessionId,
|
||||
token: sideband.token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readAgentViewWorkerControlEvents(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<AgentViewWorkerControlEvent[]> {
|
||||
const sideband = readAgentViewWorkerSidebandEnv(env);
|
||||
if (!sideband) return [];
|
||||
|
||||
const result = await callAgentViewSupervisor(
|
||||
sideband.sidebandEndpoint,
|
||||
'workerControl',
|
||||
{
|
||||
sessionId: sideband.sessionId,
|
||||
token: sideband.token,
|
||||
},
|
||||
{ timeoutMs: 1000 },
|
||||
);
|
||||
|
||||
if (!isRecord(result)) return [];
|
||||
const events = result['events'];
|
||||
if (!Array.isArray(events)) return [];
|
||||
return events.filter(isAgentViewWorkerControlEvent);
|
||||
}
|
||||
|
||||
export async function reportAgentViewWorkerState(
|
||||
report: AgentViewWorkerStateReport,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<void> {
|
||||
const sideband = readAgentViewWorkerSidebandEnv(env);
|
||||
if (!sideband) return;
|
||||
|
||||
const event = {
|
||||
type: 'state',
|
||||
...report,
|
||||
// activeCwd is guaranteed by readAgentViewWorkerSidebandEnv and survives
|
||||
// a deleted cwd, unlike process.cwd(), which throws ENOENT.
|
||||
cwd: report.cwd ?? sideband.activeCwd,
|
||||
} as const;
|
||||
const key = JSON.stringify(event);
|
||||
const sendAndRecord = async () => {
|
||||
if (key === lastStateReportKeys.get(sideband.sessionId)) return;
|
||||
|
||||
try {
|
||||
await sendAgentViewWorkerEvent(event, env);
|
||||
lastStateReportKeys.set(sideband.sessionId, key);
|
||||
} catch {
|
||||
lastStateReportKeys.delete(sideband.sessionId);
|
||||
}
|
||||
};
|
||||
const previous = stateReportChains.get(sideband.sessionId);
|
||||
const run = previous
|
||||
? previous.catch(() => {}).then(sendAndRecord)
|
||||
: sendAndRecord();
|
||||
stateReportChains.set(sideband.sessionId, run);
|
||||
try {
|
||||
await run;
|
||||
} finally {
|
||||
if (stateReportChains.get(sideband.sessionId) === run) {
|
||||
stateReportChains.delete(sideband.sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startAgentViewWorkerHeartbeat(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
intervalMs = 15_000,
|
||||
): AgentViewWorkerHeartbeat | undefined {
|
||||
if (!readAgentViewWorkerSidebandEnv(env)) return undefined;
|
||||
const interval = setInterval(() => {
|
||||
void sendAgentViewWorkerEvent({ type: 'heartbeat' }, env).catch(() => {});
|
||||
}, intervalMs);
|
||||
interval.unref?.();
|
||||
return {
|
||||
dispose() {
|
||||
clearInterval(interval);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAgentViewWorkerStateReportForTests(): void {
|
||||
lastStateReportKeys.clear();
|
||||
stateReportChains.clear();
|
||||
}
|
||||
|
||||
function isAgentViewWorkerControlEvent(
|
||||
value: unknown,
|
||||
): value is AgentViewWorkerControlEvent {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!Number.isInteger(value['sequence']) ||
|
||||
typeof value['at'] !== 'string'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (value['type'] === 'redraw') {
|
||||
return true;
|
||||
}
|
||||
if (value['type'] === 'prompt') {
|
||||
return typeof value['text'] === 'string';
|
||||
}
|
||||
return (
|
||||
value['type'] === 'answer' &&
|
||||
(value['text'] === undefined || typeof value['text'] === 'string') &&
|
||||
(value['callId'] === undefined || typeof value['callId'] === 'string') &&
|
||||
(value['outcome'] === undefined ||
|
||||
isAgentViewWorkerAnswerOutcome(value['outcome'])) &&
|
||||
(value['payload'] === undefined || isRecord(value['payload']))
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isAgentViewWorkerAnswerOutcome(value: unknown): boolean {
|
||||
return (
|
||||
value === 'proceed_once' ||
|
||||
value === 'proceed_always' ||
|
||||
value === 'proceed_always_project' ||
|
||||
value === 'proceed_always_user' ||
|
||||
value === 'modify_with_editor' ||
|
||||
value === 'restore_previous' ||
|
||||
value === 'cancel'
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,7 @@ describe('reviewCommand', () => {
|
||||
'test-efficacy',
|
||||
'test-plan',
|
||||
'findings',
|
||||
'recover-findings',
|
||||
'publish-assets',
|
||||
'compose-review',
|
||||
'save-artifact',
|
||||
|
||||
@@ -12,7 +12,8 @@ import type { Argv, CommandModule } from 'yargs';
|
||||
import { parseArgsCommand } from './review/parse-args.js';
|
||||
import { matchRemoteCommand } from './review/match-remote.js';
|
||||
import { composeReviewCommand } from './review/compose-review.js';
|
||||
import { findingsCommand } from './review/findings.js';
|
||||
import { findingsCommand } from '../utils/findings.js';
|
||||
import { recoverFindingsCommand } from './review/recover-findings.js';
|
||||
import { fetchPrCommand } from './review/fetch-pr.js';
|
||||
import { captureLocalCommand } from './review/capture-local.js';
|
||||
import { planDiffCommand } from './review/plan-diff.js';
|
||||
@@ -79,6 +80,7 @@ export const reviewCommand: CommandModule = {
|
||||
.command(testEfficacyCommand)
|
||||
.command(testPlanCommand)
|
||||
.command(findingsCommand)
|
||||
.command(recoverFindingsCommand)
|
||||
.command(publishAssetsCommand)
|
||||
.command(composeReviewCommand)
|
||||
.command(saveArtifactCommand)
|
||||
@@ -86,7 +88,7 @@ export const reviewCommand: CommandModule = {
|
||||
.command(cleanupCommand)
|
||||
.demandCommand(
|
||||
1,
|
||||
'Specify a subcommand: run, parse-args, match-remote, meta, issue-context, fetch-diff, comment-body, fetch-pr, capture-local, plan-diff, repo-context, pr-context, comment-status, load-rules, agent-prompt, build-test, base-tree, test-delta, drive, mock-provider, extract-step, script-lint, resolve-anchors, check-coverage, cost-ledger, presubmit, test-efficacy, test-plan, findings, publish-assets, compose-review, save-artifact, submit, or cleanup.',
|
||||
'Specify a subcommand: run, parse-args, match-remote, meta, issue-context, fetch-diff, comment-body, fetch-pr, capture-local, plan-diff, repo-context, pr-context, comment-status, load-rules, agent-prompt, build-test, base-tree, test-delta, drive, mock-provider, extract-step, script-lint, resolve-anchors, check-coverage, cost-ledger, presubmit, test-efficacy, test-plan, findings, recover-findings, publish-assets, compose-review, save-artifact, submit, or cleanup.',
|
||||
)
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
@@ -2773,8 +2773,18 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => {
|
||||
// The command runs install + builds + tests in one process; the agent's default
|
||||
// 120s shell timeout would kill it — the very failure this command prevents, one
|
||||
// level up. So the block tells the agent to pass the tool's max, 600000ms.
|
||||
//
|
||||
// Pinned PER SITE, not per prompt: three sites supply the directive (the
|
||||
// first call, the resume paragraph's "Same …", the efficacy probe's
|
||||
// "… too"), so a whole-prompt `toContain` stayed green with any one of
|
||||
// them deleted — and the deleted first-call directive is exactly the
|
||||
// 120s mid-install kill this assertion's own comment names.
|
||||
const p = buildRoleBrief(PR_PLAN, '7', { planPath: '/abs/tmp/plan.json' });
|
||||
expect(p).toContain(`timeout: ${SHELL_TOOL_MAX_TIMEOUT_MS}`);
|
||||
expect(p).toContain(
|
||||
`Invoke it with \`timeout: ${SHELL_TOOL_MAX_TIMEOUT_MS}\`:`,
|
||||
);
|
||||
expect(p).toContain(`Same \`timeout: ${SHELL_TOOL_MAX_TIMEOUT_MS}\``);
|
||||
expect(p).toContain(`\`timeout: ${SHELL_TOOL_MAX_TIMEOUT_MS}\` too`);
|
||||
});
|
||||
|
||||
it('tells Agent 7 how to CONTINUE a run one call could not finish', () => {
|
||||
@@ -2784,8 +2794,14 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => {
|
||||
// call teaches the agent to report a truncated dimension as a finished
|
||||
// one — which is what three live reviews did.
|
||||
const p = buildRoleBrief(PR_PLAN, '7', { planPath: '/abs/tmp/plan.json' });
|
||||
expect(p).toContain('testScope.notRun');
|
||||
expect(p).toContain('"clamped": true');
|
||||
// Anchored to the continuation PARAGRAPH's own sentence: the bare
|
||||
// literals are also supplied verbatim by the role-7 base brief
|
||||
// (agent-briefs), so `toContain('testScope.notRun')` stayed green with
|
||||
// the whole paragraph deleted.
|
||||
expect(p).toContain(
|
||||
'Work is left when `testScope.notRun` is non-empty, or when any ' +
|
||||
'`test[]` entry has `"clamped": true`',
|
||||
);
|
||||
|
||||
// Asserted on the CONTINUATION BLOCK ALONE, which is the whole point. The
|
||||
// first cut of this test searched the entire prompt: `--resume` matched the
|
||||
@@ -2802,11 +2818,45 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => {
|
||||
// `resolve` — not spelled as POSIX literals: on Windows the prompt carries
|
||||
// `C:\\abs\\tmp\\plan.json`, and a hardcoded expectation fails there for a
|
||||
// reason that has nothing to do with the continuation block.
|
||||
expect(resumeBlock[0]).toContain('review build-test');
|
||||
// The FULL wrapper, on THIS block: the whole-prompt pins are satisfied
|
||||
// by the first invocation block and vice versa, so a wrapper deleted
|
||||
// from either one shipped green — and a resume block without it execs
|
||||
// bare PATH `qwen`, an old global that lacks `build-test` entirely.
|
||||
expect(resumeBlock[0]).toContain(
|
||||
'"${QWEN_CODE_CLI:-qwen}" review build-test',
|
||||
);
|
||||
expect(resumeBlock[0]).toContain(`--plan ${resolve('/abs/tmp/plan.json')}`);
|
||||
// The tree too: a continuation against a different tree measures a
|
||||
// different run. Never asserted before — a dropped `--worktree` line
|
||||
// shipped green.
|
||||
expect(resumeBlock[0]).toContain(
|
||||
`--worktree ${resolve('.qwen/tmp/review-pr-6766')}`,
|
||||
);
|
||||
expect(resumeBlock[0]).toContain(
|
||||
`--out ${join(resolve('/abs/tmp'), 'qwen-review-pr-6766-build-test.json')}`,
|
||||
);
|
||||
expect(resumeBlock[0]).toContain('--resume');
|
||||
|
||||
// And the FIRST invocation block carries its own wrapper and tree — the
|
||||
// same two elements, scoped to the block that must supply them.
|
||||
const firstBlock = fences.find(
|
||||
(f) => f.includes('review build-test') && !f.includes('--resume'),
|
||||
);
|
||||
expect(firstBlock).toBeDefined();
|
||||
expect(firstBlock).toContain('"${QWEN_CODE_CLI:-qwen}" review build-test');
|
||||
expect(firstBlock).toContain(
|
||||
`--worktree ${resolve('.qwen/tmp/review-pr-6766')}`,
|
||||
);
|
||||
|
||||
// The third-shape sentence, at BOTH prose sites — the role-7 base brief
|
||||
// and the welded resume paragraph each carry it, so a single toContain
|
||||
// is satisfied by either and a one-site deletion ships green. Counted,
|
||||
// not just matched: deleting the sentence anywhere drops the count, and
|
||||
// an agent missing it treats the endedBeforeTests shape as continuable,
|
||||
// spending a MAX_RESUME_CALLS slot on a --resume that can only answer
|
||||
// "ended before its test phase".
|
||||
expect(p.split('"endedBeforeTests": true').length - 1).toBe(2);
|
||||
expect(p.split('do not spend a continuation on it').length - 1).toBe(2);
|
||||
});
|
||||
|
||||
it('welds the PR into Agent 0 — an unqualified number judges the wrong issue', () => {
|
||||
@@ -3354,6 +3404,27 @@ describe('verify and reverse-audit briefs — the Step 4/5 methodology, in code'
|
||||
expect(p).toContain('go read the claimed source first');
|
||||
});
|
||||
|
||||
it('the verify brief carries the #9341 live-verification run disciplines', () => {
|
||||
// A live two-arm verification of the standalone-session PR produced four
|
||||
// disciplines the brief did not then carry, each from a measured miss: a
|
||||
// behaviour matrix whose first pass was contaminated by reusing one session
|
||||
// id across rows; a restore/delete race whose verdict came off a
|
||||
// deterministic 40-round-per-arm split, not prose; a darwin-only HTTP
|
||||
// surface exercised one level down against the compiled resolver; and a
|
||||
// reserved-value session created on the base daemon and loaded on the PR
|
||||
// daemon — the base-produces/PR-consumes handoff a same-input A/B can
|
||||
// never produce. Pin each so a paraphrase cannot drop them back.
|
||||
const p = buildRoleBrief(PLAN, 'verify');
|
||||
expect(p).toContain('Each row of a run matrix starts from fresh state');
|
||||
expect(p).toContain('a deterministic split is what separates');
|
||||
expect(p).toContain('drive the same code one level down');
|
||||
expect(p).toContain('let base produce and PR consume');
|
||||
// Verifier run-hygiene must not bleed into a finder dimension.
|
||||
expect(buildRoleBrief(PLAN, '1a')).not.toContain(
|
||||
'Each row of a run matrix starts from fresh state',
|
||||
);
|
||||
});
|
||||
|
||||
it('the verify brief is a verdict role: Exclusion Criteria yes, finding format no', () => {
|
||||
const p = buildRoleBrief(PLAN, 'verify');
|
||||
expect(p).toContain('What is NOT a finding'); // the Exclusion Criteria heading
|
||||
@@ -3946,6 +4017,10 @@ describe('per-chunk retirement — cold territories stop costing a round', () =>
|
||||
beforeEach(() => {
|
||||
(writeStdoutLine as unknown as Mock).mockClear();
|
||||
(writeStderrLine as unknown as Mock).mockClear();
|
||||
// Cleared beside its throwing siblings (#9259): uncleared, every
|
||||
// assertion below read the ACCUMULATED output of earlier tests — an
|
||||
// order-dependent oracle that passed on residue alone.
|
||||
(writeStderrLineSafe as unknown as Mock).mockClear();
|
||||
dir = mkdtempSync(join(tmpdir(), 'ap-retire-'));
|
||||
dirs.push(dir);
|
||||
plan = join(dir, 'plan.json');
|
||||
@@ -3956,11 +4031,25 @@ describe('per-chunk retirement — cold territories stop costing a round', () =>
|
||||
utimesSync(plan, old, old);
|
||||
findings = join(dir, 'findings.md');
|
||||
writeFileSync(findings, '');
|
||||
for (const k of ['QWEN_CODE_PROJECT_DIR', 'QWEN_CODE_SESSION_ID']) {
|
||||
for (const k of [
|
||||
'QWEN_CODE_PROJECT_DIR',
|
||||
'QWEN_CODE_SESSION_ID',
|
||||
// The budget gate reads these three straight from process.env on
|
||||
// every admission the tests below drive (#9272): an ambient value
|
||||
// inherited from a concurrent review makes admission
|
||||
// environment-dependent — the same isolation the repro harness
|
||||
// carries (#9259), on the describe that actually needs it.
|
||||
DEADLINE_ENV,
|
||||
RESERVE_ENV,
|
||||
TOOL_CONCURRENCY_ENV,
|
||||
]) {
|
||||
SAVED[k] = process.env[k];
|
||||
}
|
||||
process.env['QWEN_CODE_PROJECT_DIR'] = dir;
|
||||
process.env['QWEN_CODE_SESSION_ID'] = 'S1';
|
||||
delete process.env[DEADLINE_ENV];
|
||||
delete process.env[RESERVE_ENV];
|
||||
delete process.env[TOOL_CONCURRENCY_ENV];
|
||||
mkdirSync(join(dir, 'subagents', 'S1'), { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -5036,6 +5125,12 @@ describe('per-chunk retirement — cold territories stop costing a round', () =>
|
||||
expect(err).toContain(
|
||||
'chunk 13 \u2014 round 1: no matching transcript; round 2: no matching transcript',
|
||||
);
|
||||
// The narrowing's absence half (#9259): chunk 14's failures exist in
|
||||
// the same schedule but must NOT ride chunk 13's build — an
|
||||
// unfiltered `schedule.diagnostics` here prints every chunk's note on
|
||||
// every build, and the count lies about the coverage.
|
||||
expect(err).not.toContain('chunk 14 \u2014');
|
||||
expect(err).toContain('1 twice-audited chunk(s)');
|
||||
// The first build admitted the round — its stamp is what used to gate
|
||||
// the second build's diagnostic out.
|
||||
expect(readRoundStamps(plan).some((s) => s.round === 3)).toBe(true);
|
||||
@@ -5169,6 +5264,150 @@ describe('per-chunk retirement — cold territories stop costing a round', () =>
|
||||
}
|
||||
});
|
||||
|
||||
it('a refused round prints no audit NOTE — the round builder defers the catch note past the gate (#9259)', () => {
|
||||
// Cap-3 plan, rounds 1-3 non-converging, transcripts unreadable: the
|
||||
// schedule read dies AND round 4 is refused. The stderr must carry
|
||||
// the ROUND CAP refusal only — a `auditing every chunk.` NOTE here
|
||||
// promises an audit that never happens.
|
||||
writeFileSync(
|
||||
plan,
|
||||
JSON.stringify({ ...PLAN, budget: { reverseAuditRounds: 3 } }),
|
||||
);
|
||||
const old = new Date(2020, 0, 1);
|
||||
utimesSync(plan, old, old);
|
||||
answerRound(1, { 13: YIELD, 14: YIELD, 15: YIELD });
|
||||
answerRound(2, { 13: YIELD, 14: YIELD, 15: YIELD });
|
||||
answerRound(3, { 13: YIELD, 14: YIELD, 15: YIELD });
|
||||
delete process.env['QWEN_CODE_SESSION_ID'];
|
||||
(writeStderrLineSafe as unknown as Mock).mockClear();
|
||||
|
||||
const out = runRound(4);
|
||||
|
||||
expect(process.exitCode).toBe(4);
|
||||
expect(out).toBe('');
|
||||
expect(keysOf(4)).toHaveLength(0);
|
||||
const msg = (writeStderrLine as unknown as Mock).mock.calls
|
||||
.map((c) => c[0])
|
||||
.join('\n');
|
||||
expect(msg).toContain('ROUND CAP');
|
||||
const safe = (writeStderrLineSafe as unknown as Mock).mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.join('\n');
|
||||
expect(safe).not.toContain('reverse-audit retirement unavailable');
|
||||
});
|
||||
|
||||
it('a refused per-chunk build prints no audit NOTE either (#9259)', () => {
|
||||
// The --chunk twin of the gate-side truthfulness: an unadmitted round
|
||||
// 4 at cap 3 with an unreadable history is refused, and the refusal
|
||||
// is the only thing stderr says about the round.
|
||||
writeFileSync(
|
||||
plan,
|
||||
JSON.stringify({ ...PLAN, budget: { reverseAuditRounds: 3 } }),
|
||||
);
|
||||
const old = new Date(2020, 0, 1);
|
||||
utimesSync(plan, old, old);
|
||||
answerRound(1, { 13: YIELD, 14: YIELD, 15: YIELD });
|
||||
answerRound(2, { 13: YIELD, 14: YIELD, 15: YIELD });
|
||||
answerRound(3, { 13: YIELD, 14: YIELD, 15: YIELD });
|
||||
delete process.env['QWEN_CODE_SESSION_ID'];
|
||||
(writeStderrLineSafe as unknown as Mock).mockClear();
|
||||
|
||||
(agentPromptCommand.handler as (a: unknown) => void)({
|
||||
plan,
|
||||
role: 'reverse-audit',
|
||||
findings,
|
||||
chunk: 13,
|
||||
round: 4,
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(4);
|
||||
const msg = (writeStderrLine as unknown as Mock).mock.calls
|
||||
.map((c) => c[0])
|
||||
.join('\n');
|
||||
expect(msg).toContain('ROUND CAP');
|
||||
const safe = (writeStderrLineSafe as unknown as Mock).mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.join('\n');
|
||||
expect(safe).not.toContain('reverse-audit retirement unavailable');
|
||||
});
|
||||
|
||||
it('a repair build whose schedule begins to throw still names the degradation — once per round (#9259)', () => {
|
||||
// Round 3's admission build (chunk 13) reads cleanly: stamp lands,
|
||||
// nothing said. Then the history dies, and chunk 14's repair build
|
||||
// must still print the NOTE — the stamp-keyed suppression this
|
||||
// replaces silenced exactly this shape. Chunk 15's build repeats the
|
||||
// failure and stays silent: the note earns its place once per round
|
||||
// per process.
|
||||
answerRound(1, { 13: DRY, 14: DRY, 15: YIELD });
|
||||
answerRound(2, { 13: DRY, 14: DRY, 15: YIELD });
|
||||
(writeStderrLineSafe as unknown as Mock).mockClear();
|
||||
(agentPromptCommand.handler as (a: unknown) => void)({
|
||||
plan,
|
||||
role: 'reverse-audit',
|
||||
findings,
|
||||
chunk: 13,
|
||||
round: 3,
|
||||
});
|
||||
expect(readRoundStamps(plan).some((s) => s.round === 3)).toBe(true);
|
||||
expect(
|
||||
(writeStderrLineSafe as unknown as Mock).mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.join('\n'),
|
||||
).not.toContain('reverse-audit retirement unavailable');
|
||||
|
||||
delete process.env['QWEN_CODE_SESSION_ID'];
|
||||
(writeStderrLineSafe as unknown as Mock).mockClear();
|
||||
(agentPromptCommand.handler as (a: unknown) => void)({
|
||||
plan,
|
||||
role: 'reverse-audit',
|
||||
findings,
|
||||
chunk: 14,
|
||||
round: 3,
|
||||
});
|
||||
let safe = (writeStderrLineSafe as unknown as Mock).mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.join('\n');
|
||||
expect(safe).toContain('reverse-audit retirement unavailable');
|
||||
expect(safe).toContain('auditing the chunk');
|
||||
// The NOTE's middle carries the WHY — the underlying failure's own
|
||||
// message, not an empty dash (#9272): the constant prefix and suffix
|
||||
// alone would print `unavailable this round — — auditing the chunk.`
|
||||
// and name nothing.
|
||||
expect(safe).toMatch(/unavailable this round — .+ — auditing the chunk\./);
|
||||
|
||||
(writeStderrLineSafe as unknown as Mock).mockClear();
|
||||
(agentPromptCommand.handler as (a: unknown) => void)({
|
||||
plan,
|
||||
role: 'reverse-audit',
|
||||
findings,
|
||||
chunk: 15,
|
||||
round: 3,
|
||||
});
|
||||
safe = (writeStderrLineSafe as unknown as Mock).mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.join('\n');
|
||||
expect(safe).not.toContain('reverse-audit retirement unavailable');
|
||||
// Every repair still builds its chunk — the safe direction stands.
|
||||
expect(keysOf(3)).toHaveLength(3);
|
||||
|
||||
// The claim is per ROUND (#9272): the same failure beginning in a
|
||||
// LATER round of the same run earns its own NOTE — a plan-only key
|
||||
// would silence it forever. Round 4 is under the default cap, and
|
||||
// the history is still unreadable.
|
||||
(writeStderrLineSafe as unknown as Mock).mockClear();
|
||||
(agentPromptCommand.handler as (a: unknown) => void)({
|
||||
plan,
|
||||
role: 'reverse-audit',
|
||||
findings,
|
||||
chunk: 13,
|
||||
round: 4,
|
||||
});
|
||||
safe = (writeStderrLineSafe as unknown as Mock).mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.join('\n');
|
||||
expect(safe).toContain('reverse-audit retirement unavailable');
|
||||
});
|
||||
|
||||
it('the #9242 note stays below the convergence gate — a converged round notes nothing', () => {
|
||||
// A plan whose own numbers say Step 3A: rounds 1 and 2 note the
|
||||
// mismatch as they build, but round 3 converges and builds nothing —
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { launchToolBudget, reverseAuditRoundCap } from './lib/budget.js';
|
||||
import {
|
||||
clearBudgetStop,
|
||||
claimRetirementDegradeNote,
|
||||
expectedAdmissionSeconds,
|
||||
readRoundStamps,
|
||||
reverseAuditBudgetExhausted,
|
||||
@@ -1429,13 +1430,14 @@ export function buildRoleBrief(
|
||||
'at 106s and `packages/cli` at 401s, before the rest). Work is left when ' +
|
||||
'`testScope.notRun` is non-empty, or when any `test[]` entry has ' +
|
||||
'`"clamped": true` — a suite the budget started too late and killed, which ' +
|
||||
'says nothing about the suite. A third shape carries no field at all: a ' +
|
||||
'says nothing about the suite. A third shape ends before any suite: a ' +
|
||||
'single-package repo whose budget ran out before its one suite has an ' +
|
||||
'empty `test[]` and no `testScope`, and only its `note` says so — read ' +
|
||||
'the note before calling the dimension finished. That shape cannot be ' +
|
||||
'continued (a continuation has no recorded scope to read, and answers ' +
|
||||
'"ended before its test phase" without running anything): report the ' +
|
||||
'dimension UNFINISHED and do not spend a continuation on it. A resumed ' +
|
||||
'empty `test[]`, no `testScope`, and `"endedBeforeTests": true` — the ' +
|
||||
"report's own stamp — with the note naming the unrun suite. That shape " +
|
||||
'cannot be continued (a continuation has no recorded scope to read; a ' +
|
||||
'`--resume` on it answers "ended before its test phase" and points at a ' +
|
||||
'fresh run): report the dimension UNFINISHED and do not spend a ' +
|
||||
'continuation on it. A resumed ' +
|
||||
'call skips install and build and ' +
|
||||
'runs only what is left, merging into the SAME report file. Same ' +
|
||||
`\`timeout: ${SHELL_TOOL_MAX_TIMEOUT_MS}\`, and at most ` +
|
||||
@@ -2172,6 +2174,61 @@ function noteUncertifiedChunks(planPath: string, diagnostics: string[]): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule read shared by the round builder and the per-chunk path
|
||||
* (#9272 — hand-rolled at both sites and edited in lockstep across three
|
||||
* consecutive PRs: the naming, the repair suppression, the deferral): a
|
||||
* throwing read degrades to "everything is due" — never to fewer
|
||||
* auditors — and composes the round's degrade NOTE, which the caller
|
||||
* prints only once the round is admitted (#9259: printed before the
|
||||
* gate, it promised an audit the gate then refused). `noteTail` names
|
||||
* the build's own scope.
|
||||
*/
|
||||
function reverseAuditScheduleOrNote(
|
||||
planPath: string,
|
||||
chunkIds: number[],
|
||||
round: number,
|
||||
env: NodeJS.ProcessEnv,
|
||||
diffPathAbsolute: unknown,
|
||||
noteTail: string,
|
||||
): { schedule: RoundSchedule | null; scheduleNote: string | null } {
|
||||
try {
|
||||
return {
|
||||
schedule: scheduleReverseAuditRound(
|
||||
planPath,
|
||||
chunkIds,
|
||||
round,
|
||||
env,
|
||||
typeof diffPathAbsolute === 'string' ? diffPathAbsolute : undefined,
|
||||
),
|
||||
scheduleNote: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
schedule: null,
|
||||
scheduleNote:
|
||||
`NOTE: reverse-audit retirement unavailable this round — ` +
|
||||
`${(err as Error).message ?? String(err)} — ${noteTail}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the round's deferred degrade NOTE exactly once per round per run
|
||||
* — the claim-plus-write glued at both build sites (#9272: a lockstep
|
||||
* duplicate of the claim condition or the writer channel would diverge
|
||||
* the two modes' diagnostics silently).
|
||||
*/
|
||||
function printRetirementDegradeNoteOnce(
|
||||
planPath: string,
|
||||
round: number | undefined,
|
||||
scheduleNote: string | null,
|
||||
): void {
|
||||
if (scheduleNote !== null && claimRetirementDegradeNote(planPath, round)) {
|
||||
writeStderrLineSafe(scheduleNote);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Topology anomaly note (#9242): the plan's own size fields decide the
|
||||
* topology (Step 3A whole-diff vs Step 3B territory fan-out), and the
|
||||
@@ -2227,6 +2284,11 @@ function runAllChunks(
|
||||
// three yielded in most: the loop earns its keep in the hot territories,
|
||||
// and the cold ones were a third of its bill.
|
||||
let schedule: RoundSchedule | null = null;
|
||||
// The catch NOTE is deferred until the round is ADMITTED (#9259): a
|
||||
// note printed before the budget/round-cap gate promises `auditing
|
||||
// every chunk.` on a round the gate then refuses — a false continuation
|
||||
// claim on the diagnostic channel this exists to keep truthful.
|
||||
let scheduleNote: string | null = null;
|
||||
// Retirement needs two consecutive dry audits, so nothing retires before
|
||||
// round 3 (the scheduler's own guard says the same).
|
||||
const retirementReadsFrom = 3;
|
||||
@@ -2235,29 +2297,16 @@ function runAllChunks(
|
||||
round !== undefined &&
|
||||
round >= retirementReadsFrom
|
||||
) {
|
||||
try {
|
||||
schedule = scheduleReverseAuditRound(
|
||||
planPath,
|
||||
chunks.map((c) => c.id),
|
||||
round,
|
||||
process.env,
|
||||
typeof report.diffPathAbsolute === 'string'
|
||||
? report.diffPathAbsolute
|
||||
: undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
// Transcripts unavailable, an unreadable plan stat, anything: the
|
||||
// schedule is an optimization, and a broken optimizer must degrade to
|
||||
// today's behaviour — every territory audited — never to fewer
|
||||
// auditors. `null` below means "everything is due". But not SILENTLY
|
||||
// (#9206): a schedule that dies here retires nothing for the rest of
|
||||
// the run, and the round's own output is where the reader can see it.
|
||||
schedule = null;
|
||||
writeStderrLineSafe(
|
||||
`NOTE: reverse-audit retirement unavailable this round — ` +
|
||||
`${(err as Error).message ?? String(err)} — auditing every chunk.`,
|
||||
);
|
||||
}
|
||||
const read = reverseAuditScheduleOrNote(
|
||||
planPath,
|
||||
chunks.map((c) => c.id),
|
||||
round,
|
||||
process.env,
|
||||
report.diffPathAbsolute,
|
||||
'auditing every chunk.',
|
||||
);
|
||||
schedule = read.schedule;
|
||||
scheduleNote = read.scheduleNote;
|
||||
}
|
||||
|
||||
if (schedule !== null && schedule.converged) {
|
||||
@@ -2295,6 +2344,11 @@ function runAllChunks(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The admission succeeded, so the round IS being built — now the
|
||||
// deferred catch NOTE tells the truth (#9259), claimed cross-process
|
||||
// so a dead-schedule round's per-chunk builds print it exactly once
|
||||
// (#9272).
|
||||
printRetirementDegradeNoteOnce(planPath, round, scheduleNote);
|
||||
|
||||
const dueSet = schedule === null ? null : new Set(schedule.due);
|
||||
const dueChunks =
|
||||
@@ -2772,33 +2826,27 @@ function runAgentPrompt(args: AgentPromptArgs): void {
|
||||
)
|
||||
.map((c) => c?.id)
|
||||
.filter((id): id is number => typeof id === 'number');
|
||||
// The catch NOTE is deferred past the admission gate (#9259 — a note
|
||||
// printed before it promises an audit the gate can then refuse) and
|
||||
// claimed cross-process via the record-dir sidecar, never keyed on
|
||||
// the stamp: the stamp lands on the admission build whether or not
|
||||
// that build's schedule read failed, so stamp-keyed suppression
|
||||
// silenced a round whose admission build read cleanly and whose
|
||||
// LATER builds began to throw — the never-retire shape with no word
|
||||
// (#9259). The sidecar is run-epoch fenced, so a retried headless
|
||||
// run re-prints — the safe side.
|
||||
let scheduleNote: string | null = null;
|
||||
if (args.round !== undefined) {
|
||||
let schedule: RoundSchedule | null = null;
|
||||
try {
|
||||
schedule = scheduleReverseAuditRound(
|
||||
args.plan,
|
||||
planChunkIds,
|
||||
args.round,
|
||||
process.env,
|
||||
typeof report.diffPathAbsolute === 'string'
|
||||
? report.diffPathAbsolute
|
||||
: undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
// Same degradation as the round builder: an unreadable history must
|
||||
// fall back to building the auditor, never to refusing it — named,
|
||||
// as the round builder names it (#9206). Named only on the builds
|
||||
// that are NOT repairs: the round's admission build (its first
|
||||
// chunk build, or the round builder itself) already spoke for it,
|
||||
// and a repair stays the clean rebuild its exemption promises.
|
||||
schedule = null;
|
||||
if (!roundAdmitted) {
|
||||
writeStderrLineSafe(
|
||||
`NOTE: reverse-audit retirement unavailable this round — ` +
|
||||
`${(err as Error).message ?? String(err)} — auditing the chunk.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const read = reverseAuditScheduleOrNote(
|
||||
args.plan,
|
||||
planChunkIds,
|
||||
args.round,
|
||||
process.env,
|
||||
report.diffPathAbsolute,
|
||||
'auditing the chunk.',
|
||||
);
|
||||
const schedule = read.schedule;
|
||||
scheduleNote = read.scheduleNote;
|
||||
if (!roundAdmitted && schedule !== null && schedule.converged) {
|
||||
refuseConverged(args.plan);
|
||||
return;
|
||||
@@ -2825,6 +2873,11 @@ function runAgentPrompt(args: AgentPromptArgs): void {
|
||||
)
|
||||
)
|
||||
return;
|
||||
// Admitted (or a stamped repair): the audit IS happening, so the
|
||||
// deferred NOTE tells the truth now (#9259) — once per round per
|
||||
// RUN, across the per-chunk processes, via the sidecar claim
|
||||
// (#9272).
|
||||
printRetirementDegradeNoteOnce(args.plan, args.round, scheduleNote);
|
||||
// The note belongs to the round's ADMISSION — a stamped rebuild
|
||||
// was ruled on when the round was admitted, so it stays silent.
|
||||
if (!roundAdmitted) {
|
||||
|
||||
@@ -449,6 +449,30 @@ describe('runBuildTest', () => {
|
||||
expect(rep.testScope?.caveat).toBeUndefined();
|
||||
expect(rep.note).toContain('no package to build');
|
||||
expect(rep.note).toContain('complete answer');
|
||||
// Not a probe: the stamp must not appear on an ordinary zero-affected run.
|
||||
expect(rep.buildOnly).toBeUndefined();
|
||||
|
||||
// The probe stamp rides the zero-affected return too — this producer
|
||||
// path branches on buildOnly for its note but used to drop the stamp,
|
||||
// so a resumed probe report lost its probe answer.
|
||||
const probe = runBuildTest({
|
||||
plan: planPath,
|
||||
worktree: root,
|
||||
timeout: 5,
|
||||
install: false,
|
||||
buildOnly: true,
|
||||
exec: (command) => {
|
||||
calls.push(command);
|
||||
return {
|
||||
command,
|
||||
exitCode: 0,
|
||||
seconds: 1,
|
||||
timedOut: false,
|
||||
output: '',
|
||||
};
|
||||
},
|
||||
});
|
||||
expect(probe.buildOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('runs nothing but discloses the caveat for out-of-workspace files that are not inert', () => {
|
||||
@@ -931,6 +955,12 @@ describe('runBuildTest', () => {
|
||||
// And the note must not claim tests it did not run.
|
||||
expect(buildOnly.note).toContain('build-only');
|
||||
expect(buildOnly.note).not.toContain('ran the tests');
|
||||
// The WRITE half of the probe stamp, on the main results-initializer
|
||||
// path: a probe report without it read as a completed zero-suite run on
|
||||
// --resume — the probe/completed misclassification the split exists to
|
||||
// prevent. The non-probe sibling must not carry it.
|
||||
expect(buildOnly.buildOnly).toBe(true);
|
||||
expect(withTests.buildOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it('scopes build AND tests to the changed workspace and its dependents', () => {
|
||||
@@ -1577,6 +1607,11 @@ describe('runBuildTest', () => {
|
||||
expect(rep.note).not.toContain('defines no test script');
|
||||
expect(rep.note).toContain('not run: .');
|
||||
expect(rep.ok).toBe(true);
|
||||
// The structural stamp: build green, no probe, no scope — without it the
|
||||
// resume split read this exact report as COMPLETED with no suite to run,
|
||||
// certified the one existing suite as finished, and dropped the fresh
|
||||
// re-run advice that is the only path to ever running it.
|
||||
expect(rep.endedBeforeTests).toBe(true);
|
||||
});
|
||||
|
||||
it('runs the AFFECTED workspace first, so the budget trims dependents, never the changed suite', () => {
|
||||
@@ -3204,6 +3239,114 @@ describe('runBuildTest', () => {
|
||||
expect(rep.note).not.toContain('reached every suite');
|
||||
});
|
||||
|
||||
it('a COMPLETED zero-suite run is not "ended before its test phase"', () => {
|
||||
// A single-root package with no test script finishes a fresh run
|
||||
// completely: test [], no scope, ok true. Calling that "ended before
|
||||
// its test phase" was self-contradictory beside the report's own note
|
||||
// ("defines no test script, so no tests ran"), and its re-run advice
|
||||
// re-derived the same zero-suite answer at the price of a full fresh
|
||||
// install+build. The split is structural — ok and the buildOnly stamp
|
||||
// — never the note's prose.
|
||||
threePackages();
|
||||
const outPath = join(root, 'report.json');
|
||||
const base = {
|
||||
toolchain: 'npm',
|
||||
run: runId(),
|
||||
affected: ['packages/core'],
|
||||
buildSet: ['packages/core'],
|
||||
widenedWith: [],
|
||||
install: okResult('npm ci --no-audit --no-fund'),
|
||||
build: [okResult('npm run build --workspace="packages/core"')],
|
||||
test: [],
|
||||
ok: true,
|
||||
timedOut: [],
|
||||
note: 'the package defines no test script, so no tests ran',
|
||||
};
|
||||
writeFileSync(outPath, JSON.stringify(base));
|
||||
const attempt = () =>
|
||||
runBuildTest({
|
||||
plan: planPath,
|
||||
worktree: root,
|
||||
out: outPath,
|
||||
timeout: 60,
|
||||
install: true,
|
||||
resume: true,
|
||||
exec: okResult,
|
||||
});
|
||||
const completed = attempt();
|
||||
expect(completed.note).toContain('completed with no suite to run');
|
||||
expect(completed.note).not.toContain('ended before its test phase');
|
||||
expect(completed.note).not.toContain('Re-run build-test');
|
||||
|
||||
// The deliberate probe keeps the early-end message: its report has no
|
||||
// tests and no scope BY CHOICE, and only the stamp tells it apart.
|
||||
writeFileSync(outPath, JSON.stringify({ ...base, buildOnly: true }));
|
||||
expect(attempt().note).toContain('ended before its test phase');
|
||||
|
||||
// And the budget-floor shape keeps it too — build green, ok true, no
|
||||
// scope, but the fresh path stamped that the test phase ran nothing.
|
||||
// This is the shape whose real suite the false-completion answer
|
||||
// certified as finished.
|
||||
writeFileSync(
|
||||
outPath,
|
||||
JSON.stringify({ ...base, endedBeforeTests: true }),
|
||||
);
|
||||
const floored = attempt();
|
||||
expect(floored.note).toContain('ended before its test phase');
|
||||
expect(floored.note).toContain('Re-run build-test without --resume');
|
||||
expect(floored.note).not.toContain('completed with no suite to run');
|
||||
});
|
||||
|
||||
it('a continuation that runs suites drops the stale endedBeforeTests stamp', () => {
|
||||
// The stamp is a phase-level claim — "the test phase ENTERED and ran
|
||||
// nothing" — and the merge must recompute it for the same staleness
|
||||
// reason it recomputes `ok`, `note`, and `caveat`: a continuation that
|
||||
// runs the starved suites falsifies it. Persisting the stale stamp
|
||||
// beside a non-empty test[] would assert "nothing ran" for a run that
|
||||
// ran — and the stamp exists so a reader never has to parse prose.
|
||||
threePackages();
|
||||
const outPath = join(root, 'report.json');
|
||||
writeFileSync(
|
||||
outPath,
|
||||
JSON.stringify({
|
||||
toolchain: 'npm',
|
||||
run: runId(),
|
||||
affected: ['packages/core'],
|
||||
buildSet: ['packages/core', 'packages/a', 'packages/b'],
|
||||
widenedWith: [],
|
||||
install: null,
|
||||
build: [okResult('npm run build --workspace="packages/core"')],
|
||||
test: [],
|
||||
ok: true,
|
||||
endedBeforeTests: true,
|
||||
timedOut: [],
|
||||
note:
|
||||
'the whole-call budget (16s) was spent with 3 suite(s) still ' +
|
||||
'to run — not run: packages/a, packages/b, packages/core',
|
||||
testScope: {
|
||||
workspaces: [],
|
||||
notRun: ['packages/a', 'packages/b', 'packages/core'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rep = runBuildTest({
|
||||
plan: planPath,
|
||||
worktree: root,
|
||||
out: outPath,
|
||||
timeout: 60,
|
||||
install: true,
|
||||
resume: true,
|
||||
exec: okResult,
|
||||
});
|
||||
|
||||
expect(rep.test).toHaveLength(3);
|
||||
expect(rep.ok).toBe(true);
|
||||
// Suites ran, so the stamp is now false and must not survive the merge.
|
||||
expect(rep.endedBeforeTests).toBeUndefined();
|
||||
expect(JSON.stringify(rep)).not.toContain('"endedBeforeTests"');
|
||||
});
|
||||
|
||||
it('keeps reporting work left when a retry is killed AGAIN by the budget', () => {
|
||||
// The ordinary outcome when an expensive suite is admitted late: it is
|
||||
// re-clamped rather than finished. Reporting that as a completed run
|
||||
@@ -3337,6 +3480,85 @@ describe('runBuildTest', () => {
|
||||
|
||||
expect(rep.note).not.toContain('Every suite in scope has now run');
|
||||
expect(rep.note).toContain('npm test --workspace="packages/a"');
|
||||
// Named ONCE. The unattempted retry rides both lists — its command is
|
||||
// still-to-run, its stale clamped entry survives in the merged test[]
|
||||
// — and two additive-looking clauses both naming it made one command
|
||||
// read as two against the continuation budget, with the provisional
|
||||
// clause claiming it was "killed on a deadline the budget shortened"
|
||||
// this call, false for a retry never started. The still-to-run clause
|
||||
// fully describes it; the provisional clause must not repeat it.
|
||||
expect(rep.note).toContain('still to run');
|
||||
expect(rep.note).not.toContain('still provisional');
|
||||
});
|
||||
|
||||
it('the caveat names BOTH halves when work is unattempted AND re-clamped', () => {
|
||||
// An else-if kept the provisional half out of the caveat whenever
|
||||
// outstanding work existed — and the brief quotes the caveat as the
|
||||
// live limitation, so a reader of it alone under-counted what is
|
||||
// left. The two segments stay disjoint: a re-clamped suite killed
|
||||
// again THIS call is provisional; an unreached workspace is still to
|
||||
// run.
|
||||
threePackages();
|
||||
const outPath = join(root, 'report.json');
|
||||
writeFileSync(
|
||||
outPath,
|
||||
JSON.stringify({
|
||||
toolchain: 'npm',
|
||||
run: runId(),
|
||||
affected: ['packages/core'],
|
||||
buildSet: ['packages/core', 'packages/b'],
|
||||
widenedWith: [],
|
||||
install: null,
|
||||
build: [okResult('npm run build --workspace="packages/core"')],
|
||||
test: [
|
||||
{
|
||||
command: 'npm test --workspace="packages/core"',
|
||||
exitCode: null,
|
||||
seconds: 100,
|
||||
timedOut: true,
|
||||
output: '',
|
||||
deadlineMs: 100_000,
|
||||
clamped: true,
|
||||
},
|
||||
],
|
||||
ok: false,
|
||||
timedOut: [],
|
||||
note: 'one clamped, one unreached',
|
||||
testScope: {
|
||||
workspaces: ['packages/core'],
|
||||
notRun: ['packages/b'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// The retry is admitted with a budget-shortened deadline and killed
|
||||
// again (exec burns ~6s and reports the timeout); what remains is
|
||||
// below the attempt floor, so packages/b is never started.
|
||||
const rep = runBuildTest({
|
||||
plan: planPath,
|
||||
worktree: root,
|
||||
out: outPath,
|
||||
timeout: 60,
|
||||
budget: 20,
|
||||
install: true,
|
||||
resume: true,
|
||||
exec: (command) => {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 6000);
|
||||
return {
|
||||
command,
|
||||
exitCode: null,
|
||||
seconds: 6,
|
||||
timedOut: true,
|
||||
output: '',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const caveat = rep.testScope?.caveat ?? '';
|
||||
expect(caveat).toContain('still to run');
|
||||
expect(caveat).toContain('packages/b');
|
||||
expect(caveat).toContain('provisional');
|
||||
expect(caveat).toContain('npm test --workspace="packages/core"');
|
||||
});
|
||||
|
||||
it("runs the AFFECTED pending suite first — the fresh path's invariant", () => {
|
||||
@@ -3713,6 +3935,59 @@ describe('runBuildTest', () => {
|
||||
{ toolchain: 'npm', test: [] },
|
||||
{ toolchain: 'npm', test: [], build: [] },
|
||||
{ toolchain: 'npm', test: [], timedOut: [] },
|
||||
// The `test` clause needs its own UNMASKED witness: a fixture that
|
||||
// also omitted `affected` and `ok` was refused by their clauses
|
||||
// whatever happened to the `test` clause, so deleting
|
||||
// `!commandsOk(shape.test)` kept every refusal green while a report
|
||||
// truncated of only its `test` key cleared the mutated gate and
|
||||
// died at `previous.test.filter` — the raw crash the gate exists to
|
||||
// replace. Every other walked field is present and valid here, so
|
||||
// the refusal rides on the `test` clause alone.
|
||||
{
|
||||
toolchain: 'npm',
|
||||
affected: ['packages/core'],
|
||||
ok: true,
|
||||
build: [],
|
||||
timedOut: [],
|
||||
},
|
||||
// The two newest clauses need their own witnesses too: a corrupted
|
||||
// stamp must refuse here, not steer the nothing-to-resume message
|
||||
// off a non-boolean truthiness ('"buildOnly": "yes"' or
|
||||
// '"endedBeforeTests": "yes"' fails `=== true` and would read as a
|
||||
// completed zero-suite run).
|
||||
{
|
||||
toolchain: 'npm',
|
||||
affected: ['packages/core'],
|
||||
ok: true,
|
||||
buildOnly: 'yes',
|
||||
test: [],
|
||||
build: [],
|
||||
timedOut: [],
|
||||
},
|
||||
{
|
||||
toolchain: 'npm',
|
||||
affected: ['packages/core'],
|
||||
ok: true,
|
||||
endedBeforeTests: 'yes',
|
||||
test: [],
|
||||
build: [],
|
||||
timedOut: [],
|
||||
},
|
||||
{
|
||||
toolchain: 'npm',
|
||||
affected: ['packages/core'],
|
||||
ok: 'false',
|
||||
test: [],
|
||||
build: [],
|
||||
timedOut: [],
|
||||
},
|
||||
{
|
||||
toolchain: 'npm',
|
||||
affected: ['packages/core'],
|
||||
test: [],
|
||||
build: [],
|
||||
timedOut: [],
|
||||
},
|
||||
]) {
|
||||
writeFileSync(outPath, JSON.stringify(partial));
|
||||
expect(() =>
|
||||
|
||||
@@ -128,6 +128,24 @@ export interface BuildTestReport {
|
||||
install: CommandResult | null;
|
||||
build: CommandResult[];
|
||||
test: CommandResult[];
|
||||
/**
|
||||
* True when the run was a deliberate `--build-only` probe. Structural,
|
||||
* because `--resume`'s nothing-to-resume answer keys on it: a probe's
|
||||
* report has no tests and no scope BY CHOICE, and without the stamp that
|
||||
* shape is indistinguishable from a completed zero-suite run.
|
||||
*/
|
||||
buildOnly?: boolean;
|
||||
/**
|
||||
* True when the test phase was ENTERED and ran nothing — the whole-call
|
||||
* budget fell below the attempt floor (or the unbuilt closure covered
|
||||
* every suite) before the first test command started. Structural for the
|
||||
* same reason `buildOnly` is: a single-root run in this state carries no
|
||||
* `testScope` and keeps `ok: true` (the build passed), so without the
|
||||
* stamp `--resume` read it as a COMPLETED zero-suite run — certifying an
|
||||
* existing, unrun suite as finished and dropping the re-run advice that
|
||||
* is the only path to ever running it.
|
||||
*/
|
||||
endedBeforeTests?: boolean;
|
||||
/**
|
||||
* What the test phase covered, so the review can state exactly what was and
|
||||
* was not run: `workspaces` lists exactly the suites the run executes, and
|
||||
@@ -528,6 +546,20 @@ function previousReport(out: string | undefined): BuildTestReport {
|
||||
const strings = (v: unknown): boolean =>
|
||||
Array.isArray(v) && v.every((e) => typeof e === 'string' && e.length > 0);
|
||||
const affectedOk = strings((parsed as { affected?: unknown }).affected);
|
||||
// Read by the nothing-to-resume split (deliberate probe vs completed
|
||||
// zero-suite run) — validated like every other field the continuation
|
||||
// reads, so a corrupted stamp refuses here instead of steering the
|
||||
// message off a non-boolean truthiness.
|
||||
const buildOnlyShape = (parsed as { buildOnly?: unknown }).buildOnly;
|
||||
const buildOnlyOk =
|
||||
buildOnlyShape === undefined || typeof buildOnlyShape === 'boolean';
|
||||
const endedBeforeShape = (parsed as { endedBeforeTests?: unknown })
|
||||
.endedBeforeTests;
|
||||
const endedBeforeOk =
|
||||
endedBeforeShape === undefined || typeof endedBeforeShape === 'boolean';
|
||||
// Same rule for `ok`, which the split reads beside it: required on the
|
||||
// report, so undefined is refused too.
|
||||
const okOk = typeof (parsed as { ok?: unknown }).ok === 'boolean';
|
||||
const notBuiltShape = (parsed as { notBuilt?: unknown }).notBuilt;
|
||||
const notBuiltOk = notBuiltShape === undefined || strings(notBuiltShape);
|
||||
const scope = shape.testScope;
|
||||
@@ -547,6 +579,9 @@ function previousReport(out: string | undefined): BuildTestReport {
|
||||
!commandsOk(shape.build) ||
|
||||
!strings(shape.timedOut) ||
|
||||
!affectedOk ||
|
||||
!buildOnlyOk ||
|
||||
!endedBeforeOk ||
|
||||
!okOk ||
|
||||
!notBuiltOk ||
|
||||
!scopeOk ||
|
||||
!runOk
|
||||
|
||||
@@ -797,6 +797,71 @@ describe('budget-gap disclosures — guarded, parsed, never punished', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('a whole-diff disclosure is silenced only by a compliant gap-free relaunch', () => {
|
||||
// `gapsSuperseded`'s whole-diff branch: the superseding record must have
|
||||
// OPENED the key's brief and be gap-free itself. Neither conjunct was
|
||||
// reached by any test — a revert of the branch shipped green.
|
||||
const p = plan3a();
|
||||
const d = promptRecordDir(p);
|
||||
mkdirSync(d, { recursive: true });
|
||||
const brief = briefPath(p, 'audit-w');
|
||||
writeFileSync(brief, 'The audit-w brief.');
|
||||
const prompt =
|
||||
'You are review agent `audit-w`.\n' +
|
||||
`read_file(file_path="${brief}")\n` +
|
||||
wholeDiff();
|
||||
writeFileSync(join(d, 'audit-w.txt'), prompt);
|
||||
transcript('g1', prompt, {
|
||||
calls: 3,
|
||||
text: 'Walked the diff.\nBudget gap: the reconnect state machine',
|
||||
});
|
||||
// A gap-free relaunch that opened the brief silences the disclosure.
|
||||
transcript('g2', prompt, { calls: 3 });
|
||||
expect(coverageFromTranscripts(p, ENV).budgetGaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('a relaunch that never opened the brief cannot silence the disclosure', () => {
|
||||
const p = plan3a();
|
||||
const d = promptRecordDir(p);
|
||||
mkdirSync(d, { recursive: true });
|
||||
const brief = briefPath(p, 'audit-w');
|
||||
writeFileSync(brief, 'The audit-w brief.');
|
||||
const prompt =
|
||||
'You are review agent `audit-w`.\n' +
|
||||
`read_file(file_path="${brief}")\n` +
|
||||
wholeDiff();
|
||||
writeFileSync(join(d, 'audit-w.txt'), prompt);
|
||||
transcript('g1', prompt, {
|
||||
calls: 3,
|
||||
text: 'Walked the diff.\nBudget gap: the reconnect state machine',
|
||||
});
|
||||
transcript('g2', prompt, { calls: 3, opens: [] });
|
||||
expect(coverageFromTranscripts(p, ENV).budgetGaps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a relaunch still disclosing gaps of its own cannot silence anything', () => {
|
||||
const p = plan3a();
|
||||
const d = promptRecordDir(p);
|
||||
mkdirSync(d, { recursive: true });
|
||||
const brief = briefPath(p, 'audit-w');
|
||||
writeFileSync(brief, 'The audit-w brief.');
|
||||
const prompt =
|
||||
'You are review agent `audit-w`.\n' +
|
||||
`read_file(file_path="${brief}")\n` +
|
||||
wholeDiff();
|
||||
writeFileSync(join(d, 'audit-w.txt'), prompt);
|
||||
transcript('g1', prompt, {
|
||||
calls: 3,
|
||||
text: 'Walked the diff.\nBudget gap: the reconnect state machine',
|
||||
});
|
||||
transcript('g2', prompt, {
|
||||
calls: 3,
|
||||
text: 'Walked again.\nBudget gap: the remaining call sites',
|
||||
});
|
||||
// Two live disclosures, neither silenced by the other.
|
||||
expect(coverageFromTranscripts(p, ENV).budgetGaps).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('a disclosure costs no coverage credit — the gate must not punish it', () => {
|
||||
// An earlier draft narrowed a disclosing agent's credit to its ranged
|
||||
// reads. `rangeOf` records only reads carrying a positive `limit`, so
|
||||
@@ -2291,6 +2356,7 @@ describe('coverage — a resumed run credits the prior attempt through the ledge
|
||||
// The run ledger `fetch-pr` writes: S0 is the interrupted attempt, S1 the
|
||||
// resumed continuation this suite's ENV runs as. Entries carry a current
|
||||
// atMs, which sits inside the epoch fence of the backdated plan.
|
||||
let ledgerNowMs = 0;
|
||||
function ledger(planPath: string, ...ids: string[]): void {
|
||||
const d = promptRecordDir(planPath);
|
||||
mkdirSync(d, { recursive: true });
|
||||
@@ -2299,6 +2365,7 @@ describe('coverage — a resumed run credits the prior attempt through the ledge
|
||||
// evidence at all. The current attempt is stamped last, since each
|
||||
// attempt's window closes when the next one opened.
|
||||
const nowMs = Date.now();
|
||||
ledgerNowMs = nowMs;
|
||||
ids.forEach((id, i) =>
|
||||
appendRunSession(
|
||||
planPath,
|
||||
@@ -2325,6 +2392,10 @@ describe('coverage — a resumed run credits the prior attempt through the ledge
|
||||
),
|
||||
);
|
||||
rmSync(from, { force: true });
|
||||
if (ledgerNowMs > 0) {
|
||||
const at = new Date(ledgerNowMs);
|
||||
utimesSync(to, at, at);
|
||||
}
|
||||
}
|
||||
|
||||
it('passes 3D on work the interrupted attempt completed, and discloses it', () => {
|
||||
@@ -2391,19 +2462,26 @@ describe('verificationGaps — a resumed run reads the prior attempt', () => {
|
||||
function moveToSession(id: string, session: string): void {
|
||||
mkdirSync(join(dir, 'subagents', session), { recursive: true });
|
||||
const from = join(dir, 'subagents', 'S1', `agent-${id}.jsonl`);
|
||||
const to = join(dir, 'subagents', session, `agent-${id}.jsonl`);
|
||||
writeFileSync(
|
||||
join(dir, 'subagents', session, `agent-${id}.jsonl`),
|
||||
to,
|
||||
readFileSync(from, 'utf8').replaceAll(
|
||||
'"sessionId":"S1"',
|
||||
`"sessionId":"${session}"`,
|
||||
),
|
||||
);
|
||||
rmSync(from, { force: true });
|
||||
if (ledgerNowMs > 0) {
|
||||
const at = new Date(ledgerNowMs);
|
||||
utimesSync(to, at, at);
|
||||
}
|
||||
}
|
||||
|
||||
/** The ledger `fetch-pr` writes, through the real writers. */
|
||||
let ledgerNowMs = 0;
|
||||
function ledger(planPath: string, ...ids: string[]): void {
|
||||
const nowMs = Date.now();
|
||||
ledgerNowMs = nowMs;
|
||||
ids.forEach((id, i) =>
|
||||
appendRunSession(
|
||||
planPath,
|
||||
@@ -2518,6 +2596,7 @@ describe('verificationGaps — a resumed run reads the prior attempt', () => {
|
||||
});
|
||||
|
||||
describe('coverage — a stale Uncoverable declaration cannot cap live coverage', () => {
|
||||
let ledgerNowMs = 0;
|
||||
function ledger(planPath: string, ...ids: string[]): void {
|
||||
const d = promptRecordDir(planPath);
|
||||
mkdirSync(d, { recursive: true });
|
||||
@@ -2526,6 +2605,7 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage'
|
||||
// evidence at all. The current attempt is stamped last, since each
|
||||
// attempt's window closes when the next one opened.
|
||||
const nowMs = Date.now();
|
||||
ledgerNowMs = nowMs;
|
||||
ids.forEach((id, i) =>
|
||||
appendRunSession(
|
||||
planPath,
|
||||
@@ -2551,6 +2631,10 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage'
|
||||
),
|
||||
);
|
||||
rmSync(from, { force: true });
|
||||
if (ledgerNowMs > 0) {
|
||||
const at = new Date(ledgerNowMs);
|
||||
utimesSync(to, at, at);
|
||||
}
|
||||
}
|
||||
|
||||
it('a superseded prior-attempt declaration does not delete the chunk it covers', () => {
|
||||
@@ -2630,6 +2714,33 @@ describe('coverage — a stale Uncoverable declaration cannot cap live coverage'
|
||||
expect(r.recoveredAgents).toBe(0);
|
||||
});
|
||||
|
||||
it('a whole-diff recovery is superseded only by a relaunch that opened the brief', () => {
|
||||
// `keySatisfied` — the chunk-less arm of the supersession predicates —
|
||||
// was reached by no test: its brief requirement could be deleted (or
|
||||
// left dangling) with the suite green. The deciding conjunct is the
|
||||
// relaunch's brief read, so both arms pin it.
|
||||
const p = plan();
|
||||
ledger(p, 'S0', 'S1');
|
||||
const d = promptRecordDir(p);
|
||||
mkdirSync(d, { recursive: true });
|
||||
const brief = briefPath(p, 'audit-w');
|
||||
writeFileSync(brief, 'The audit-w brief.');
|
||||
const prompt =
|
||||
'You are review agent `audit-w`.\n' +
|
||||
`read_file(file_path="${brief}")\n` +
|
||||
wholeDiff();
|
||||
writeFileSync(join(d, 'audit-w.txt'), prompt);
|
||||
transcript('w1', prompt, { calls: 3 });
|
||||
moveToSession('w1', 'S0');
|
||||
// The current relaunch never opened its brief: no supersession, the
|
||||
// prior work still counts as recovered.
|
||||
transcript('w2', prompt, { calls: 3, opens: [] });
|
||||
expect(coverageFromTranscripts(p, ENV).recoveredAgents).toBe(1);
|
||||
// A compliant relaunch supersedes it.
|
||||
transcript('w3', prompt, { calls: 3 });
|
||||
expect(coverageFromTranscripts(p, ENV).recoveredAgents).toBe(0);
|
||||
});
|
||||
|
||||
it('does NOT credit a prior agent whose text is progress, not a return', () => {
|
||||
// `finalText` keeps the last non-empty assistant text, and agents narrate
|
||||
// between tool calls — so an agent that said "reading the diff now" and
|
||||
|
||||
@@ -6,13 +6,20 @@ import { join } from 'node:path';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execFileSync: vi.fn(),
|
||||
existsSync: vi.fn(() => false),
|
||||
existsSync: vi.fn((_path: string): boolean => false),
|
||||
// The return type is declared so `mockReturnValue` can take string arrays —
|
||||
// the sweep-retention tests hand it the tmp-dir listing.
|
||||
readdirSync: vi.fn((): string[] => []),
|
||||
readdirSync: vi.fn((_path: string): string[] => []),
|
||||
readFileSync: vi.fn((_path: string): string => {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
}),
|
||||
// statSync drives retention's mtime signal (runEpochMs + the per-entry
|
||||
// comparison); unmocked it hit the REAL filesystem and the signal could
|
||||
// only ever fail open here (#9259). The default is the same fail-open
|
||||
// throw readFileSync carries.
|
||||
statSync: vi.fn((_path: string): { mtimeMs: number } => {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
}),
|
||||
rmSync: vi.fn(),
|
||||
writeStdoutLine: vi.fn(),
|
||||
writeStderrLine: vi.fn(),
|
||||
@@ -51,11 +58,13 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
existsSync: mocks.existsSync,
|
||||
readdirSync: mocks.readdirSync,
|
||||
readFileSync: mocks.readFileSync,
|
||||
statSync: mocks.statSync,
|
||||
rmSync: mocks.rmSync,
|
||||
},
|
||||
existsSync: mocks.existsSync,
|
||||
readdirSync: mocks.readdirSync,
|
||||
readFileSync: mocks.readFileSync,
|
||||
statSync: mocks.statSync,
|
||||
rmSync: mocks.rmSync,
|
||||
};
|
||||
});
|
||||
@@ -111,6 +120,22 @@ describe('runCleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.existsSync.mockReturnValue(false);
|
||||
// Implementations survive clearAllMocks — restore the fail-open throw
|
||||
// so one retention test's mtimes cannot leak into the next test. The
|
||||
// readFileSync default is the same story (#9272): a leaked
|
||||
// marker-returning implementation short-circuits the retention `||`
|
||||
// on the marker signal, and the mtime/plan-missing branches under
|
||||
// test never even evaluate.
|
||||
mocks.statSync.mockImplementation(() => {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
mocks.readFileSync.mockImplementation(() => {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
// Same leak class for the listing (#9272): the retention tests install
|
||||
// path-dependent implementations, and a later test reading the declared
|
||||
// `[]` default would otherwise inherit them.
|
||||
mocks.readdirSync.mockImplementation((_path: string): string[] => []);
|
||||
mocks.refExists.mockReturnValue(true);
|
||||
mocks.releaseWorktree.mockReturnValue({
|
||||
existed: false,
|
||||
@@ -451,6 +476,110 @@ describe('runCleanup', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the record directory whose records predate the plan — a killed loop leaves no marker (#9206)', () => {
|
||||
// Signal 2: a loop KILLED mid-round stops without converging and
|
||||
// writes no marker; its records predate the retry's fresh plan
|
||||
// capture. The mtime comparison is what keeps that history — pinned
|
||||
// here against an inverted `<` or a slack/sign slip (#9259).
|
||||
mocks.execFileSync.mockReturnValue(Buffer.from(''));
|
||||
mocks.existsSync.mockReturnValue(true);
|
||||
mocks.readdirSync.mockImplementation((p: string): string[] =>
|
||||
p === '/repo/.qwen/tmp'
|
||||
? ['qwen-review-pr-123-fetch.json', 'qwen-review-pr-123-fetch-prompts']
|
||||
: ['reverse-audit--chunk-13--round-1--abc.txt'],
|
||||
);
|
||||
// No marker — the readFileSync default throws for budget-stop.json.
|
||||
const planNow = Date.now();
|
||||
mocks.statSync.mockImplementation((p: string) => ({
|
||||
mtimeMs: p.endsWith('.json') ? planNow : Date.parse('2020-01-01'),
|
||||
}));
|
||||
|
||||
runCleanup('pr-123');
|
||||
|
||||
expect(mocks.rmSync).not.toHaveBeenCalledWith(
|
||||
'/repo/.qwen/tmp/qwen-review-pr-123-fetch-prompts',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Kept /repo/.qwen/tmp/qwen-review-pr-123-fetch-prompts',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the record directory whose plan is already gone — a second cleanup keeps what the first kept (#9213)', () => {
|
||||
// Signal 3: the first cleanup preserved the directory and swept the
|
||||
// plan beside it, so no marker read and no mtime comparison can run.
|
||||
// The directory that survived on that evidence must survive again —
|
||||
// and "Nothing to clean" must NOT print while something was kept.
|
||||
mocks.execFileSync.mockReturnValue(Buffer.from(''));
|
||||
mocks.existsSync.mockImplementation((p: string) => p === '/repo/.qwen/tmp');
|
||||
mocks.readdirSync.mockReturnValue(['qwen-review-pr-123-fetch-prompts']);
|
||||
|
||||
runCleanup('pr-123');
|
||||
|
||||
expect(mocks.rmSync).not.toHaveBeenCalledWith(
|
||||
'/repo/.qwen/tmp/qwen-review-pr-123-fetch-prompts',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Kept /repo/.qwen/tmp/qwen-review-pr-123-fetch-prompts',
|
||||
),
|
||||
);
|
||||
expect(mocks.writeStdoutLine).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Nothing to clean'),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the record directory on a PREVIOUS run’s marker — retention reads unfenced (#9213)', () => {
|
||||
// The fence drops a marker older than the plan capture — exactly the
|
||||
// marker a killed run left behind. Retention reading through the
|
||||
// fenced `readBudgetStop` would sweep the evidence #9206 reports;
|
||||
// this pins the unfenced read against that swap.
|
||||
mocks.execFileSync.mockReturnValue(Buffer.from(''));
|
||||
mocks.existsSync.mockReturnValue(true);
|
||||
mocks.readdirSync.mockImplementation((p: string): string[] =>
|
||||
p === '/repo/.qwen/tmp'
|
||||
? ['qwen-review-pr-123-fetch.json', 'qwen-review-pr-123-fetch-prompts']
|
||||
: [],
|
||||
);
|
||||
const planNow = Date.now();
|
||||
mocks.statSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith('.json')) return { mtimeMs: planNow };
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
mocks.readFileSync.mockImplementation((path: string): string => {
|
||||
if (path.endsWith('budget-stop.json')) {
|
||||
// A stop from HOURS before the plan capture — the fenced reader
|
||||
// (deadline.ts's own tests pin this) returns null for it.
|
||||
return JSON.stringify({
|
||||
cause: 'round-cap',
|
||||
cap: 5,
|
||||
entry: 'reverse audit — did not converge within the 5-round cap',
|
||||
entryZh: '反向审计——在 5 轮的反审轮数上限内未收敛',
|
||||
round: 6,
|
||||
remainingSeconds: 0,
|
||||
reserveSeconds: 0,
|
||||
atMs: Date.parse('2020-01-01'),
|
||||
});
|
||||
}
|
||||
return JSON.stringify({});
|
||||
});
|
||||
|
||||
runCleanup('pr-123');
|
||||
|
||||
expect(mocks.rmSync).not.toHaveBeenCalledWith(
|
||||
'/repo/.qwen/tmp/qwen-review-pr-123-fetch-prompts',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mocks.writeStdoutLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Kept /repo/.qwen/tmp/qwen-review-pr-123-fetch-prompts',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('still sweeps the record directory once the loop converged (#9206)', () => {
|
||||
// A converged run cleared its marker (`refuseConverged` removes it): the
|
||||
// certification history earned nothing, and the sweep takes it like any
|
||||
|
||||
@@ -35,6 +35,8 @@ interface CommentBodyArgs {
|
||||
repo: string;
|
||||
prNumber?: number;
|
||||
out?: string;
|
||||
/** The `--host` flag, fed to platform detection (an Aone host selects a1). */
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export function runCommentBody(args: CommentBodyArgs): {
|
||||
@@ -61,7 +63,16 @@ export function runCommentBody(args: CommentBodyArgs): {
|
||||
if (args.out !== undefined) {
|
||||
assertWritableOutPath(args.out);
|
||||
}
|
||||
const platform = getPlatformReader();
|
||||
const platform = getPlatformReader({ host: args.host });
|
||||
// Aone addresses comment bodies per-MR for EVERY kind — enforce it before
|
||||
// the auth gate (this file's rule: usage errors precede auth; `a1 auth
|
||||
// login` can never fix a missing --pr). The GitHub `kind === 'review'`
|
||||
// guard above gets the same pre-auth treatment.
|
||||
if (platform.kind === 'aone' && args.prNumber === undefined) {
|
||||
throw new TypeError(
|
||||
'aone comment bodies are addressed per-MR — pass `--pr <mr id>`',
|
||||
);
|
||||
}
|
||||
platform.ensureAuthenticated();
|
||||
const body = platform.getCommentBody(
|
||||
args.kind,
|
||||
@@ -99,7 +110,8 @@ export const commentBodyCommand: CommandModule = {
|
||||
})
|
||||
.option('pr', {
|
||||
type: 'number',
|
||||
describe: 'The PR number — required with --kind review',
|
||||
describe:
|
||||
'The PR number — required with --kind review (GitHub), and with every kind on Aone (comment bodies are addressed per-MR)',
|
||||
})
|
||||
.option('repo', {
|
||||
type: 'string',
|
||||
@@ -109,7 +121,7 @@ export const commentBodyCommand: CommandModule = {
|
||||
.option('host', {
|
||||
type: 'string',
|
||||
describe:
|
||||
'The PR host (GitHub Enterprise). Omitted: inherit GH_HOST, else github.com.',
|
||||
"The host the target lives on. An Aone host (*.alibaba-inc.com) selects the a1 backend; omitted: detected from the clone's origin, else GitHub (GH_HOST, then github.com).",
|
||||
})
|
||||
.option('out', {
|
||||
type: 'string',
|
||||
@@ -158,6 +170,7 @@ export const commentBodyCommand: CommandModule = {
|
||||
repo: String(argv['repo']),
|
||||
prNumber: pr,
|
||||
out: (argv as { out?: string }).out,
|
||||
host,
|
||||
});
|
||||
if (result.outPath !== undefined) {
|
||||
writeStdoutLine(
|
||||
|
||||
@@ -4,10 +4,69 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const {
|
||||
csGhMock,
|
||||
csGhApiAllMock,
|
||||
csCurrentUserMock,
|
||||
csEnsureAuthenticatedMock,
|
||||
csSetGhHostMock,
|
||||
csGitOptMock,
|
||||
csWorktreePathMock,
|
||||
csWriteFileSyncMock,
|
||||
csMkdirSyncMock,
|
||||
csStdoutMock,
|
||||
} = vi.hoisted(() => ({
|
||||
csGhMock: vi.fn(),
|
||||
csGhApiAllMock: vi.fn(),
|
||||
csCurrentUserMock: vi.fn(),
|
||||
csEnsureAuthenticatedMock: vi.fn(),
|
||||
csSetGhHostMock: vi.fn(),
|
||||
csGitOptMock: vi.fn(),
|
||||
csWorktreePathMock: vi.fn(),
|
||||
csWriteFileSyncMock: vi.fn(),
|
||||
csMkdirSyncMock: vi.fn(),
|
||||
csStdoutMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./lib/gh.js', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
gh: csGhMock,
|
||||
ghApiAll: csGhApiAllMock,
|
||||
currentUser: csCurrentUserMock,
|
||||
ensureAuthenticated: csEnsureAuthenticatedMock,
|
||||
setGhHost: csSetGhHostMock,
|
||||
};
|
||||
});
|
||||
vi.mock('./lib/git.js', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, gitOpt: csGitOptMock };
|
||||
});
|
||||
vi.mock('./lib/paths.js', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, worktreePath: csWorktreePathMock };
|
||||
});
|
||||
vi.mock('../../utils/stdioHelpers.js', () => ({
|
||||
writeStdoutLine: csStdoutMock,
|
||||
writeStderrLine: csStdoutMock,
|
||||
}));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
const mock = {
|
||||
...actual,
|
||||
mkdirSync: csMkdirSyncMock,
|
||||
writeFileSync: csWriteFileSyncMock,
|
||||
};
|
||||
return { ...mock, default: mock };
|
||||
});
|
||||
|
||||
import {
|
||||
buildThreadStatuses,
|
||||
summarizeThreads,
|
||||
commentStatusCommand,
|
||||
type CodeChangeProbe,
|
||||
type RawStatusComment,
|
||||
} from './comment-status.js';
|
||||
@@ -58,6 +117,59 @@ describe('buildThreadStatuses — thread grouping', () => {
|
||||
expect(threads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('marks the attribution-off posted shape as a blocker, same as pr-context', () => {
|
||||
// Parity with the context file's re-check section: a Critical posted
|
||||
// without attribution carries its severity only in the invisible
|
||||
// marker, and only the reviewing account's markers count.
|
||||
const [own] = buildThreadStatuses(
|
||||
[
|
||||
comment({
|
||||
id: 1,
|
||||
user: { login: 'qwen-code-ci-bot' },
|
||||
body: 'the guard checks the wrong variable\n\n<!-- qwen-review critical -->',
|
||||
}),
|
||||
],
|
||||
'author',
|
||||
noChange,
|
||||
'qwen-code-ci-bot',
|
||||
);
|
||||
expect(own.isBlocker).toBe(true);
|
||||
|
||||
const [planted] = buildThreadStatuses(
|
||||
[
|
||||
comment({
|
||||
id: 1,
|
||||
user: { login: 'someone-else' },
|
||||
body: '<!-- qwen-review critical -->',
|
||||
}),
|
||||
],
|
||||
'author',
|
||||
noChange,
|
||||
'qwen-code-ci-bot',
|
||||
);
|
||||
expect(planted.isBlocker).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on an unresolved identity — a matching author is not enough', () => {
|
||||
// The marker disjunct must never fire with an empty `me` — exactly
|
||||
// the state a failed identity lookup used to swallow silently, where a
|
||||
// planted marker from a ghost or deleted author would otherwise
|
||||
// promote to a blocker.
|
||||
const [t] = buildThreadStatuses(
|
||||
[
|
||||
comment({
|
||||
id: 1,
|
||||
user: { login: 'qwen-code-ci-bot' },
|
||||
body: 'the guard checks the wrong variable\n\n<!-- qwen-review critical -->',
|
||||
}),
|
||||
],
|
||||
'author',
|
||||
noChange,
|
||||
'',
|
||||
);
|
||||
expect(t.isBlocker).toBe(false);
|
||||
});
|
||||
|
||||
it('survives a reply cycle without hanging', () => {
|
||||
const threads = buildThreadStatuses(
|
||||
[
|
||||
@@ -228,3 +340,88 @@ describe('summarizeThreads', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('commentStatusCommand handler — identity fail-closed', () => {
|
||||
// The same gate pr-context applies, probed at the handler level: both
|
||||
// unknown-identity shapes — a thrown lookup AND an empty login — must
|
||||
// degrade the report to an \`error\` a consumer sees, never a
|
||||
// complete-looking index that silently undercounts blockers.
|
||||
const MARKER_COMMENT = {
|
||||
id: 1,
|
||||
user: { login: 'review-bot' },
|
||||
path: 'a.ts',
|
||||
line: 3,
|
||||
body: 'the guard checks the wrong variable\n\n<!-- qwen-review critical -->',
|
||||
commit_id: 'headsha',
|
||||
original_commit_id: 'headsha',
|
||||
};
|
||||
|
||||
const runHandler = (): Promise<void> =>
|
||||
Promise.resolve(
|
||||
commentStatusCommand.handler({
|
||||
pr_number: '42',
|
||||
owner_repo: 'o/r',
|
||||
out: '/tmp/comment-status/report.json',
|
||||
} as never) as void,
|
||||
);
|
||||
|
||||
const writtenReport = () =>
|
||||
JSON.parse(csWriteFileSyncMock.mock.calls[0]?.[1] as string) as {
|
||||
error?: string;
|
||||
summary?: { blockers: number };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
csGhMock.mockClear();
|
||||
csGhMock.mockReturnValue(
|
||||
JSON.stringify({ author: { login: 'author' }, headRefOid: 'headsha' }),
|
||||
);
|
||||
csGhApiAllMock.mockClear();
|
||||
csGhApiAllMock.mockReturnValue([MARKER_COMMENT]);
|
||||
csCurrentUserMock.mockClear();
|
||||
csCurrentUserMock.mockReturnValue('review-bot');
|
||||
csGitOptMock.mockClear();
|
||||
csGitOptMock.mockReturnValue(null);
|
||||
csWorktreePathMock.mockClear();
|
||||
csWorktreePathMock.mockReturnValue('/tmp/no-such-worktree');
|
||||
csWriteFileSyncMock.mockClear();
|
||||
csStdoutMock.mockClear();
|
||||
});
|
||||
|
||||
it('refuses the report when the login is EMPTY while a critical marker is posted', async () => {
|
||||
// Exit-0-with-empty-output is a stubbed or proxied gh, not a
|
||||
// confirmed identity: with `me = ''` the marker disjunct never fires
|
||||
// and the blocker index undercounts while reading as complete.
|
||||
csCurrentUserMock.mockReturnValue('');
|
||||
await runHandler();
|
||||
expect(writtenReport().error).toMatch(
|
||||
/cannot determine the reviewing account/,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses the report when the lookup throws while a critical marker is posted', async () => {
|
||||
csCurrentUserMock.mockImplementation(() => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
await runHandler();
|
||||
expect(writtenReport().error).toMatch(
|
||||
/cannot determine the reviewing account/,
|
||||
);
|
||||
});
|
||||
|
||||
it('proceeds best-effort when the login is empty and no marker is posted', async () => {
|
||||
csGhApiAllMock.mockReturnValue([
|
||||
{ ...MARKER_COMMENT, body: 'plain prose' },
|
||||
]);
|
||||
csCurrentUserMock.mockReturnValue('');
|
||||
await runHandler();
|
||||
expect(writtenReport().error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('counts the marker as a blocker when identity resolves', async () => {
|
||||
await runHandler();
|
||||
const report = writtenReport();
|
||||
expect(report.error).toBeUndefined();
|
||||
expect(report.summary?.blockers).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,20 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD } from '@qwen-code/qwen-code-core';
|
||||
import { writeStdoutLine } from '../../utils/stdioHelpers.js';
|
||||
import { ensureAuthenticated, gh, ghApiAll, setGhHost } from './lib/gh.js';
|
||||
import {
|
||||
currentUser,
|
||||
ensureAuthenticated,
|
||||
gh,
|
||||
ghApiAll,
|
||||
setGhHost,
|
||||
} from './lib/gh.js';
|
||||
import { gitOpt } from './lib/git.js';
|
||||
import { worktreePath } from './lib/paths.js';
|
||||
import { carriesBlockerSignal, findRootId } from './pr-context.js';
|
||||
import {
|
||||
anyRootCarriesCriticalMarker,
|
||||
isBlockerBody,
|
||||
findRootId,
|
||||
} from './pr-context.js';
|
||||
|
||||
/** Inline review comment, as listed by `GET /pulls/{n}/comments`. */
|
||||
export interface RawStatusComment {
|
||||
@@ -126,6 +136,7 @@ export function buildThreadStatuses(
|
||||
comments: RawStatusComment[],
|
||||
prAuthor: string,
|
||||
probe: CodeChangeProbe,
|
||||
me: string = '',
|
||||
): ThreadStatus[] {
|
||||
const byId = new Map<number, RawStatusComment>();
|
||||
for (const c of comments) byId.set(c.id, c);
|
||||
@@ -160,7 +171,7 @@ export function buildThreadStatuses(
|
||||
path: root.path ?? '',
|
||||
author: root.user?.login ?? 'unknown',
|
||||
createdAt: root.created_at ?? '',
|
||||
isBlocker: carriesBlockerSignal(root.body),
|
||||
isBlocker: isBlockerBody(root.body, root.user?.login, me),
|
||||
anchor: {
|
||||
line: root.line ?? null,
|
||||
originalLine: root.original_line ?? null,
|
||||
@@ -405,10 +416,41 @@ async function runCommentStatus(args: CommentStatusArgs): Promise<void> {
|
||||
worktreeHeadSha !== liveHeadAfter;
|
||||
const headDrift = headMovedDuringFetch || worktreeStale;
|
||||
|
||||
// The reviewing account gates the comment marker's blocker promotion —
|
||||
// the same gate pr-context applies, so this report and the context file
|
||||
// agree on what is a blocker. Both unknown shapes fail closed
|
||||
// identically — a thrown lookup AND an empty login (a stubbed or
|
||||
// proxied `gh` exiting 0 with no output) — when a posted root comment
|
||||
// carries a critical marker: an index that silently undercounts
|
||||
// blockers reads as complete, while the report's degradation contract
|
||||
// is an `error` a consumer sees.
|
||||
let me = '';
|
||||
if (comments.length) {
|
||||
let lookupError: unknown = null;
|
||||
try {
|
||||
me = currentUser();
|
||||
} catch (err) {
|
||||
lookupError = err;
|
||||
}
|
||||
if (me === '' && anyRootCarriesCriticalMarker(comments)) {
|
||||
throw new Error(
|
||||
`cannot determine the reviewing account (${
|
||||
lookupError === null
|
||||
? 'empty login'
|
||||
: lookupError instanceof Error
|
||||
? lookupError.message
|
||||
: String(lookupError)
|
||||
}) while a posted root comment carries a Qwen critical marker — ` +
|
||||
'the blocker signal depends on it; re-run',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const threads = buildThreadStatuses(
|
||||
comments,
|
||||
prAuthor,
|
||||
makeGitProbe(worktree),
|
||||
me,
|
||||
);
|
||||
if (worktreeStale) {
|
||||
// Denormalize onto every thread: the code facts describe a superseded
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -25,6 +25,8 @@ interface FetchDiffArgs {
|
||||
prNumber: number;
|
||||
repo: string;
|
||||
out: string;
|
||||
/** The `--host` flag, fed to platform detection (an Aone host selects a1). */
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface FetchDiffResult {
|
||||
@@ -45,7 +47,7 @@ export function runFetchDiff(args: FetchDiffArgs): FetchDiffResult {
|
||||
// An empty or directory --out resolves to the cwd or dies EISDIR AFTER the
|
||||
// fetch — classify it before fetching.
|
||||
assertWritableOutPath(args.out);
|
||||
const platform = getPlatformReader();
|
||||
const platform = getPlatformReader({ host: args.host });
|
||||
platform.ensureAuthenticated();
|
||||
|
||||
// ghRaw keeps the diff's trailing bytes; normalise exactly one trailing
|
||||
@@ -86,7 +88,7 @@ export const fetchDiffCommand: CommandModule = {
|
||||
.option('host', {
|
||||
type: 'string',
|
||||
describe:
|
||||
'The PR host (GitHub Enterprise). Omitted: inherit GH_HOST, else github.com.',
|
||||
"The host the target lives on. An Aone host (*.alibaba-inc.com) selects the a1 backend; omitted: detected from the clone's origin, else GitHub (GH_HOST, then github.com).",
|
||||
})
|
||||
.option('out', {
|
||||
type: 'string',
|
||||
@@ -113,6 +115,7 @@ export const fetchDiffCommand: CommandModule = {
|
||||
prNumber,
|
||||
repo: String(argv['repo']),
|
||||
out: String(argv['out']),
|
||||
host,
|
||||
});
|
||||
writeStdoutLine(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -38,7 +38,9 @@ import {
|
||||
reviewLeaseHeldByAnotherSession,
|
||||
reviewLeasePath,
|
||||
} from '../../services/review-worktree-lease.js';
|
||||
import { ensureAuthenticated, gh, setGhHost } from './lib/gh.js';
|
||||
import { setGhHost } from './lib/gh.js';
|
||||
import { getPlatformReader } from './lib/platform/registry.js';
|
||||
import type { ReviewPlatformReader } from './lib/platform/types.js';
|
||||
import type { ReviewEffort } from './parse-args.js';
|
||||
import {
|
||||
git,
|
||||
@@ -70,9 +72,27 @@ import {
|
||||
} from './lib/report.js';
|
||||
import { resolveMergeBase, type GitProbe } from './lib/merge-base.js';
|
||||
import { operatorReviewSettings } from './lib/review-settings.js';
|
||||
import { hasReviewDeadline } from './lib/deadline.js';
|
||||
import { appendRunSession } from './lib/run-ledger.js';
|
||||
import { SHA_RE } from './lib/ledger.js';
|
||||
import {
|
||||
appendRunSession,
|
||||
ledgerResumeCount,
|
||||
readResumeMarker,
|
||||
recordResume,
|
||||
recordRestart,
|
||||
RESUME_MAX,
|
||||
} from './lib/run-ledger.js';
|
||||
import {
|
||||
assessResume,
|
||||
type PreviousReport,
|
||||
type ResumeRefusal,
|
||||
} from './lib/resume.js';
|
||||
import {
|
||||
hasReviewDeadline,
|
||||
readBudgetStop,
|
||||
clearBudgetStop,
|
||||
clearRoundStamps,
|
||||
} from './lib/deadline.js';
|
||||
import { certifierMatchesRound, roundModelIdFrom } from './lib/round-model.js';
|
||||
|
||||
interface PrMetadata {
|
||||
headRefName: string;
|
||||
@@ -101,6 +121,29 @@ interface FetchPrArgs {
|
||||
* array and the recovery flow can produce one; `runFetchPr` normalizes.
|
||||
*/
|
||||
since?: string | string[];
|
||||
/**
|
||||
* Continue the interrupted run at this plan path when its state still
|
||||
* matches (worktree at `fetchedSha`, diff bytes unhashed-unchanged, live
|
||||
* head unmoved): keep the worktree, do NOT rewrite the plan — its mtime is
|
||||
* the run epoch every fence keys on — and re-announce the existing report.
|
||||
* When the state does not match, fall through to a fresh fetch; the flag
|
||||
* never fails a run that could start over.
|
||||
*/
|
||||
resume?: boolean;
|
||||
/**
|
||||
* WHO certified the `--since` anchor: the `lastModelId` beside it in the
|
||||
* cache, or the `model` beside the marker's `sha`. Copied through verbatim
|
||||
* — the orchestrator never compares it to anything, because a comparison
|
||||
* stated in prompt text is one that can be skipped, and because the two
|
||||
* sides are not even the same kind of string there (`{{model}}` is the bare
|
||||
* `config.getModel()`; what the CLI writes is provider-qualified). The gate
|
||||
* lives HERE, over `certifierMatchesRound`, which is the same function the
|
||||
* marker-recovery ruling uses.
|
||||
*
|
||||
* Omitted for an anchor nobody certified — a cache written before the
|
||||
* field. That is a mismatch, not a pass.
|
||||
*/
|
||||
sinceModel?: string | string[];
|
||||
}
|
||||
|
||||
type FetchPrResult = PlanReport & {
|
||||
@@ -173,6 +216,27 @@ type FetchPrResult = PlanReport & {
|
||||
* local review's plan has no such field: nothing is posted there.
|
||||
*/
|
||||
prDescriptionHasHan: boolean;
|
||||
/**
|
||||
* The model this ROUND started under — the runtime identity at capture
|
||||
* time, stamped here because nothing else in the flow remembers it.
|
||||
*
|
||||
* `compose-review` certifies the posted anchor with the model that did the
|
||||
* review, and its only other source is `QWEN_CODE_MODEL` read at compose
|
||||
* or submit time. That env tracks the session's CURRENT model: the
|
||||
* documented deferred-post flow — review under A, `/model` to B, "post
|
||||
* comments" — then certified A's range as B, and the next round under B
|
||||
* scoped `sha..HEAD` past code B never reviewed. A stamp taken when the
|
||||
* diff was captured is the one value that cannot drift out from under the
|
||||
* work it describes.
|
||||
*
|
||||
* Absent on a report written before this field. Compose keeps its previous
|
||||
* behaviour there rather than withholding every anchor: a report is written
|
||||
* at the start of the round and read at its end, so a missing stamp means
|
||||
* the CLI was upgraded between the two — and on a runtime that publishes no
|
||||
* model id at all, the runtime side of the comparison is empty too, so the
|
||||
* pair is certified by the same declared fallback as before this field.
|
||||
*/
|
||||
reviewModelId?: string;
|
||||
/**
|
||||
* Present when `--since <sha>` was passed: the incremental-review scoping
|
||||
* decision, validated HERE so the orchestrator never hand-runs git against
|
||||
@@ -217,6 +281,7 @@ export interface IncrementalDecision {
|
||||
| 'behind-merge-base'
|
||||
| 'hunks-outside-pr-diff'
|
||||
| 'containment-unverified'
|
||||
| 'cross-model-anchor'
|
||||
| 'base-untrusted'
|
||||
| 'capture-failed'
|
||||
| 'partition-failed';
|
||||
@@ -565,11 +630,68 @@ function sectionsContained(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlist shape for a server-controlled branch name reaching git's argv:
|
||||
* a plain branch name and nothing else (twin of aone.ts's guard — see that
|
||||
* comment for each admitted channel's wrong outcome). Fail closed: an
|
||||
* unusual-but-legal name is refused with a clear metadata-stage error
|
||||
* rather than guessed at inside a git invocation. The pseudo-ref set rides
|
||||
* the same rejection: `FETCH_HEAD` resolves to the JUST-fetched PR head
|
||||
* (merge-base(head, head) = empty diff beside full-range metadata), and
|
||||
* `ORIG_HEAD` to an arbitrary ancestor — both shape-legal, both silently
|
||||
* wrong. The match is CASE-INSENSITIVE: on case-insensitive filesystems
|
||||
* (macOS/Windows defaults) `.git/fetch_head` folds onto `.git/FETCH_HEAD`,
|
||||
* so the lowercase spellings reach the same pseudo-refs. `refs/`-prefixed
|
||||
* names ride the same rejection: they are legal branch names
|
||||
* (`git check-ref-format --branch` accepts `refs/heads/x`), but as a
|
||||
* fetch/merge-base argument they resolve QUALIFIED refs the server
|
||||
* controls — a wrong base disclosed only by a misdescribing warning.
|
||||
*/
|
||||
const GIT_PSEUDO_REFS =
|
||||
/^(FETCH|ORIG|MERGE|CHERRY_PICK|REVERT|REBASE|BISECT)_HEAD$/i;
|
||||
|
||||
function isPlainBranchName(name: string): boolean {
|
||||
return (
|
||||
name.toUpperCase() !== 'HEAD' &&
|
||||
!GIT_PSEUDO_REFS.test(name) &&
|
||||
!name.includes('..') &&
|
||||
!/^refs\//i.test(name) &&
|
||||
/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
/** The real git surface `resolveMergeBase` runs against. */
|
||||
const gitProbe: GitProbe = {
|
||||
fetch: (remote, ref) => gitOpt('fetch', remote, ref) !== null,
|
||||
// `--` ends option parsing: the base ref is server-controlled platform
|
||||
// metadata (GitHub `baseRefName`, Aone `targetBranch`), and a dash-leading
|
||||
// value must never reach git as an option (`git fetch origin
|
||||
// --upload-pack=<payload>` executes the attacker-named program on the
|
||||
// remote host with the reviewer's credentials).
|
||||
//
|
||||
// The fetch must ALSO have produced the tracking ref: a bare-name fetch
|
||||
// of a TAG exits 0 writing only FETCH_HEAD (`* tag v1.0 -> FETCH_HEAD`),
|
||||
// so the fetch "succeeds" yet no `origin/<ref>` exists — and the
|
||||
// bare-name fallback then merge-bases against the reviewer's LOCAL tag:
|
||||
// a wrong-base diff with baseFetchFailed falsely false. The fetch is an
|
||||
// EXPLICIT BRANCH REFSPEC: the source is fully qualified (git dwims a
|
||||
// bare name onto a same-named TAG and exits 0 without updating the
|
||||
// tracking ref — a pushable, server-controlled shadow that passes the
|
||||
// freshness guard it never refreshed), and the destination is the
|
||||
// qualified tracking ref. The backstop check is FULLY QUALIFIED
|
||||
// (`refs/remotes/…`): an unqualified `origin/<ref>` resolves in
|
||||
// refs/tags and refs/heads FIRST, so a tag or branch named
|
||||
// `origin/<ref>` — likewise pushable, auto-carried at clone time — would
|
||||
// satisfy the check with no tracking ref present.
|
||||
fetch: (remote, ref) =>
|
||||
gitOpt(
|
||||
'fetch',
|
||||
remote,
|
||||
'--',
|
||||
`+refs/heads/${ref}:refs/remotes/${remote}/${ref}`,
|
||||
) !== null && refExists(`refs/remotes/${remote}/${ref}`),
|
||||
refExists,
|
||||
mergeBase: (a, b) => gitOpt('merge-base', a, b),
|
||||
mergeBase: (a, b) =>
|
||||
gitOpt('-c', 'core.commitGraph=false', 'merge-base', a, b),
|
||||
};
|
||||
|
||||
function tryRemove(action: () => void): void {
|
||||
@@ -590,7 +712,147 @@ function cleanStale(prNumber: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** sha256 of a file's raw bytes, or null when it cannot be read. */
|
||||
function sha256OfFile(path: string): string | null {
|
||||
try {
|
||||
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type ResumeOutcome =
|
||||
| { resumed: true }
|
||||
| { resumed: false; reason: ResumeRefusal; priorFetchedSha: string | null };
|
||||
|
||||
/**
|
||||
* The `--resume` fast path: rule on the interrupted attempt's state and, when
|
||||
* it holds, continue it — every probe is a fact this command gathers itself
|
||||
* (git, gh, file hashes, the CLI-written marker), never the orchestrator's
|
||||
* account. On a continuation the plan file is NOT touched: its mtime is the
|
||||
* run epoch that keeps the first attempt's records, stamps and transcripts
|
||||
* inside every reader's fence.
|
||||
*/
|
||||
function tryResume(
|
||||
args: FetchPrArgs,
|
||||
wt: string,
|
||||
platform: ReviewPlatformReader,
|
||||
): ResumeOutcome {
|
||||
const { pr_number: prNumber, owner_repo: ownerRepo, out } = args;
|
||||
let prev: PreviousReport | null = null;
|
||||
try {
|
||||
prev = JSON.parse(readFileSync(out, 'utf8')) as PreviousReport;
|
||||
} catch {
|
||||
prev = null;
|
||||
}
|
||||
// An unreachable forge reads as "unmoved": the worktree and diff hashes
|
||||
// pin the content, and presubmit's headDrift re-checks before anything
|
||||
// posts. Only the live head OID is needed — a moved head is the one
|
||||
// upstream change resume refuses. The read goes through the same platform
|
||||
// reader the fresh path uses, so an Aone clone resolves the same way.
|
||||
let liveHeadSha: string | null = null;
|
||||
try {
|
||||
const oid = platform.getFetchMeta(Number(prNumber), ownerRepo).headRefOid;
|
||||
liveHeadSha = typeof oid === 'string' && oid !== '' ? oid : null;
|
||||
} catch {
|
||||
liveHeadSha = null;
|
||||
}
|
||||
// The resume cap: how many times this review has already been resumed.
|
||||
// Both counters are the CLI's own record; the marker is primary and the
|
||||
// session ledger cross-caps it, minus the original run's own first entry.
|
||||
const marker = readResumeMarker(out);
|
||||
const currentSessionId = process.env['QWEN_CODE_SESSION_ID']?.trim();
|
||||
const ledgerResumes = ledgerResumeCount(out, {
|
||||
excludeSessionId: currentSessionId,
|
||||
});
|
||||
const currentKey = currentSessionId?.toLowerCase();
|
||||
const markerResumes = marker.resumes.filter(
|
||||
(r) => r.sessionId.toLowerCase() !== currentKey,
|
||||
).length;
|
||||
// `--porcelain` prints nothing on a clean tree; a null (the command could
|
||||
// not run) is treated as dirty. `--untracked-files=normal` explicitly, so
|
||||
// a `status.showUntrackedFiles=no` tuning cannot hide residue that is not
|
||||
// in the PR.
|
||||
const status = gitOpt(
|
||||
'-C',
|
||||
wt,
|
||||
'status',
|
||||
'--porcelain',
|
||||
'--untracked-files=normal',
|
||||
);
|
||||
const ruling = assessResume(prev, {
|
||||
prNumber,
|
||||
worktreeHeadSha: gitOpt('-C', wt, 'rev-parse', 'HEAD'),
|
||||
worktreeClean: status === null ? null : status.trim() === '',
|
||||
diffSha256OnDisk: sha256OfFile(tmpFile(`pr-${prNumber}`, 'diff.txt')),
|
||||
liveHeadSha,
|
||||
resumeCount: Math.max(markerResumes, ledgerResumes),
|
||||
requestedEffort: args.effort ?? null,
|
||||
});
|
||||
if (!ruling.ok) {
|
||||
return {
|
||||
resumed: false,
|
||||
reason: ruling.reason,
|
||||
priorFetchedSha:
|
||||
prev !== null && typeof prev.fetchedSha === 'string'
|
||||
? prev.fetchedSha
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Budget hygiene: the continuation runs under a fresh deadline, so a
|
||||
// time-budget stop is the dead attempt's, not this run's, and is cleared.
|
||||
// A round-cap stop is about rounds, not time — it is the trusted CLI's own
|
||||
// record that the audit reached its round cap, so it stands, and the round
|
||||
// stamps stay with it. Any other stop is cleared with the stamps: the span
|
||||
// from the dead attempt's last stamp to the continuation's first admission
|
||||
// spans the death gap and would price a round at hours; without the stamps
|
||||
// the gate falls back to its conservative constant.
|
||||
const stop = readBudgetStop(out);
|
||||
const roundCapStands = stop !== null && stop.cause === 'round-cap';
|
||||
if (stop !== null && !roundCapStands) {
|
||||
clearBudgetStop(out);
|
||||
}
|
||||
if (!roundCapStands) {
|
||||
clearRoundStamps(out);
|
||||
}
|
||||
appendRunSession(out);
|
||||
recordResume(out);
|
||||
// Read the marker back: `recordResume` deduplicates by session, so a
|
||||
// second `--resume` in the SAME session is the same resume, and deriving
|
||||
// the number from the pre-write count would announce attempt 2 for it.
|
||||
const attempt = Math.max(1, readResumeMarker(out).resumes.length);
|
||||
// `restartsSpent` is the resume marker's ONE consumer beyond idempotency:
|
||||
// the resumed session initialises Step 7's once-per-review restart bound
|
||||
// from it — without a reader here, the recorded restart would silently
|
||||
// reset on every resume. `effort` names the level the continuation is
|
||||
// pinned to (the plan's, deliberately untouched), so a continuation never
|
||||
// silently runs at a level the caller did not expect.
|
||||
const pinnedEffort =
|
||||
prev !== null && typeof prev.effort === 'string' && prev.effort !== ''
|
||||
? prev.effort
|
||||
: 'high';
|
||||
writeStdoutLine(
|
||||
JSON.stringify({
|
||||
resumed: true,
|
||||
resumeAttempt: attempt,
|
||||
restartsSpent: marker.restarts.length,
|
||||
effort: pinnedEffort,
|
||||
out,
|
||||
}),
|
||||
);
|
||||
writeStderrLine(
|
||||
`Resumed PR #${prNumber} review (resume ${attempt} of ${RESUME_MAX}): ` +
|
||||
`worktree, plan and the interrupted attempt's agent evidence are reused; ` +
|
||||
`the report at ${out} is unchanged, and the run continues at its ` +
|
||||
`recorded effort (${pinnedEffort}).`,
|
||||
);
|
||||
return { resumed: true };
|
||||
}
|
||||
|
||||
async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
// Sampled HERE, at the start of the round: see `reviewModelId`.
|
||||
const roundModelId = roundModelIdFrom(process.env);
|
||||
const { pr_number: prNumber, owner_repo: ownerRepo, remote, out } = args;
|
||||
|
||||
// The lease gate below only engages `pr-\d+` targets, but `cleanStale`
|
||||
@@ -608,8 +870,18 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
if (ownerRepo.indexOf('/') < 0) {
|
||||
throw new Error('owner_repo must look like "owner/repo"');
|
||||
}
|
||||
|
||||
ensureAuthenticated();
|
||||
// Validate before coercing: Number('1e3') is 1000, so an unvalidated token
|
||||
// would fetch a DIFFERENT PR's head while the ref/worktree/report all carry
|
||||
// the caller's label. `[1-9]` also rejects `0` (no PR zero — the message
|
||||
// promises a POSITIVE integer, and admitting it ran detection, auth, and a
|
||||
// worktree lease before dying at the fetch) and leading zeros (the label
|
||||
// would not round-trip through Number). The skill path is guarded by
|
||||
// parse-args' digit grammar; this is the direct-CLI surface.
|
||||
if (!/^[1-9]\d*$/.test(prNumber)) {
|
||||
throw new Error(
|
||||
`pr_number must be a positive integer, got ${JSON.stringify(prNumber)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const ref = reviewBranch(prNumber);
|
||||
const wt = worktreePath(prNumber);
|
||||
@@ -651,7 +923,6 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
`re-run.`,
|
||||
);
|
||||
}
|
||||
|
||||
// The lock above refuses any later session that finds another
|
||||
// session's lease, so one left behind by ANY failure after this point
|
||||
// would block every later review of this PR until deleted by hand.
|
||||
@@ -670,33 +941,102 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
branch: ref,
|
||||
});
|
||||
|
||||
// Select the platform from the remote under review (falling back to the
|
||||
// cwd clone's origin), so an Aone clone fetches the Aone ref via a1
|
||||
// while a GitHub clone keeps `pull/<n>/head` via gh. Trim the host:
|
||||
// isAoneHost does not trim, and a padded `--host` would silently drop
|
||||
// to GitHub here. Runs INSIDE the lease gate: the refusal above must
|
||||
// precede every call this command makes — the detection probe included
|
||||
// — and a detection/auth failure rolls the lease back through the catch.
|
||||
let remoteUrl: string | undefined;
|
||||
try {
|
||||
remoteUrl = git('remote', 'get-url', remote).trim();
|
||||
} catch {
|
||||
remoteUrl = undefined;
|
||||
}
|
||||
const platform = getPlatformReader({ remoteUrl, host: args.host?.trim() });
|
||||
platform.ensureAuthenticated();
|
||||
|
||||
// A `--resume` rules before any destructive step: a continuation must
|
||||
// reach neither the cleanup below (the worktree is the state being
|
||||
// resumed) nor the plan write (its mtime is the run epoch). Platform
|
||||
// detection above is non-destructive and gives the resume probe's forge
|
||||
// read its auth. The lease already covers the resumed run — a
|
||||
// continuation keeps working in this worktree after this command
|
||||
// returns, and cleanup releases the lease with the rest. A refused
|
||||
// resume falls through to the fresh path and announces why; head
|
||||
// movement is recorded AFTER the fresh plan lands, so the marker entry
|
||||
// postdates the new epoch.
|
||||
let resumeRefusal: ResumeRefusal | null = null;
|
||||
let priorFetchedSha: string | null = null;
|
||||
if (args.resume) {
|
||||
const outcome = tryResume(args, wt, platform);
|
||||
if (outcome.resumed) return;
|
||||
resumeRefusal = outcome.reason;
|
||||
priorFetchedSha = outcome.priorFetchedSha;
|
||||
writeStdoutLine(
|
||||
JSON.stringify({ resumed: false, resumeRefused: outcome.reason }),
|
||||
);
|
||||
writeStderrLine(
|
||||
`Cannot resume PR #${prNumber} (${outcome.reason}); starting a fresh review.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Clean any stale worktree / branch from an earlier run.
|
||||
cleanStale(prNumber);
|
||||
|
||||
// 2. Fetch PR HEAD into a unique local ref.
|
||||
// 2. Fetch PR HEAD into a unique local ref. The refspec source is
|
||||
// platform-specific: GitHub `pull/<n>/head`, Aone
|
||||
// `refs/merge-requests/<global-id>/head`.
|
||||
try {
|
||||
git('fetch', remote, `pull/${prNumber}/head:${ref}`);
|
||||
git(
|
||||
'fetch',
|
||||
remote,
|
||||
`${platform.fetchHeadRefSpec(Number(prNumber))}:${ref}`,
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to fetch PR #${prNumber} from remote "${remote}": ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
const fetchedSha = git('rev-parse', ref);
|
||||
// QUALIFIED: an unqualified `ref` resolves in refs/tags BEFORE
|
||||
// refs/heads, so a planted tag with the throwaway branch's name (fully
|
||||
// attacker-known, no pid here) would silently name the tag's commit as
|
||||
// the fetched head while the worktree shows the real one.
|
||||
const fetchedSha = git('rev-parse', `refs/heads/${ref}`);
|
||||
|
||||
// 3. Fetch PR metadata via gh CLI. Cross-repo flag tells the LLM whether
|
||||
// to switch into lightweight mode.
|
||||
// 3. Fetch PR metadata via the platform. Cross-repo flag tells the LLM
|
||||
// whether to switch into lightweight mode.
|
||||
let meta: PrMetadata;
|
||||
try {
|
||||
const json = gh(
|
||||
'pr',
|
||||
'view',
|
||||
prNumber,
|
||||
'--repo',
|
||||
ownerRepo,
|
||||
'--json',
|
||||
'headRefName,headRefOid,baseRefName,additions,deletions,changedFiles,isCrossRepository,body',
|
||||
);
|
||||
meta = JSON.parse(json) as PrMetadata;
|
||||
const fetchMeta = platform.getFetchMeta(Number(prNumber), ownerRepo);
|
||||
meta = {
|
||||
headRefName: fetchMeta.headRefName ?? prNumber,
|
||||
headRefOid: fetchMeta.headRefOid,
|
||||
baseRefName: fetchMeta.baseRefName,
|
||||
// Platforms without advertised stats (Aone) fill these from the
|
||||
// captured diff after step 5.
|
||||
additions: fetchMeta.additions ?? 0,
|
||||
deletions: fetchMeta.deletions ?? 0,
|
||||
changedFiles: fetchMeta.changedFiles ?? 0,
|
||||
isCrossRepository: fetchMeta.isCrossRepository,
|
||||
body: fetchMeta.body,
|
||||
};
|
||||
// The base ref is server-controlled metadata reaching git's argv through
|
||||
// the base fetch below. The fetch probe passes `--`, but that only ends
|
||||
// OPTION parsing — validate ALLOWLIST-style, accepting only a plain
|
||||
// branch name (the twin of aone.fetchDiff's target guard, whose comment
|
||||
// names each admitted channel's wrong outcome: option spellings, `+`
|
||||
// force refspec, `src:dst` colon refspec, `HEAD`'s silent fetch + stale
|
||||
// clone-time symref merge-base, rev-parse metasyntax, the empty
|
||||
// string). A hostile platform value dies here with the metadata rolled
|
||||
// back, not inside a git invocation.
|
||||
if (!isPlainBranchName(meta.baseRefName)) {
|
||||
throw new Error(
|
||||
`refusing base ref ${JSON.stringify(meta.baseRefName)} from the ` +
|
||||
`platform metadata — not a plain branch name`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Roll back the fetched ref so the next run starts clean.
|
||||
tryRemove(() =>
|
||||
@@ -706,6 +1046,9 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
`Failed to fetch PR #${prNumber} metadata: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
// Aone does not advertise diff stats; compute them from the captured diff
|
||||
// once it exists. Tracked so the recomputed numbers replace the zeros.
|
||||
const needsLocalStats = platform.kind !== 'github';
|
||||
|
||||
// 4. Create the ephemeral worktree.
|
||||
try {
|
||||
@@ -731,7 +1074,9 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
const { sha: mergeBaseSha, baseFetchFailed } = resolveMergeBase(
|
||||
remote,
|
||||
meta.baseRefName,
|
||||
ref,
|
||||
// QUALIFIED — the head side is dwim-shadowable exactly like the
|
||||
// fetchedSha read above.
|
||||
`refs/heads/${ref}`,
|
||||
gitProbe,
|
||||
);
|
||||
if (baseFetchFailed) {
|
||||
@@ -829,7 +1174,45 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
// later, `since.slice(…)` — which crashed the command after the worktree
|
||||
// existed and before any report was written.
|
||||
const sinceArg = typeof rawSince === 'string' ? rawSince : undefined;
|
||||
if (sinceArg !== undefined && sinceArg !== '') {
|
||||
// WHO certified it, gated before the history is consulted at all: "clean
|
||||
// up to this sha" is the recorded identity's verdict, and `fetch-pr`
|
||||
// validates an anchor against the HISTORY, never against who certified
|
||||
// it — so an anchor from another model is ancestrally perfect and still
|
||||
// scopes this round past code it never reviewed.
|
||||
//
|
||||
// Ruled here rather than in the skill because every prompt-text version
|
||||
// of this comparison has been wrong: `{{model}}` interpolates the BARE
|
||||
// `config.getModel()` while every identity the CLI writes is
|
||||
// provider-qualified, so the two sides were never the same kind of string
|
||||
// and two providers exposing one model name passed each other's gate.
|
||||
// With the check here there is no identity comparison left in prompt text
|
||||
// at all — the orchestrator copies two fields and reads a decision.
|
||||
const rawSinceModel = Array.isArray(args.sinceModel)
|
||||
? args.sinceModel[args.sinceModel.length - 1]
|
||||
: args.sinceModel;
|
||||
const sinceModel =
|
||||
typeof rawSinceModel === 'string' ? rawSinceModel : undefined;
|
||||
const crossModel =
|
||||
sinceArg !== undefined &&
|
||||
sinceArg !== '' &&
|
||||
!certifierMatchesRound(sinceModel, roundModelIdFrom(process.env));
|
||||
if (crossModel) {
|
||||
anchor = {
|
||||
incremental: {
|
||||
since: sinceArg,
|
||||
effective: false,
|
||||
reason: 'cross-model-anchor',
|
||||
},
|
||||
diffBase: null,
|
||||
};
|
||||
writeStderrLine(
|
||||
`Incremental anchor not used — it was certified by ` +
|
||||
`${sinceModel ? `"${sinceModel}"` : 'no recorded identity'}, and ` +
|
||||
`this review runs as ` +
|
||||
`${roundModelIdFrom(process.env) || 'an unpublished identity'}. ` +
|
||||
`Reviewing the full range.`,
|
||||
);
|
||||
} else if (sinceArg !== undefined && sinceArg !== '') {
|
||||
try {
|
||||
anchor = resolveIncrementalAnchor(
|
||||
sinceArg,
|
||||
@@ -1124,6 +1507,20 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Aone does not advertise diff stats — fill them from the captured diff
|
||||
// so the report's diffStat and the stderr summary carry real numbers.
|
||||
// Runs AFTER the plan/rescue above, where `diffText` is FINAL: the
|
||||
// partition-rescue can republish the full range over a delta-scoped
|
||||
// capture, and a backfill run before it would advertise the delta's
|
||||
// numbers beside a diffPath pointing at the full merge-base diff. The
|
||||
// numbers must describe the SAME diff the report points at.
|
||||
if (needsLocalStats) {
|
||||
const stats = computeDiffStats(diffText);
|
||||
meta.additions = stats.additions;
|
||||
meta.deletions = stats.deletions;
|
||||
meta.changedFiles = stats.changedFiles;
|
||||
}
|
||||
|
||||
// 6. Emit the report. The window opening survives drift restarts: this
|
||||
// command overwrites its own report, and a reset boundary would hide any
|
||||
// bypass write made during the abandoned attempt from cleanup's audit.
|
||||
@@ -1166,8 +1563,12 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
// attempt, never forward. A corrupted far-future `auditSince`
|
||||
// (`"2099-…"`) is therefore rejected here — it would push the window
|
||||
// ahead of every real comment and silently report a clean audit.
|
||||
// (ISO-8601 strings from `toISOString()` compare chronologically.)
|
||||
prevSince < auditSince
|
||||
// Compared NUMERICALLY: `toISOString()` output happens to sort
|
||||
// chronologically, but a forged extended-year form
|
||||
// (`"+275760-…"`) parses to the far future while sorting
|
||||
// lexicographically BEFORE any `"2026-…"` string — a string
|
||||
// comparison inherits exactly the forgery this bound rejects.
|
||||
Date.parse(prevSince) < Date.parse(auditSince)
|
||||
) {
|
||||
auditSince = prevSince;
|
||||
}
|
||||
@@ -1242,14 +1643,24 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
// advertised stat, so the collapse ratio would fire on every incremental
|
||||
// review — both flags are full-range facts, so both read `fullText` on
|
||||
// EVERY round, delta-scoped or not.
|
||||
...(isCollapsedFromUpstream({
|
||||
diffText: fullText ?? '',
|
||||
baseFetchFailed,
|
||||
additions: meta.additions,
|
||||
deletions: meta.deletions,
|
||||
})
|
||||
? { collapsedFromUpstream: true }
|
||||
: {}),
|
||||
// The collapse disclosure needs TWO independent quantities: the
|
||||
// platform-advertised full-PR stat and the recomputed full-range count.
|
||||
// Off GitHub the advertised half is locally derived FROM THE SAME
|
||||
// captured text — one source, not two — and on a delta-scoped round it
|
||||
// is delta-scoped beside a full-range count (a churned delta passing
|
||||
// containment would fire a false "overlapping merged PRs collapsed
|
||||
// this PR"). Skip it when the stats are locally derived; a genuine
|
||||
// upstream collapse cannot be disclosed without an independent fact.
|
||||
...(needsLocalStats
|
||||
? {}
|
||||
: isCollapsedFromUpstream({
|
||||
diffText: fullText ?? '',
|
||||
baseFetchFailed,
|
||||
additions: meta.additions,
|
||||
deletions: meta.deletions,
|
||||
})
|
||||
? { collapsedFromUpstream: true }
|
||||
: {}),
|
||||
diffStat: {
|
||||
files: meta.changedFiles,
|
||||
additions: meta.additions,
|
||||
@@ -1261,6 +1672,7 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
diffPathAbsolute,
|
||||
diffSha256,
|
||||
prDescriptionHasHan: /\p{Script=Han}/u.test(meta.body ?? ''),
|
||||
...(roundModelId ? { reviewModelId: roundModelId } : {}),
|
||||
...(anchor ? { incremental: anchor.incremental } : {}),
|
||||
...buildPlanReport(plan, (path) => fileLineCount(fetchedSha, path), {
|
||||
operatorRoundCap: operatorReviewSettings().reverseAuditRounds,
|
||||
@@ -1274,6 +1686,14 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
||||
// reads the ledger to find this attempt's transcripts. After the plan
|
||||
// write, so the entry sits inside the run-epoch fence it is read through.
|
||||
appendRunSession(out);
|
||||
if (resumeRefusal === 'head-moved') {
|
||||
// The once-per-review restart bound, now a fact on disk. Recorded after
|
||||
// the plan write for the same fence reason as the session entry.
|
||||
recordRestart(
|
||||
out,
|
||||
`head-moved ${priorFetchedSha?.slice(0, 7) ?? 'unknown'}->${fetchedSha.slice(0, 7)}`,
|
||||
);
|
||||
}
|
||||
writeStdoutLine(`Wrote fetch-pr report to ${out}`);
|
||||
if (diffPath) writeStdoutLine(`Wrote review diff to ${diffPath}`);
|
||||
// Surface diff stats to stderr so a human running the command interactively
|
||||
@@ -1375,34 +1795,59 @@ export function isCollapsedFromUpstream(i: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Changed (+/-) lines in a unified diff — headers excluded. */
|
||||
/**
|
||||
* Changed (+/-) lines in a unified diff — headers excluded. Delegates to the
|
||||
* single hunk-state walker in computeDiffStats so the two can never disagree
|
||||
* (isCollapsedFromUpstream compares this against the advertised stats, and
|
||||
* for Aone the advertised stats COME from computeDiffStats — one walker, or
|
||||
* the ratio is load-bearing on two copies agreeing).
|
||||
*
|
||||
* POSITION, not prefix shape. Guessing by prefix (`^-(?!--)`) has to exclude
|
||||
* every line starting `--`, and a DELETED line whose own content starts `--`
|
||||
* arrives as `--- …`: markdown rules and YAML document markers, SQL and Lua
|
||||
* comments, a `--flag` in a script. Each one silently dropped a real changed
|
||||
* line, and every drop pushes the ratio toward a false `collapsedFromUpstream`
|
||||
* (the disclosure fires when the recomputed count comes in LOW).
|
||||
*/
|
||||
export function countDiffChangedLines(diffText: string): number {
|
||||
// POSITION, not prefix shape. Guessing by prefix (`^-(?!--)`) has to exclude
|
||||
// every line starting `--`, and a DELETED line whose own content starts `--`
|
||||
// arrives as `--- …`: markdown rules and YAML document markers, SQL and Lua
|
||||
// comments, a `--flag` in a script. Each one silently dropped a real changed
|
||||
// line, and every drop pushes the ratio toward a false `collapsedFromUpstream`
|
||||
// (the disclosure fires when the recomputed count comes in LOW).
|
||||
//
|
||||
// Inside a hunk the position is unambiguous — `---`/`+++` cannot be file
|
||||
// headers there — so track hunk state and count every `+`/`-` line in it.
|
||||
let n = 0;
|
||||
const { additions, deletions } = computeDiffStats(diffText);
|
||||
return additions + deletions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additions / deletions / changed-files counted straight off a unified diff —
|
||||
* the single hunk-state walker (see countDiffChangedLines). Used when the
|
||||
* platform does not advertise diff stats (Aone); GitHub's `gh pr view`
|
||||
* reports them, so GitHub keeps the advertised numbers. Inside a hunk the
|
||||
* position is unambiguous — `---`/`+++` cannot be file headers there — so
|
||||
* track hunk state and count every `+`/`-` line in it; `diff --git` opens
|
||||
* the next file's header block and `\ No newline at end of file` is a marker,
|
||||
* not content.
|
||||
*/
|
||||
export function computeDiffStats(diffText: string): {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changedFiles: number;
|
||||
} {
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
let changedFiles = 0;
|
||||
let inHunk = false;
|
||||
for (const line of diffText.split('\n')) {
|
||||
if (line.startsWith('diff --git')) {
|
||||
changedFiles++;
|
||||
inHunk = false;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('@@')) {
|
||||
inHunk = true;
|
||||
continue;
|
||||
}
|
||||
// `diff --git` opens the next file's header block; `\ No newline at end of
|
||||
// file` is a marker, not content, and git emits it inside the hunk.
|
||||
if (line.startsWith('diff --git')) {
|
||||
inHunk = false;
|
||||
continue;
|
||||
}
|
||||
if (!inHunk || line.startsWith('\\')) continue;
|
||||
if (line.startsWith('+') || line.startsWith('-')) n++;
|
||||
if (line.startsWith('+')) additions++;
|
||||
else if (line.startsWith('-')) deletions++;
|
||||
}
|
||||
return n;
|
||||
return { additions, deletions, changedFiles };
|
||||
}
|
||||
|
||||
export const fetchPrCommand: CommandModule = {
|
||||
@@ -1435,7 +1880,7 @@ export const fetchPrCommand: CommandModule = {
|
||||
.option('host', {
|
||||
type: 'string',
|
||||
describe:
|
||||
'GitHub host for this PR (GitHub Enterprise). Routes every gh call in this command via GH_HOST; omit for github.com.',
|
||||
"The host the target lives on — it selects the platform, i.e. whether the fetch uses pull/<n>/head or refs/merge-requests/<id>/head. An Aone host (*.alibaba-inc.com) selects the Aone backend; omitted: detected from the remote under review, else the clone's origin, else GitHub.",
|
||||
})
|
||||
.option('max-chunk-lines', {
|
||||
type: 'number',
|
||||
@@ -1443,6 +1888,12 @@ export const fetchPrCommand: CommandModule = {
|
||||
describe:
|
||||
'Target size, in diff lines, of each review chunk. A chunk boundary falls on a hunk boundary; a hunk larger than this is split only at a top-level declaration, never inside a function.',
|
||||
})
|
||||
.option('resume', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
describe:
|
||||
'Continue an interrupted run of this PR when its on-disk state still matches (worktree at the fetched SHA, diff bytes unchanged, PR head unmoved): keep the worktree, leave the plan untouched, and print {"resumed":true}. Falls through to a normal fresh fetch — printing {"resumed":false,"resumeRefused":"<reason>"} — whenever the state does not match.',
|
||||
})
|
||||
.option('effort', {
|
||||
type: 'string',
|
||||
choices: ['low', 'medium', 'high'],
|
||||
@@ -1462,6 +1913,18 @@ export const fetchPrCommand: CommandModule = {
|
||||
'diff with the reason in the report; a valid one scopes the diff ' +
|
||||
"and the chunk plan to since..head. The decision is the report's " +
|
||||
'`incremental` field.',
|
||||
})
|
||||
.option('since-model', {
|
||||
type: 'string',
|
||||
describe:
|
||||
'WHO certified the --since anchor: the `lastModelId` beside it in ' +
|
||||
'the review cache, or the `model` beside the marker sha. Copy it ' +
|
||||
'through verbatim — do NOT compare it to anything yourself. The ' +
|
||||
'anchor is used only when it matches the identity running this ' +
|
||||
'review; otherwise the report says `cross-model-anchor` and the ' +
|
||||
'round reviews the full diff, because "clean up to this sha" is ' +
|
||||
"the recorded identity's verdict and this command validates an " +
|
||||
'anchor against the history, never against who certified it.',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
setGhHost((argv as { host?: string }).host);
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
appendFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -58,10 +59,24 @@ vi.mock('../../utils/stdioHelpers.js', () => ({
|
||||
writeStderrLine: vi.fn(),
|
||||
writeStderrLineSafe: vi.fn(),
|
||||
}));
|
||||
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
|
||||
import {
|
||||
writeStdoutLine,
|
||||
writeStderrLine,
|
||||
writeStderrLineSafe,
|
||||
} from '../../utils/stdioHelpers.js';
|
||||
import { agentPromptCommand } from './agent-prompt.js';
|
||||
import { promptRecordDir, readRecordedPrompts } from './lib/prompt-record.js';
|
||||
import { readBudgetStop, writeRoundCapStop } from './lib/deadline.js';
|
||||
import {
|
||||
findingsPointerOf,
|
||||
promptRecordDir,
|
||||
readRecordedPrompts,
|
||||
} from './lib/prompt-record.js';
|
||||
import {
|
||||
DEADLINE_ENV,
|
||||
RESERVE_ENV,
|
||||
TOOL_CONCURRENCY_ENV,
|
||||
readBudgetStop,
|
||||
writeRoundCapStop,
|
||||
} from './lib/deadline.js';
|
||||
import { runCleanup } from './cleanup.js';
|
||||
|
||||
const PLAN = {
|
||||
@@ -152,11 +167,24 @@ describe('issue #9206 — retirement must retire twice-dry chunks, or say why it
|
||||
utimesSync(plan, old, old);
|
||||
findings = join(dir, 'findings.md');
|
||||
writeFileSync(findings, '');
|
||||
for (const k of ['QWEN_CODE_PROJECT_DIR', 'QWEN_CODE_SESSION_ID']) {
|
||||
for (const k of [
|
||||
'QWEN_CODE_PROJECT_DIR',
|
||||
'QWEN_CODE_SESSION_ID',
|
||||
// The budget gate reads these three straight from process.env on
|
||||
// every round admission (#9259): an ambient deadline inherited from
|
||||
// a concurrent review makes round admission environment-dependent
|
||||
// and fails this suite for reasons that have nothing to do with it.
|
||||
DEADLINE_ENV,
|
||||
RESERVE_ENV,
|
||||
TOOL_CONCURRENCY_ENV,
|
||||
]) {
|
||||
SAVED[k] = process.env[k];
|
||||
}
|
||||
process.env['QWEN_CODE_PROJECT_DIR'] = dir;
|
||||
process.env['QWEN_CODE_SESSION_ID'] = 'S1';
|
||||
delete process.env[DEADLINE_ENV];
|
||||
delete process.env[RESERVE_ENV];
|
||||
delete process.env[TOOL_CONCURRENCY_ENV];
|
||||
mkdirSync(join(dir, 'subagents', 'S1'), { recursive: true });
|
||||
});
|
||||
|
||||
@@ -173,6 +201,10 @@ describe('issue #9206 — retirement must retire twice-dry chunks, or say why it
|
||||
function runRound(round: number): string {
|
||||
(writeStdoutLine as unknown as Mock).mockClear();
|
||||
(writeStderrLine as unknown as Mock).mockClear();
|
||||
// The Safe writer carries the certification diagnostics (#9259) —
|
||||
// leaving it out of the round's text makes every `chunk N —` failure
|
||||
// line invisible to the assertions below.
|
||||
(writeStderrLineSafe as unknown as Mock).mockClear();
|
||||
(agentPromptCommand.handler as (a: unknown) => void)({
|
||||
plan,
|
||||
role: 'reverse-audit',
|
||||
@@ -186,7 +218,10 @@ describe('issue #9206 — retirement must retire twice-dry chunks, or say why it
|
||||
const err = (writeStderrLine as unknown as Mock).mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.join('\n');
|
||||
return `${out}\n${err}`;
|
||||
const safe = (writeStderrLineSafe as unknown as Mock).mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.join('\n');
|
||||
return `${out}\n${err}\n${safe}`;
|
||||
}
|
||||
|
||||
/** The recorded launch prompt for one (round, chunk). */
|
||||
@@ -264,28 +299,107 @@ describe('issue #9206 — retirement must retire twice-dry chunks, or say why it
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
// A compliant auditor reads the cumulative findings list its prompt
|
||||
// points at — the comparison against known findings IS the audit's
|
||||
// method, and the dry bar (#9091) refuses a receipt from an auditor
|
||||
// that skipped the read.
|
||||
const pointer = findingsPointerOf(launchPrompt);
|
||||
if (pointer !== null) {
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
...base,
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: `${id}-c2`,
|
||||
name: 'read_file',
|
||||
args: { file_path: pointer },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
...base,
|
||||
type: 'tool_result',
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: `${id}-c2`,
|
||||
name: 'read_file',
|
||||
response: { output: 'findings list' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
...base,
|
||||
type: 'assistant',
|
||||
message: { role: 'model', parts: [{ text: finalText }] },
|
||||
}),
|
||||
];
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'subagents', 'S1', `agent-${id}.jsonl`),
|
||||
lines.join('\n') + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk 15's finding, fresh each round (#9259): a fixed `**File:**` line
|
||||
* lands on the baked findings list from round 3 on, the return's only
|
||||
* FILE_LINE_RE match reads as a quotation of the list, and the "yields
|
||||
* every round" control classifies `unknown` instead — emitting the very
|
||||
* diagnostic noise this suite exists to catch. Varying the line number
|
||||
* keeps every round's finding off the list the round was launched with.
|
||||
*/
|
||||
function yieldFor(round: number): string {
|
||||
return YIELD.replace('part3.ts:12', `part3.ts:${round}2`);
|
||||
}
|
||||
|
||||
/** Rounds 1-2 with the given receipt for the two cold chunks; 15 yields. */
|
||||
function twiceDry(receipt: string): void {
|
||||
for (const round of [1, 2]) {
|
||||
runRound(round);
|
||||
auditorTranscript(recordOf(round, 13), receipt, 13);
|
||||
auditorTranscript(recordOf(round, 14), receipt, 14);
|
||||
auditorTranscript(recordOf(round, 15), YIELD, 15);
|
||||
auditorTranscript(recordOf(round, 15), yieldFor(round), 15);
|
||||
// The loop found something every round — grow the list like the real
|
||||
// run, so a later round's list holds exactly the earlier rounds'
|
||||
// findings and no more.
|
||||
appendFileSync(findings, yieldFor(round));
|
||||
}
|
||||
// The loop found something every round — grow the list like the real run.
|
||||
writeFileSync(findings, YIELD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a round's output says something about chunk `id` — a
|
||||
* retirement note naming it, or a certification-failure diagnostic
|
||||
* naming it and the fallen bar. One predicate at describe scope
|
||||
* (#9272): the two tests below assert on the same oracle, and a
|
||||
* production-wording change must not pay a double edit (a stale inline
|
||||
* copy reads green while asserting the old wording).
|
||||
*/
|
||||
function mentionsChunk(out: string, id: number): boolean {
|
||||
return (
|
||||
new RegExp(`chunk ${id} — retired`).test(out) ||
|
||||
new RegExp(
|
||||
`chunk ${id}[^\\n]*(?:certif|receipt|territory|transcript)`,
|
||||
'i',
|
||||
).test(out) ||
|
||||
new RegExp(
|
||||
`(?:certif|receipt|territory|transcript)[^\\n]*chunk ${id}\\b`,
|
||||
'i',
|
||||
).test(out)
|
||||
);
|
||||
}
|
||||
|
||||
it('wiring control: the canonical receipt retires its chunk at round 3', () => {
|
||||
@@ -309,47 +423,52 @@ describe('issue #9206 — retirement must retire twice-dry chunks, or say why it
|
||||
// The reported symptom: rounds 3-5 each built auditors for EVERY chunk,
|
||||
// no retirement note ever appeared, and nothing anywhere said which
|
||||
// certification condition rejected the receipts. Either outcome the
|
||||
// issue expects must show up in the builder's output:
|
||||
const retired = /retirement:[\s\S]*chunk 1[34]/.test(r3);
|
||||
const diagnosed =
|
||||
// A diagnostic naming the chunk and the failed condition (no matching
|
||||
// transcript / receipt not matched / territory read missing).
|
||||
/chunk 1[34][^\n]*(?:certif|receipt|territory|transcript)/i.test(r3) ||
|
||||
/(?:certif|receipt|territory|transcript)[^\n]*chunk 1[34]/i.test(r3);
|
||||
|
||||
// A twice-dry chunk may stay under audit, but never silently: the
|
||||
// round-3 output must carry EITHER a retirement note naming it OR a
|
||||
// certification-failure diagnostic. Today it carries neither.
|
||||
expect(retired || diagnosed).toBe(true);
|
||||
// issue expects must show up in the builder's output — PER CHUNK
|
||||
// (#9259): a pair-wide existential match lets one chunk's retirement
|
||||
// cover the other's silent re-audit, the asymmetric shape real runs
|
||||
// show (#9206 hit 4 of 12 chunks, not all).
|
||||
for (const id of [13, 14]) {
|
||||
// A twice-dry chunk may stay under audit, but never silently: the
|
||||
// round-3 output must carry EITHER a retirement note naming it OR a
|
||||
// certification-failure diagnostic. Today it carries neither.
|
||||
expect(mentionsChunk(r3, id)).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('the silence is not one round: across rounds 3-5 the twice-dry chunks are retired or diagnosed at least once', () => {
|
||||
twiceDry(DRY_EN_PERIOD);
|
||||
const mentions = (out: string): boolean =>
|
||||
/retirement:[\s\S]*chunk 1[34]/.test(out) ||
|
||||
/chunk 1[34][^\n]*(?:certif|receipt|territory|transcript)/i.test(out) ||
|
||||
/(?:certif|receipt|territory|transcript)[^\n]*chunk 1[34]/i.test(out);
|
||||
let anyWord = '';
|
||||
const mentioned = new Set<number>();
|
||||
for (const round of [3, 4, 5]) {
|
||||
const out = runRound(round);
|
||||
// Today every one of these reads `3 auditors required this round`.
|
||||
if (mentions(out)) anyWord = out;
|
||||
for (const id of [13, 14]) {
|
||||
if (mentionsChunk(out, id)) mentioned.add(id);
|
||||
}
|
||||
// The hot control stays honest (#9259): chunk 15's finding is fresh
|
||||
// each round, so it classifies yielded and never produces the
|
||||
// certifiable-but-never-retiring diagnostic this suite exists to
|
||||
// catch — a `chunk 15 —` failure line here is the harness misreading
|
||||
// its own control, not production speaking.
|
||||
expect(out).not.toContain('chunk 15 —');
|
||||
// Answer whatever chunks the round actually built, so the next round's
|
||||
// schedule reads a complete history — the loop shape of the real run.
|
||||
for (const m of out.matchAll(/— chunk (\d+)(?: \(cold check\))? ─/g)) {
|
||||
const chunkId = Number(m[1]);
|
||||
auditorTranscript(
|
||||
recordOf(round, chunkId),
|
||||
chunkId <= 14 ? DRY_EN_PERIOD : YIELD,
|
||||
chunkId <= 14 ? DRY_EN_PERIOD : yieldFor(round),
|
||||
chunkId,
|
||||
);
|
||||
}
|
||||
// Grow the list with this round's finding, like the real loop.
|
||||
appendFileSync(findings, yieldFor(round));
|
||||
}
|
||||
// Observed today (and on the installed CLI end to end): rounds 3, 4 and 5
|
||||
// each built every chunk (`3 auditors required this round` three times)
|
||||
// and never said a word about the twice-dry ones.
|
||||
expect(anyWord).not.toBe('');
|
||||
// and never said a word about the twice-dry ones. Per chunk (#9259): one
|
||||
// chunk's word must not cover the other's silence.
|
||||
expect([...mentioned].sort()).toEqual([13, 14]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ interface IssueContextArgs {
|
||||
out: string;
|
||||
/** Additional issues to fetch beyond the closing set (from --issue). */
|
||||
extraIssues: RequestedIssue[];
|
||||
/** The `--host` flag, fed to platform detection (an Aone host selects a1). */
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface IssueContextResult {
|
||||
@@ -123,7 +125,7 @@ export function runIssueContext(args: IssueContextArgs): IssueContextResult {
|
||||
// An empty or directory --out resolves to the cwd or dies EISDIR AFTER the
|
||||
// fetches — classify it before fetching.
|
||||
assertWritableOutPath(args.out);
|
||||
const platform = getPlatformReader();
|
||||
const platform = getPlatformReader({ host: args.host });
|
||||
platform.ensureAuthenticated();
|
||||
|
||||
const fetchOne = (n: number, ownerRepo: string): IssueOutcome => {
|
||||
@@ -258,7 +260,7 @@ export const issueContextCommand: CommandModule = {
|
||||
.option('host', {
|
||||
type: 'string',
|
||||
describe:
|
||||
'The PR host (GitHub Enterprise). Omitted: inherit GH_HOST, else github.com.',
|
||||
"The host the target lives on. An Aone host (*.alibaba-inc.com) selects the a1 backend; omitted: detected from the clone's origin, else GitHub (GH_HOST, then github.com).",
|
||||
})
|
||||
.option('issue', {
|
||||
type: 'string',
|
||||
@@ -317,6 +319,7 @@ export const issueContextCommand: CommandModule = {
|
||||
repo,
|
||||
out: String(argv['out']),
|
||||
extraIssues: extras,
|
||||
host,
|
||||
});
|
||||
writeStdoutLine(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
|
||||
@@ -535,7 +535,7 @@ You are undirected on purpose. Do not restrict yourself to the list.`,
|
||||
Read the JSON it prints:
|
||||
|
||||
- \`toolchain: "npm"\` → use its \`build[]\` / \`test[]\` results. A failure in a file **the diff changed** is a **Critical** (\`Source: [build]\` or \`[test]\`); a failure in a file it did **not** touch is pre-existing — say so, do not file it against this PR. A non-empty \`timedOut\`, or a failed \`install\`, is environment/infrastructure — informational, never a Critical. On \`ok: true\`, name the workspaces built and the commands run; a return that names no command is a whiff. Report the TEST coverage from \`testScope\`, never from assumption. \`testScope.workspaces\` lists exactly the suites that ran — say "tests scoped to <list> — the changed workspaces and their declared dependents that define a test script". \`testScope.notRun\`, when present, names suites the whole-call budget stopped before they ran — say they did not run, never fold them into the coverage. When \`testScope.caveat\` is present, the scope may be incomplete — quote the caveat and say exactly that. A green run is a claim about those suites only — do not phrase it as the whole suite passing.
|
||||
- **A suite left unrun is not a suite that passed — continue the run.** \`testScope.notRun\` names suites the whole-call budget could not reach, and a \`test[]\` entry with \`"clamped": true\` is a suite the budget started too late and killed (its deadline was shortened, so its timeout says nothing about the suite). A third shape carries no field at all — a single-package repo whose budget ran out before its one suite has an empty \`test[]\` and no \`testScope\`, and only the \`note\` says so; read it before calling the dimension finished. That third shape cannot be continued — a continuation has no recorded scope to read, and answers "ended before its test phase" without running anything — so report the dimension UNFINISHED and do not spend a continuation on it. The first two mean the dimension is unfinished AND continuable: re-run the SAME \`build-test\` command with \`--resume\` — it skips install and build, runs only what is left, and merges into the same report file. The ${SHELL_TOOL_MAX_TIMEOUT_MS / 1000}-second ceiling is per CALL, so this is the only way a repo whose suites do not fit one call ever finishes them (measured on this repo: \`packages/cli\` alone needs 401s, and install + builds + \`packages/core\` had already spent 285s). Keep resuming while work is left, up to ${MAX_RESUME_CALLS} continuations; then report what the run has, with \`notRun\` disclosed.
|
||||
- **A suite left unrun is not a suite that passed — continue the run.** \`testScope.notRun\` names suites the whole-call budget could not reach, and a \`test[]\` entry with \`"clamped": true\` is a suite the budget started too late and killed (its deadline was shortened, so its timeout says nothing about the suite). A third shape ends before any suite — a single-package repo whose budget ran out before its one suite has an empty \`test[]\`, no \`testScope\`, and \`"endedBeforeTests": true\` (the report's own stamp), with the \`note\` naming the unrun suite; read them before calling the dimension finished. That third shape cannot be continued — a continuation has no recorded scope to read, and a \`--resume\` on it answers "ended before its test phase" and points at a fresh run — so report the dimension UNFINISHED and do not spend a continuation on it. The first two mean the dimension is unfinished AND continuable: re-run the SAME \`build-test\` command with \`--resume\` — it skips install and build, runs only what is left, and merges into the same report file. The ${SHELL_TOOL_MAX_TIMEOUT_MS / 1000}-second ceiling is per CALL, so this is the only way a repo whose suites do not fit one call ever finishes them (measured on this repo: \`packages/cli\` alone needs 401s, and install + builds + \`packages/core\` had already spent 285s). Keep resuming while work is left, up to ${MAX_RESUME_CALLS} continuations; then report what the run has, with \`notRun\` disclosed.
|
||||
- **When any \`test[]\` command failed (exit non-zero, not a timeout), MEASURE which failures are the PR's before ruling by path.** The path rule above misclassifies in both directions — an environment-flaky test in a touched file gets filed as a Critical it did not cause, and a PR that breaks a test in an UNTOUCHED file gets waved through as pre-existing. The measurement is two commands: \`qwen review base-tree --plan <plan> --worktree <worktree> --out <plan dir>/qwen-review-pr-<n>-base-tree.json\` (builds the merge base beside the worktree). **Read \`available\` before using \`path\`** — a tree that was created but did NOT build populates \`path\` too, and a base that failed to build says nothing whatsoever about the PR, so measuring against it turns an infrastructure failure into a list of Criticals. \`available: false\` (local/lightweight review, no merge base, a base that would not compile) means the path rule stands — say so and stop here, and \`qwen review test-delta --report <the build-test report you wrote> --baseline <the base-tree report's path field> --pr-worktree <this worktree> --out <plan dir>/qwen-review-pr-<n>-test-delta.json\`. Read its verdict: a file in \`netNew\` fails on the PR side only — **that is the Critical**, whatever file the diff touches; a file in \`shared\` fails on base too — **pre-existing by measurement**, never filed, whatever file the diff touches; an \`unparsed\` entry, a timed-out base rerun, a base rerun that FAILED without naming any failing file (it did not measure the base — an unbuilt tree, a missing install, a workspace absent at base), or a command the whole-command budget could not fit attributes nothing — the report names each with its own reason; fall back to the path rule for those and say the delta could not rule. Compare failing FILE SETS, never counts: a flaky suite fails different test NAMES on two runs of the same tree, so counts are noise and the set difference is the signal.
|
||||
- \`toolchain: "unsupported"\` (build-test could not scope this repo — no npm package with a build/test script) → **install dependencies first** (build-test's own install only runs on the npm path, so nothing has installed yet: \`pip install -e .\`, \`mvn -q -DskipTests package\`'s own fetch, \`cargo fetch\`, \`go mod download\`, etc.), then fall back to **one** build and **one** test command by this precedence, each with a deadline it can meet: \`pom.xml\` → \`{mvn} compile\` / \`{mvn} test -q\`; \`build.gradle\` → \`{gradle} compileJava\` / \`{gradle} test\`; \`Makefile\` → \`make build\`; \`Cargo.toml\` → \`cargo build\` / \`cargo test\`; \`go.mod\` → \`go build ./...\` / \`go test ./...\`; \`pytest.ini\` or \`pyproject.toml\` \`[tool.pytest]\` → \`pytest\`. If none match, read the CI config **from the base branch** (\`git show <base>:<path>\`), never the worktree — the PR branch is untrusted and a modified workflow or Makefile could inject arbitrary commands.
|
||||
|
||||
@@ -647,14 +647,18 @@ For each finding you were given:
|
||||
|
||||
**When the defect is mechanically enumerable, sweep the real population — the count is the verdict.** For a claim about a pattern, a predicate, or a parser ("this misclassifies X", "this mishandles shape Y"), do not stop at the one reported instance: run the check over every real instance this repo holds (every workflow step body, every call site, every input the code will actually see) and report the count. "195 of 434 real \`run:\` bodies reach this path" confirms the finding, sizes its severity, and hands the author a number they can re-run rather than argue with — and a count of **zero** is the quoted contradiction that rejects it. Two rules keep a sweep evidence rather than theatre: its oracle must be an **external authority** — the real parser, the real tool, \`bash -n\` — never your own reimplementation of the logic under test, because a mirror shares the blind spots of what it mirrors and mirrored sweeps have manufactured false findings out of their own bugs; and spot-check one hit by reading it before you quote a nonzero count.
|
||||
|
||||
**The commonest sweepable thing in a diff is a hardcoded table of another system's namespace** — heap-space names, error codes, MIME types, status codes, locales, a runtime's own enums. It reads as data rather than logic, so it invites being checked by eye against a list you retype; a retyped list is a mirror of the thing under test, which the oracle rule above already rejects, and it is the mirror you are most likely to type correctly and therefore believe. **Parse the literal out of the source** and take the set difference against the authority at runtime — the real enum, the real registry, the real API call. Both directions are findings and they are not the same finding: a name the table has and the authority does not is dead weight; a name the **authority** has and the table does not is an under-count, which is the direction that ships and the one no test written against the table can see, because the table is what those tests enumerate.
|
||||
|
||||
**A suggested fix you did not run is a hypothesis; say which one you are giving.** When a finding's fix is cheap to apply, patch it in, re-run the same probe/harness to show it works, then revert — and state that every other number in your report comes from the unmodified PR (the contamination line is what lets a reader trust the rest). A fix too costly to verify is still worth proposing, labeled untested.
|
||||
|
||||
**A probabilistic failure gets a RATE, not an anecdote.** For a timing/race claim, run N repetitions per arm and report the rates as the verdict; amplify with full CPU load to force the window open (a live case went from 4/11 idle to 5/5 loaded). And attribute honestly: a lower idle rate with no structural change is luck, not a fix. Fake-timer tests hardcode one ordering by construction — they cannot discriminate a race, so a green fake-timer suite is non-evidence here.
|
||||
**A probabilistic failure gets a RATE, not an anecdote.** For a timing/race claim, run N repetitions per arm and report the rates as the verdict; amplify with full CPU load to force the window open (a live case went from 4/11 idle to 5/5 loaded). And attribute honestly: a lower idle rate with no structural change is luck, not a fix. Fake-timer tests hardcode one ordering by construction — they cannot discriminate a race, so a green fake-timer suite is non-evidence here. When the split is deterministic — one arm N/N and the other 0/N — say exactly that: a deterministic split is what separates a structural race (or a structural fix) from a flaky window, and it is the strongest witness a race claim carries (a live verification drove a restore/delete interleaving 40 rounds per arm and read the verdict off the split).
|
||||
|
||||
**When the authority you need is unreachable, triangulate and label — do not guess and do not just give up.** A claim resting on an external service or an absent platform has a middle path between "confirmed" and "cannot tell": corroborate via the vendor's own tracker, an in-repo sibling convention, and a monotonic-safety argument (the change can only tighten, never widen); or model the blamed platform deviation locally and show the mechanism reproduces and the fix removes it. Either way, DECLARE the stub — "verified against a model of X, not X" is a different claim from "verified", and writing the first as the second is how a wrong platform assumption ships.
|
||||
**When the authority you need is unreachable, triangulate and label — do not guess and do not just give up.** A claim resting on an external service or an absent platform has a middle path between "confirmed" and "cannot tell": corroborate via the vendor's own tracker, an in-repo sibling convention, and a monotonic-safety argument (the change can only tighten, never widen); model the blamed platform deviation locally and show the mechanism reproduces and the fix removes it; or, when the surface itself is gated off this machine — a darwin-only route, an OS branch that never executes here — drive the same code one level down, the internal module the surface delegates to (or the compiled artifact), against its real dependencies, and rule on that layer (a live verification exercised a darwin-only HTTP surface this way — the compiled resolver one level below it — and named the surface itself untested). Either way, DECLARE the stub or the layer — "verified against a model of X, not X" and "the resolver driven directly; the gated surface above it not exercised" are different claims from "verified", and writing either of them as the plain word is how a wrong platform assumption ships.
|
||||
|
||||
**Leave the tree as you found it** — delete any probe file and revert any fix you applied for the self-check, so nothing you wrote reaches the diff or the build. A finding you actually probed carries \`Source: [probe]\` with the observed evidence; never tag one you only reasoned about — that source means "a run produced this", and downstream treats it as deterministic.
|
||||
|
||||
**Each row of a run matrix starts from fresh state.** The tree rule above is about files; the runs themselves have a state of their own. When you drive a stateful target through several inputs in sequence — a daemon's session table, transcripts on disk, a cache, a lock — give every row an id, a fixture, and a directory the target has never seen: a reused id turns a later row into an attach to what an earlier row left behind, and the contamination reads as a result on both arms (a live verification reused one session id across a behaviour matrix and discarded its whole first pass). When one row must deliberately consume what an earlier row produced, that is the base-produces/PR-consumes handoff below — name it as that; do not stumble into it.
|
||||
|
||||
**When the claim is about a CHANGE in behaviour, one tree cannot settle it — build the other one.** A probe runs the PR's code, which answers "what does it do now". It cannot answer "and what did it do before", and a whole class of finding is exactly that difference: "this changes the output format", "this only adds a field", "this silently drops the error message", "cancelled and failed used to be indistinguishable". Reading the diff to recover the old behaviour is the step that goes wrong quietly — the new lines are always there and always look right, and whether they change what anyone observes routinely turns on code the diff never touches. So when a finding's claim is comparative, get the *before* and measure it:
|
||||
|
||||
\`\`\`bash
|
||||
@@ -662,15 +666,18 @@ For each finding you were given:
|
||||
--out <the plan report's directory>/qwen-review-pr-<n>-base-tree.json
|
||||
\`\`\`
|
||||
|
||||
It builds the merge base in a sibling worktree and reports \`available\` and \`path\`. Then run **the same input** in both trees — the same command, the same fixture, the same script — and compare the observed output byte for byte. The three rules that make this evidence:
|
||||
It builds the merge base in a sibling worktree and reports \`available\` and \`path\`. Then run **the same input** in both trees — the same command, the same fixture, the same script — and compare the observed output byte for byte. The rules that make this evidence:
|
||||
|
||||
- **Prove the arm before trusting the run.** Before an A/B observation counts, confirm each artifact actually contains (PR side) or lacks (base side) the change — grep the built output for a string the diff introduces. And if your comparator reports "no difference", first show it CAN report one (feed it two runs known to differ): a dead comparator and a true no-op read identically.
|
||||
- **Same input, same procedure, both sides.** A difference produced by running two different things is not a difference between the two programs. If you had to build or install differently on one side, say so and treat the result as inconclusive.
|
||||
- **For a compatibility claim, let base produce and PR consume.** "Existing transcripts keep loading", "no migration needed", "what the old version wrote stays valid" — the same-input shape cannot test these, because the input is not a fixture you have; it is what the OLD arm creates. Run the base arm first and let it produce the persisted state — the file, the transcript, the config — then point the PR arm at that same state and consume it. "Created on base, unopenable on PR" is a verdict the symmetric shape can never produce (a live verification created sessions on the base daemon and loaded them against the same home on the PR daemon — the only shape that surfaced a reserved-value session permanently unopenable after the change).
|
||||
- **Quote both outputs.** \`BASE: <what it printed>\` / \`PR: <what it printed>\`. The observation is the verdict; a summary of it is a reading again.
|
||||
- **A/B is expensive — spend it on a claim that turns on it.** An install and a build (the command reuses an already-built base tree; shards that race the first build may both pay). A finding you can settle by tracing does not need this, and \`available: false\` (no merge base, a stale one, or a base that does not build) is a fact about the harness, never a finding against the PR.
|
||||
|
||||
A finding an A/B settled carries \`Source: [probe]\` like any other run-produced evidence, with both sides' output quoted. **Do not remove the base tree** — \`cleanup\` sweeps it at the end of the review, and a later finding may need it.
|
||||
|
||||
**When the claim is about a version you are not running, fetch that version — the A/B's other axis is not always git.** "This handles the next major", "this survives the runtime's next release", "this parses the format's new field" cannot be settled on the one runtime, dependency or format version the harness happens to have, and a green CI does not settle it either: a matrix is evidence about the versions in the matrix. Install the version the claim names, run the **smallest discriminator** on both — often one expression, not a suite — and quote both outputs. Measured: a heap-space classifier written against the eleven space names Node 22 reports covers them with nothing left over, and silently drops the two more that Node 24 adds. This is cheap where \`base-tree\` is not — a download and one \`-e\`, no dependency install and no build — so keep it to the versions the claim itself names (a claim about a support range names its floor and its newest, which is two runs, not a matrix), and a version you cannot fetch is \`witness: not run — <why>\` like any other unreachable claim.
|
||||
|
||||
**When the claim is about what a WORKFLOW does, run the step — do not read the YAML.** A finding against a CI workflow ("this step posts the wrong body", "the sanitizer is bypassed on this path", "this only changed a log line") is a claim about a shell script that happens to live inside YAML, and reading it in place is where workflow review goes wrong quietly: the \`run:\` body is indented inside a block scalar, the \`env:\` that decides its behaviour is spread over three levels (workflow, job, step — nearest wins, and two of them are nowhere near the step), and every \`\${{ … }}\` is a hole the reader fills in from imagination. Lift it out instead:
|
||||
|
||||
\`\`\`bash
|
||||
@@ -696,6 +703,8 @@ until [ -s <plan dir>/mock.json ]; do sleep 0.1; done # its port is in that rep
|
||||
|
||||
For anything that is not one of those two wires — the project's own HTTP service, an MCP server, an OAuth endpoint — stand it up yourself and let \`drive\` own the lifecycle.
|
||||
|
||||
**When the observable is an AGGREGATE, change the population rather than instrumenting the reader.** A total, a maximum across children, a count over a fleet — a claim about how one of those combines cannot be settled from a single reading, and the obvious repair (add a per-component dump and read that) costs the verdict its standing, because the numbers then come out of a build you edited. Shrink the contributing population instead: read the aggregate with every contributor live, remove exactly one — kill the process, unregister the workspace, drop the feed — and read it again. Both numbers come from unmodified code, and what they mean depends on the combining rule you are testing for: doubled with the population is a sum, flat is not a sum, and reducing the population to a single contributor makes the reading that contributor's own value outright. Only for a sum is the **difference** a contributor's value; under a maximum, removing a non-holder moves nothing and removing the holder exposes the next-largest. Identify what you removed by something the product did not choose for you — a process's own working directory, its port, its registered id — because removing the one you assumed answers a different question than the one asked.
|
||||
|
||||
**When the claim is about GITHUB's behaviour, neither tree can settle it — only GitHub can.** A claim like "this encoding renders identically and can never ping", "GitHub strips this tag", "this markdown shape closes the fold" is about the comment pipeline's parser, sanitizer allowlist and notification path, none of which exist in this environment — a local markdown library is a model of GitHub, and judging a sanitizer claim against a model of the authority is exactly the parser-divergence failure under review. Measured live: an \`@\` → \`@\` defusal read as sound in every local trace, and GitHub's real renderer registered the mention and fired the notification. So:
|
||||
|
||||
- **If the environment variable \`QWEN_REVIEW_SCRATCH_REPO\` is set** (an \`owner/repo\` the user designated for disposable test posts), you may adjudicate on the real renderer: post the payload as an issue comment there — \`gh api repos/$QWEN_REVIEW_SCRATCH_REPO/issues/<n>/comments -f body=@<file>\` against an issue you created there for this purpose — read it back with \`-H "Accept: application/vnd.github.html+json"\`, and rule on the returned HTML (and, for mention claims, the timeline events). The observation is the verdict; quote it. This is the ONLY write destination other than \`submit\`'s that any part of this review may touch, it is user-designated, and nothing about the PR under review, its code, or its authors may appear in what you post there — post the minimal payload shape, not the report.
|
||||
@@ -707,7 +716,7 @@ Return, for each finding, one verdict:
|
||||
- **confirmed (low confidence)** — the mechanism is real but the trigger is uncertain (timing, environment, configuration). Say what would confirm it. Carry the severity.
|
||||
- **rejected** — the code does not do what the finding claims (**quote the contradicting code**), or it matches an Exclusion Criterion (one-line reason).
|
||||
|
||||
**A confirmed Critical returns its witness.** Alongside the verdict, include a \`witness:\` line quoting the observed output that settled it — the probe's two sides, the A/B's \`BASE:\`/\`PR:\` pair, the extracted step's run, the sweep count — trimmed to the deciding lines. When every run-capability above is genuinely inapplicable and the confirmation rests on the trace alone, write the one line \`witness: not run — <why no run could settle this claim>\` instead; writing that line is also the moment you notice when the claim was runnable after all. This is mechanical downstream — enforced in code at the findings canonicalization, not merely by the orchestrator's read of its rules: a confirmed Critical returning neither the witness nor the reason line is filed at **low confidence** — terminal-only, never posted — whatever your prose argued, because the evidence a run produced is the one part of a Critical its author can act on without re-deriving the bug.
|
||||
**A confirmed Critical returns its witness.** Alongside the verdict, include a \`witness:\` line quoting the observed output that settled it — the probe's two sides, the A/B's \`BASE:\`/\`PR:\` pair, the extracted step's run, the sweep count, the two versions' outputs, the table's set difference, the two readings either side of a removal — trimmed to the deciding lines. When every run-capability above is genuinely inapplicable and the confirmation rests on the trace alone, write the one line \`witness: not run — <why no run could settle this claim>\` instead; writing that line is also the moment you notice when the claim was runnable after all. This is mechanical downstream — enforced in code at the findings canonicalization, not merely by the orchestrator's read of its rules: a confirmed Critical returning neither the witness nor the reason line is filed at **low confidence** — terminal-only, never posted — whatever your prose argued, because the evidence a run produced is the one part of a Critical its author can act on without re-deriving the bug.
|
||||
|
||||
**Rejecting a Critical carries a higher bar than anything else, and it is one-way.** A rejected Critical is gone — no later stage revisits it, it vanishes from both the pull request and the terminal. To reject one you must **quote the specific code that contradicts the claim**. A passing test, a plausible-looking guard, or "I could not reproduce the reasoning" is not enough — when you cannot quote the contradiction, the floor is \`confirmed (low confidence)\`, never rejection. Downgrading is reversible; a human still sees a low-confidence finding under "Needs Human Review". Rejection is not.
|
||||
|
||||
@@ -739,7 +748,7 @@ The asymmetry cuts both ways: confirming also requires the trace, and a finding
|
||||
- **Report only Critical or Suggestion.** Do not report Nice to have.
|
||||
- A found gap uses the standard finding format (with \`Source: [review]\`), including its failure scenario — your findings go through the same verification as any other, so they must carry the evidence a verifier can trace.
|
||||
|
||||
If you find no new gap in your scope, your WHOLE return is the receipt — exactly one line, the no-issues phrase, a dash, and a clause that names what you re-examined, opening with the walk (\`re-walked\` / \`verified\` / \`traced\` — 走查 / 复核 / 核对), as in \`${REVERSE_AUDIT_EXAMPLE_RECEIPT}\`. Nothing else may ride in the return but the \`Budget gap:\` and \`Layer walked:\` lines this brief already mandates: any other prose — before the receipt line, after it, or hedged inside its clause — reads as "not dry", because prose has no last hedge and the tooling will not guess which ones are harmless. If any part of your scope went unexamined — a file you could not open, a walk the ceiling cut short — do NOT emit the receipt: say what you did not walk. That keeps the territory under audit, which is the honest outcome; the receipt certifies only a walk that happened. A bare "No issues found." is indistinguishable from an agent that did nothing, and it is treated as one: it ends nothing, and it earns your scope a relaunch.`,
|
||||
If you find no new gap in your scope, your WHOLE return is the receipt — exactly one line, the no-issues phrase, a dash, and a clause that names what you re-examined, opening with the walk (\`re-walked\` / \`verified\` / \`traced\` — 走查 / 复核 / 核对), as in \`${REVERSE_AUDIT_EXAMPLE_RECEIPT}\`. The clause narrates the walk in the walk's own words and NEVER restates the all-clear — no \`no issues…\` / 未发现问题… inside the clause, not even as the walk's object (\`verified no issues in X\`): a restatement proves no walk, and the tooling reads the return as "not dry". Nothing else may ride in the return but the \`Budget gap:\` and \`Layer walked:\` lines this brief already mandates: any other prose — before the receipt line, after it, or hedged inside its clause — reads as "not dry", because prose has no last hedge and the tooling will not guess which ones are harmless. If any part of your scope went unexamined — a file you could not open, a walk the ceiling cut short — do NOT emit the receipt: say what you did not walk. That keeps the territory under audit, which is the honest outcome; the receipt certifies only a walk that happened. A bare "No issues found." is indistinguishable from an agent that did nothing, and it is treated as one: it ends nothing, and it earns your scope a relaunch.`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -19,12 +19,15 @@
|
||||
// `{"comment":{"effective":true}}` to any file and point at it; it cannot
|
||||
// retroactively edit the user's own keystrokes.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
skillArgsPath,
|
||||
currentSessionId,
|
||||
SKILL_ARGS_DIR,
|
||||
} from '../../../services/skill-args-file.js';
|
||||
import { parseReviewArgs } from '../parse-args.js';
|
||||
import { isOwnerRepo } from './gh.js';
|
||||
|
||||
/**
|
||||
* Where the CLI records a skill's invocation arguments, verbatim, before the
|
||||
@@ -85,6 +88,132 @@ export interface WriteAuthorizationRequest {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the recorded-args lookup found for THIS write's target.
|
||||
* - `host`: the recorded host to bind the refusal on, when one exists.
|
||||
* - `unbound`: a recording naming the same PR exists but yields NO host
|
||||
* evidence (bare-number spellings without a `--host` flag). The target's
|
||||
* platform is then unprovable from the recording — for a public,
|
||||
* irreversible write the gate fails CLOSED on this rather than trusting
|
||||
* the runtime environment alone (a bare-number Aone review recorded in
|
||||
* an Aone clone otherwise posts at github.com's same-named repo from a
|
||||
* non-Aone cwd — the canonical Aone invocation shape carries no URL).
|
||||
*/
|
||||
interface RecordedHostLookup {
|
||||
host?: string;
|
||||
unbound: boolean;
|
||||
}
|
||||
|
||||
/** Bound on a recorded-args read: the files are one-line CLI invocations;
|
||||
* anything bigger is not a recording this code wrote and must not be
|
||||
* slurped (a planted symlink to an endless source would otherwise hang
|
||||
* the publish). */
|
||||
const RECORDED_ARGS_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Lookup of the recorded target's host for the `--user-authorized` fast
|
||||
* path — it must publish without running the full gate, but the write
|
||||
* gate's platform binding must not lose the host the recorded target
|
||||
* names.
|
||||
*
|
||||
* The host is bound to THIS write: only a recording naming the same PR
|
||||
* number AND the same repo supplies a host — a stale recording of a
|
||||
* different target must not supply one (the refusal would fire on the
|
||||
* wrong target, or a stale host would suppress the environment arms). A
|
||||
* bare-number recording of the same PR supplies the recorded `--host`
|
||||
* flag when present — that spelling carries no URL host, and the flag is
|
||||
* the only recorded platform evidence.
|
||||
*
|
||||
* The scan is HARDENED — the store lives under `.qwen/tmp/`, which also
|
||||
* holds review worktrees checked out from the PR's own tree, so the
|
||||
* content there is attacker-influenceable:
|
||||
* - only `s-*` session directories are scanned (never `review-pr-*`
|
||||
* worktrees or anything else a reviewed PR can plant);
|
||||
* - symlinks are skipped at both the directory and the file level
|
||||
* (mirroring writeSkillArgs' O_NOFOLLOW policy on the write side of
|
||||
* this same store) — a planted link must not be followed;
|
||||
* - reads are size-bounded (RECORDED_ARGS_MAX_BYTES).
|
||||
*
|
||||
* Lookup order: the session-scoped args file first, then the sibling
|
||||
* session directories (sorted). The args file is named for the session
|
||||
* that recorded the review, and a `--user-authorized` publish
|
||||
* characteristically runs in a DIFFERENT session ("post the review we
|
||||
* saved") — without the sibling scan the file is simply absent there and
|
||||
* a recorded Aone target posts at github.com's same-named repo. Any
|
||||
* read/parse trouble still degrades gracefully and never blocks a
|
||||
* user-authorised publish.
|
||||
*/
|
||||
function lookupRecordedHost(
|
||||
req: WriteAuthorizationRequest,
|
||||
): RecordedHostLookup {
|
||||
const bindHost = (raw: string): string | undefined | null => {
|
||||
// undefined = matches this write but yields no host (bare number, no
|
||||
// recorded --host); null = not this write's recording.
|
||||
try {
|
||||
const parsed = parseReviewArgs(raw, { comment: req.defaultComment });
|
||||
const t = parsed.target;
|
||||
if (t.type === 'pr-url') {
|
||||
return t.number === req.pr && `${t.owner}/${t.repo}` === req.repo
|
||||
? t.host
|
||||
: null;
|
||||
}
|
||||
if (t.type === 'pr-number' && t.number === req.pr) {
|
||||
return parsed.host;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const isReadableRecording = (path: string): boolean => {
|
||||
try {
|
||||
if (lstatSync(path).isSymbolicLink()) return false;
|
||||
return statSync(path).size <= RECORDED_ARGS_MAX_BYTES;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const candidates: string[] = [
|
||||
currentSessionId() === '' && req.skillArgs
|
||||
? req.skillArgs
|
||||
: defaultSkillArgsPath(),
|
||||
];
|
||||
try {
|
||||
const entries = readdirSync(SKILL_ARGS_DIR, {
|
||||
withFileTypes: true,
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
// Session directories ONLY — `.qwen/tmp/` also holds review
|
||||
// worktrees materialized from the reviewed PR's own tree; their
|
||||
// content is attacker-controlled and must never supply a host.
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
||||
if (!/^s-/.test(entry.name)) continue;
|
||||
candidates.push(
|
||||
join(SKILL_ARGS_DIR, entry.name, 'qwen-skill-args-review.txt'),
|
||||
);
|
||||
}
|
||||
candidates.push(join(SKILL_ARGS_DIR, 'qwen-skill-args-review.txt'));
|
||||
} catch {
|
||||
// No recorded-args directory at all — the session-scoped candidate
|
||||
// above is the only one.
|
||||
}
|
||||
let sawSamePrRecording = false;
|
||||
for (const path of candidates) {
|
||||
if (!isReadableRecording(path)) continue;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const bound = bindHost(raw);
|
||||
if (bound === null) continue;
|
||||
sawSamePrRecording = true;
|
||||
if (bound !== undefined) return { host: bound, unbound: false };
|
||||
}
|
||||
return { host: undefined, unbound: sawSamePrRecording };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly three things authorise a public write, and all are facts rather than
|
||||
* impressions: `--comment` in the arguments the user typed (re-parsed from the
|
||||
@@ -96,9 +225,42 @@ export interface WriteAuthorizationRequest {
|
||||
export function reviewWriteAuthorization(req: WriteAuthorizationRequest): {
|
||||
ok: boolean;
|
||||
why: string;
|
||||
/**
|
||||
* The host the recorded target names, when it names one: a pr-url target
|
||||
* carries it; a bare pr-number supplies a recorded `--host` flag or none.
|
||||
* The `--user-authorized` fast path reads it best-effort from the
|
||||
* recorded args (below) for the same reason the slow path does. Write
|
||||
* gates that must reason about the target's PLATFORM read it here
|
||||
* instead of re-deriving the platform from the runtime environment alone
|
||||
* — the effective host can be steered by an ambient GH_HOST export away
|
||||
* from where the recorded review actually lives (submit's Aone refusal
|
||||
* uses it to stay shut in both directions).
|
||||
*/
|
||||
recordedHost?: string;
|
||||
/**
|
||||
* A recording naming this PR exists but yields NO host evidence (see
|
||||
* lookupRecordedHost). The platform is unprovable from the recording;
|
||||
* the write gate fails closed on this arm rather than trusting the
|
||||
* runtime environment alone. Absent on the refusal paths.
|
||||
*/
|
||||
recordedUnbound?: boolean;
|
||||
} {
|
||||
if (req.userAuthorized) {
|
||||
return { ok: true, why: 'the user asked for this review to be published' };
|
||||
const lookup = lookupRecordedHost(req);
|
||||
return {
|
||||
ok: true,
|
||||
why: 'the user asked for this review to be published',
|
||||
// The fast path publishes because the user asked — but it must still
|
||||
// surface the recorded target's host: the write gate's platform
|
||||
// binding keys on it, and skipping it here re-opens the exact leak
|
||||
// the binding exists to close (a recorded Aone codereview target,
|
||||
// user-authorised from a non-Aone cwd with no --host/GH_HOST, would
|
||||
// otherwise post at github.com's same-named repo). Best effort: any
|
||||
// read/parse trouble degrades gracefully (see lookupRecordedHost)
|
||||
// and never blocks a user-authorised publish.
|
||||
recordedHost: lookup.host,
|
||||
recordedUnbound: lookup.unbound,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionScoped = defaultSkillArgsPath();
|
||||
@@ -195,5 +357,144 @@ export function reviewWriteAuthorization(req: WriteAuthorizationRequest): {
|
||||
why: verdict.comment.requested
|
||||
? `\`--comment\` was in the review arguments for #${authorisedPr}`
|
||||
: `\`review.comment\` is enabled in settings, and the review arguments name #${authorisedPr}`,
|
||||
// Mirror of the fast-path binding: a bare-number recording supplies
|
||||
// the recorded `--host` flag (its only host evidence). The UNBOUND
|
||||
// fail-closed does NOT ride the slow path: a same-session Aone review
|
||||
// runs inside an Aone clone, so the write gate's cwd arm already
|
||||
// refuses it — marking every bare-number slow-path recording unbound
|
||||
// would refuse the canonical same-session github posting flow instead.
|
||||
recordedHost: t.type === 'pr-url' ? t.host : verdict.host,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort recovery of the operator's recorded posting floor, shared by
|
||||
* the two boundaries that must resolve the floor from the CLI's verbatim
|
||||
* record rather than the model-written state: `submit` (the posting write)
|
||||
* and `compose-review`'s CLI handler (the archived composed JSON and the
|
||||
* terminal verdict). Both resolving through this ONE function — with the
|
||||
* SAME identity source — is what keeps the registered artifact and the
|
||||
* posted review describing the same floor.
|
||||
*
|
||||
* **The identity is the CALLER'S CLI-typed one first; the plan only fills
|
||||
* the axes the caller did not supply.** The plan's CONTENT is CLI-written,
|
||||
* but its PATH arrives through the model-written state JSON — the same
|
||||
* document whose floor copy this recovery exists to outrank — so a
|
||||
* plan-first precedence let a parseable-but-wrong plan choose which
|
||||
* identity the operator's verbatim record was tested against and silently
|
||||
* stand the recovery down. Caller-first closes that: at submit the caller
|
||||
* pr is additionally gate-bound to the recorded target on the `--comment`
|
||||
* path, and both boundaries are fed the same caller identity by the skill
|
||||
* (`--pr`/`--repo`/`--host` at compose mirroring submit's own flags), so
|
||||
* the two recoveries still resolve one floor for one review.
|
||||
*
|
||||
* The record is bound to that identity at the SAME bar the `--comment`
|
||||
* authorisation applies to the same record: the number always, and — for a
|
||||
* URL-shaped record — the repo (when an identity repo is known) and the
|
||||
* host, both case-insensitive with an absent host reading as github.com.
|
||||
* The record is last-writer-wins (`writeSkillArgs` truncates), so a later
|
||||
* `/review` of a different PR — or the same number in a DIFFERENT repo —
|
||||
* must recover nothing.
|
||||
*
|
||||
* Returns the floor with its source only when the record carries an
|
||||
* operator decision (`severityFloorSource` of `explicit`/`configured`) —
|
||||
* the source rides along so the boundaries' audit notes can name the true
|
||||
* origin instead of claiming a flag the operator never typed. A
|
||||
* default-resolved `auto` (including one produced by silently discarding an
|
||||
* invalid configured value) is not a decision and recovers nothing. Every
|
||||
* failure mode — no plan PR, no record, unreadable, no decision, another
|
||||
* PR's or repo's record — returns undefined and leaves the caller's state
|
||||
* value standing, the same fail-open direction enforcement itself takes.
|
||||
* The path rule is the gate's own: the caller-supplied seam is honoured
|
||||
* only when no session id is present.
|
||||
*/
|
||||
export function recordedSeverityFloor(opts: {
|
||||
/** The plan of the review being composed or posted — CLI-written content
|
||||
* behind a model-written path, so it only FILLS identity axes the caller
|
||||
* did not supply, never overrides them. */
|
||||
planPath?: string;
|
||||
/** The caller's CLI-typed PR number — the identity's first source. */
|
||||
callerPr?: number;
|
||||
/** The caller's repo — the URL-record bar's first repo source, the plan's
|
||||
* `ownerRepo` filling in when absent. */
|
||||
callerRepo?: string;
|
||||
/** The caller's EFFECTIVE host. Never plan-filled: absent means
|
||||
* github.com by the gate's own rule, so there is no gap to fill — the
|
||||
* axis where absence is meaningful must not read absence as a gap. */
|
||||
callerHost?: string;
|
||||
defaultSeverityFloor?: string;
|
||||
skillArgs?: string;
|
||||
}):
|
||||
| {
|
||||
floor: 'critical' | 'suggestion' | 'auto';
|
||||
source: 'explicit' | 'configured';
|
||||
}
|
||||
| undefined {
|
||||
let planPr: number | undefined;
|
||||
let planRepo: string | undefined;
|
||||
try {
|
||||
if (opts.planPath) {
|
||||
const plan = JSON.parse(readFileSync(opts.planPath, 'utf8')) as {
|
||||
prNumber?: unknown;
|
||||
ownerRepo?: unknown;
|
||||
};
|
||||
const n = plan?.prNumber;
|
||||
if (typeof n === 'number' && Number.isInteger(n) && n > 0) planPr = n;
|
||||
else if (typeof n === 'string' && /^\d+$/.test(n)) planPr = Number(n);
|
||||
if (typeof plan?.ownerRepo === 'string' && isOwnerRepo(plan.ownerRepo)) {
|
||||
planRepo = plan.ownerRepo;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* the identity falls back to the caller's, exactly as with no plan */
|
||||
}
|
||||
const pr = opts.callerPr ?? planPr;
|
||||
if (pr === undefined) return undefined;
|
||||
const repo = opts.callerRepo ?? planRepo;
|
||||
// The host axis is NEVER plan-filled: an absent caller host IS github.com
|
||||
// by the gate's own rule ("an absent req.host means the write routes at
|
||||
// github.com — a host like any other, not an exemption"), so there is no
|
||||
// gap for the plan to fill — and a gap-read here handed the model-pathed
|
||||
// plan the one identity axis the mandatory caller flags did not pin.
|
||||
const host = (opts.callerHost ?? 'github.com').toLowerCase();
|
||||
const path =
|
||||
currentSessionId() === '' && opts.skillArgs
|
||||
? opts.skillArgs
|
||||
: defaultSkillArgsPath();
|
||||
try {
|
||||
const verdict = parseReviewArgs(readFileSync(path, 'utf8'), {
|
||||
severityFloor: opts.defaultSeverityFloor,
|
||||
});
|
||||
const t = verdict.target;
|
||||
if (t.type === 'pr-number') {
|
||||
if (t.number !== pr) return undefined;
|
||||
} else if (t.type === 'pr-url') {
|
||||
if (t.number !== pr) return undefined;
|
||||
// A URL-shaped record NAMES a repo, so the repo bar is part of its
|
||||
// identity — and an unknown identity repo cannot check it. Skipping
|
||||
// the comparison there (the gate's shape, whose only repo-less
|
||||
// caller is publish-assets writing to a DESIGNATED assets repo) let
|
||||
// another repo's record bind on number and host alone: the record is
|
||||
// last-writer-wins, so a `/review other/repo#123` in the session
|
||||
// could hand its floor to this repo's #123. Unknown repo therefore
|
||||
// recovers nothing — the same direction every other doubt state
|
||||
// takes here, leaving the state's floor standing.
|
||||
if (
|
||||
repo === undefined ||
|
||||
`${t.owner}/${t.repo}`.toLowerCase() !== repo.toLowerCase()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (t.host.toLowerCase() !== host) return undefined;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
if (verdict.severityFloorSource === 'default') return undefined;
|
||||
return {
|
||||
floor: verdict.severityFloor,
|
||||
source: verdict.severityFloorSource,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -453,6 +453,83 @@ describe('budgetGapDisclosures — the one parser of the disclosure format', ()
|
||||
}
|
||||
});
|
||||
|
||||
it('strips full-width parens too — a CJK IME wraps the same no-answer', () => {
|
||||
// The strip knew only the ASCII pair, so `(无 — 所有检查均完成)`
|
||||
// survived as a phantom gap in exactly the output language the ZH
|
||||
// branch exists for — the #9094 incident shape, wearing the paren
|
||||
// form a Chinese keyboard produces by default.
|
||||
for (const line of [
|
||||
'Budget gap: (无 — 所有检查均完成)',
|
||||
'Budget gap: (无)',
|
||||
'预算缺口:(没有跳过的检查)',
|
||||
]) {
|
||||
expect(budgetGapDisclosures(line)).toEqual([]);
|
||||
}
|
||||
// A wrapped placeholder's inner tail judges identically to the bare
|
||||
// form: bare `无?`/`无、` lose those tails to the fold-key strip before
|
||||
// the classifier sees them, so the wrapped forms drop too — identical
|
||||
// content cannot split bare-vs-wrapped.
|
||||
for (const line of ['Budget gap: (无?)', 'Budget gap: (无、)']) {
|
||||
expect(budgetGapDisclosures(line)).toEqual([]);
|
||||
}
|
||||
// Only a SYMMETRIC pair unwraps, and a real gap inside full-width
|
||||
// parens survives — over-disclosure is the safe direction.
|
||||
expect(
|
||||
budgetGapDisclosures('Budget gap: (无法验证 Windows 矩阵的集成测试)'),
|
||||
).toEqual(['(无法验证 Windows 矩阵的集成测试)']);
|
||||
});
|
||||
|
||||
it('closes the same split for ENGLISH no-answers wearing a CJK tail', () => {
|
||||
// The normalize strip (TRAILING_GAP_CHAR_RE) is bilingual, so bare
|
||||
// `none。` judges as `none` and drops — but a wrapped twin kept its
|
||||
// inner tail: the EN tail classes in PLACEHOLDER_GAP_RE are ASCII-only,
|
||||
// so `(none。)` survived as a phantom gap while its identical bare form
|
||||
// dropped. The exact bare-vs-wrapped split the test above closed for
|
||||
// the ZH branch, left open on the EN one — measured by the review on
|
||||
// this PR: under a CJK output language `Budget gap: (none。)` spends a
|
||||
// MAX_GAPS_PER_AGENT slot and an orchestrator ruling on a no-answer.
|
||||
// The inner text must judge character-for-character as its bare twin,
|
||||
// so the wrapped forms drop too — in both paren shapes.
|
||||
for (const line of [
|
||||
'Budget gap: none。',
|
||||
'Budget gap: (none。)',
|
||||
'Budget gap: (none。)',
|
||||
'Budget gap: none - stayed under budget。',
|
||||
'Budget gap: (none - stayed under budget。)',
|
||||
'Budget gap: (N/A - stayed under budget。)',
|
||||
'Budget gap: (none — all checks completed。)',
|
||||
]) {
|
||||
expect(budgetGapDisclosures(line)).toEqual([]);
|
||||
}
|
||||
// A REAL gap keeps its CJK tail in both forms — the strip equalizes
|
||||
// judgment, never swallows.
|
||||
expect(budgetGapDisclosures('Budget gap: (渗透测试未进行。)')).toEqual([
|
||||
'(渗透测试未进行。)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('folds a Chinese gap restated with and without a trailing full stop', () => {
|
||||
// The fold key promises one disclosure per gap; a key that stripped only
|
||||
// ASCII trailing punctuation kept `渗透测试未进行。` and `渗透测试未进行`
|
||||
// as two, double-spending MAX_GAPS_PER_AGENT slots.
|
||||
const text = [
|
||||
'Budget gap: 渗透测试未进行。',
|
||||
'some other line',
|
||||
'Budget gap: 渗透测试未进行',
|
||||
].join('\n');
|
||||
expect(budgetGapDisclosures(text)).toEqual(['渗透测试未进行。']);
|
||||
|
||||
// Both CJK full stops — 。(U+3002) and .(U+FF0E) — are ZH_TAIL
|
||||
// characters, and the fold key must strip both: covering only one left
|
||||
// the double-spend open for the other.
|
||||
const ff0e = [
|
||||
'Budget gap: 渗透测试未进行.',
|
||||
'some other line',
|
||||
'Budget gap: 渗透测试未进行',
|
||||
].join('\n');
|
||||
expect(budgetGapDisclosures(ff0e)).toEqual(['渗透测试未进行.']);
|
||||
});
|
||||
|
||||
it('keeps a REAL Chinese gap — 无法 is a prefix of the token, not the token', () => {
|
||||
// Chinese has no word boundary, so a token is only a token when
|
||||
// punctuation, whitespace or end-of-text follows it. `无法验证…`
|
||||
|
||||
@@ -742,7 +742,10 @@ const ZH_DONE = '(?:均|都|皆)?(?:已|均已|都已)?(?:完成|完毕|结束|
|
||||
const ZH_BUDGET_TAIL =
|
||||
'(?:[,,、]?\\s*(?:未触及|未达到|未超出|未用尽|没有触及|在|不超过)' +
|
||||
'(?:工具)?(?:调用)?预算(?:上限|限制|范围内)?)?';
|
||||
const ZH_TAIL = '[。..!!…,,;;::\\s]*$';
|
||||
// ? (U+FF1F) and 、 (U+3001) mirror the fold key's trailing strip:
|
||||
// a wrapped placeholder's inner text must judge identically to its
|
||||
// bare form, which loses those tails before the classifier sees it.
|
||||
const ZH_TAIL = '[。..!!…,,;;::?、\\s]*$';
|
||||
// NO whitespace between the pieces. Four optional groups chained across `\s*`
|
||||
// is the overlapping-quantifier shape this module's header bans and its
|
||||
// 'stays linear on pathological inputs' test exists for — and Chinese does not
|
||||
@@ -818,7 +821,15 @@ function stripWrappers(s: string): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
const TRAILING_GAP_CHAR_RE = /[.!…,;:\s]/;
|
||||
// Both scripts' trailing punctuation: the fold key promises that a gap
|
||||
// restated with and without a trailing stop discloses once, and a key that
|
||||
// stripped only the ASCII set kept `渗透测试未进行。` and `渗透测试未进行` as
|
||||
// two gaps — double-spending MAX_GAPS_PER_AGENT slots in exactly the
|
||||
// output language the ZH branch exists for. The class mirrors ZH_TAIL's
|
||||
// full stop set — including the fullwidth full stop .(U+FF0E) beside 。
|
||||
// (U+3002): the classifier treats both as trailing, and a fold key that
|
||||
// dropped only one of them kept the double-spend open for the other.
|
||||
const TRAILING_GAP_CHAR_RE = /[.!…,;:\s。,;:!?、.]/;
|
||||
|
||||
/** Trailing punctuation/whitespace strip for the normalize and fold keys. */
|
||||
function stripTrailingGapChars(s: string): string {
|
||||
@@ -882,17 +893,30 @@ export function budgetGapDisclosures(finalText: string): string[] {
|
||||
const normalized = stripTrailingGapChars(raw).trim();
|
||||
// Judged on the paren-stripped text, bare and wrapped alike, by the
|
||||
// one strict classifier — its doc names why the shapes are narrow.
|
||||
const unparenthesized =
|
||||
normalized.startsWith('(') && normalized.endsWith(')')
|
||||
// Both paren shapes: under `outputLanguage: 中文` the full-width pair
|
||||
// (U+FF08/U+FF09)is what an IME produces, and a strip that knew only
|
||||
// the ASCII pair let `(无 — 所有检查均完成)` through as a phantom gap —
|
||||
// the #9094 incident shape this classifier exists to kill, surviving
|
||||
// in exactly the output language the ZH branch was added for. Only a
|
||||
// SYMMETRIC pair is unwrapped, so a mixed or unbalanced wrap stays
|
||||
// whole and errs toward disclosure. The inner text is trailing-stripped
|
||||
// exactly as the bare form already was (`normalized` above): the EN tail
|
||||
// classes are ASCII-only, so a wrapped `none。` kept its CJK tail and
|
||||
// survived as a phantom while its identical bare twin dropped — one
|
||||
// normalize, one judgment, bare and wrapped alike.
|
||||
const unparenthesized = stripTrailingGapChars(
|
||||
(normalized.startsWith('(') && normalized.endsWith(')')) ||
|
||||
(normalized.startsWith('(') && normalized.endsWith(')'))
|
||||
? normalized.slice(1, -1).trim()
|
||||
: normalized;
|
||||
: normalized,
|
||||
);
|
||||
if (normalized.length === 0 || PLACEHOLDER_GAP_RE.test(unparenthesized)) {
|
||||
continue;
|
||||
}
|
||||
// Folded on the paren-stripped text with its OWN trailing punctuation
|
||||
// gone, so one gap restated with and without parentheses — `(auth
|
||||
// flow untested.)` and `auth flow untested` — discloses once.
|
||||
const key = stripTrailingGapChars(unparenthesized).toLowerCase();
|
||||
// Folded on the same normalized text the classifier judged, so one gap
|
||||
// restated with and without parentheses — `(auth flow untested.)` and
|
||||
// `auth flow untested` — discloses once.
|
||||
const key = unparenthesized.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
gaps.push(truncateGap(raw));
|
||||
|
||||
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user