qwen-code/docs/verification/batch-api
易良 c3a4058a0c
feat(cli): agent-prepared Batch API workflow (/batch-api) (#12492)
* docs: evaluate Bailian Batch API integration and add probe scripts

Feasibility study for using the Bailian (DashScope) Batch API from Qwen Code: why it cannot serve the agent loop (24h window, no streaming, per-request cost math vs prefix cache), the three shapes that do fit (agent-driven fan-out, headless --batch swap, repo CI offline work), the request-level switch needed to keep the 17 side-call purposes on the realtime path, and a blast-radius plan.

Adds docs/verification/batch-api/: four probe scripts (plumbing, tool-call passthrough, cache-hit-in-batch, queue-timing) with a README and pass criteria. Scripts read DASHSCOPE_API_KEY from env only and delete their own uploaded files; they have not been run yet.

* docs(batch-api): add handoff section with next steps and change-point checklists

* feat(cli): add qwen batch command for DashScope Batch API jobs

Adds `qwen batch submit|status|fetch|cancel` so many independent single-turn requests can be run through the DashScope Batch API at half the realtime price. Credentials resolve the same way as the interactive CLI and only OpenAI-compatible key auth is accepted; the command talks to /files and /batches with native fetch, so no new dependency. The agent loop is untouched — this is the fan-out shape from the feasibility study, not a swap of the main request path.

Not run locally: vitest and typecheck are left to CI per repo convention; the command has not been exercised against the live API yet.

* feat(core,cli): add headless --batch mode routing main turns through the Batch API

Adds an experimental `--batch` flag for non-interactive runs (-p, positional prompt, or piped stdin; rejected with -i or in the TUI). When set, the main loop marks its own generateContentStream request with `executionMode: 'batch'` and the OpenAI pipeline sends that one request through files → batches → poll → output file instead of the realtime route, then replays the finished completion as a single stream chunk so tool-call parsing and the rest of the chunk path are unchanged. Side calls (compression, titles, classifiers, judges) never carry the marker, so they stay realtime; Qwen OAuth is refused because that endpoint has no Batch API. Aborting cancels the batch server-side; uploaded and produced files are deleted afterwards; the batch id is written to stderr so a run that dies mid-wait can be recovered with `qwen batch fetch`.

Not in this cut: batch_id persistence / resume across processes, explicit enable_thinking, cost accounting. Not run locally (vitest/typecheck are CI's per repo convention) and not yet exercised against the live API — the tool-passthrough probe in docs/verification/batch-api is the acceptance test.

* fix(cli): list batch in bootstrap help and prettier-format the batch docs

The bootstrap help table must name every top-level command registered in config.ts (cli.test.ts guards it); add batch there and to the guard's mapping. Also run prettier over the feasibility doc and the probe scripts, which the earlier commits had only lint-checked for TypeScript files.

* fix(cli,core): make `qwen batch` and `--batch` actually run

Three defects the unit tests could not see, all found by driving the real
CLI process against a fake DashScope server (added here as
docs/verification/batch-api/).

1. `--batch` never ran at all. `client.files.create` throws "The provided
   fetch function does not support file uploads with the current global
   FormData class" before a request leaves the process: every Node run
   installs a custom fetch on the client (undici's, pinned to the
   timeout-free dispatcher in runtimeFetchOptions.ts), and the SDK's
   multipart probe rejects any such client. Upload through the global
   fetch instead — the same route `qwen batch submit` already takes.

2. `batch` was missing from the subcommand exit list in parseArguments, so
   a handler fell through to the main flow: every subcommand exited 1 with
   "No input provided via stdin" (a TTY would have entered the UI), and the
   memory relaunch below re-parsed argv in the child, running the handler a
   second time — `qwen batch submit` created two batch jobs and billed
   both, returning only the second id.

3. A settled-but-failed batch reported `Batch <id> failed` and nothing
   else: the reason lives in the error file, which the cleanup then
   deleted. Read it before throwing.

Verified with docs/verification/batch-api/regression.sh (26 assertions,
no network): one batch per submit, exit 0 for all four subcommands,
--batch through a two-hop tool call, the provider's reason on failure,
refusal to fetch an unsettled batch, and SIGINT cancelling server-side.

* test(cli): stop the `batch` exit-list case from running the real handler

parseArguments() executes yargs command handlers, so driving it with
`batch status batch_x` reached statusCommand.handler -> resolveEndpoint()
-> getBatch() -> a live fetch. It throws today only because no credentials
are set: getAuthTypeFromEnv returns USE_OPENAI when OPENAI_API_KEY,
OPENAI_MODEL/QWEN_MODEL and OPENAI_BASE_URL are all present -- exactly what
this feature's own docs/verification/batch-api/regression.sh exports -- and a
settings-level selectedType: 'openai' does it too. On such a machine the case
issued a real HTTPS request during parseArguments(), could run past vitest's
5s timeout, and left process.exitCode = 1 set on the worker.

Mock ../commands/batch.js the way this file already mocks
../commands/update.js, and assert the handler ran and that exit was called
with 0, so the case pins the subcommand exit list rather than the network.
Clear process.exitCode in a finally block, matching the neighbouring
`propagates non-zero exitCode from the update handler` case.

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

* fix(core): delete the batch input file when batches.create fails

`fileIds` and the try/finally both began after `client.batches.create`
returned, so a create failure left the already-uploaded -- and already
billed -- input file on the server with nothing left to delete it. This
change exists to stop leaving billed orphan artifacts behind, so move the
try up one statement and let the finally cover create as well.

`batch` is now declared outside the try as possibly-undefined: the catch has
to tell "create threw" (no batch to cancel) from "polling threw", and
reading a try-scoped `let` from the catch would throw and mask the original
error.

Also record in uploadInputFile's doc comment what bypassing the SDK costs
(no maxRetries, no proxy / QWEN_TLS_INSECURE dispatcher, no SDK default or
user customHeaders, no typed APIError with retry-after handling) and that
staying line for line consistent with the `qwen batch submit` uploader in
packages/cli/src/commands/batch.ts is a deliberate choice, not an oversight.

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

* fix(docs): make the batch harness prettier-clean and its R1 assertion non-vacuous

The sandboxed verification of 004b7b9fe8 (run 35311656741, 90 passed / 1 failed)
flagged one failing gate and one assertion that could not fail:

- fake-dashscope.mjs was 47 lines off `prettier --check` and is not in
  .prettierignore, while CI's Run Prettier step is repo-wide
  (`prettier --check .`). Formatting only, no behaviour change.
- regression.sh never cleared QWEN_CODE_NO_RELAUNCH. An outer qwen shell exports
  it to its children, relaunchAppInChildProcess then returns immediately, and
  with the relaunch off the UNFIXED code also creates exactly one batch - so R1
  ("exactly one batch created", labelled "default env, relaunch enabled") passed
  on base as well as on head. This is not hypothetical: QWEN_CODE_NO_RELAUNCH is
  set to `true` in the environment of any qwen-code shell, which is the ordinary
  way this harness gets run. Unsetting it makes R1 assert what it claims.
- README said five scenarios including `slow`; the script starts four servers and
  never passes SLOW_SECONDS, so pollJob's slow branch is uncovered. Say so.

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

* fix(docs): make the batch regression script shellcheck-clean

CI's shellcheck runs with --enable=all --severity=style over every tracked
shell script, which the first draft could not pass.

* fix(docs): restore the QWEN_CODE_NO_RELAUNCH unset lost in the shellcheck rewrite

The shellcheck-clean rewrite of regression.sh was written from the
pre-68695c7 draft and silently dropped the F6 fix, which is what makes
R1's "exactly one batch" mean anything. Verified by running the harness
with QWEN_CODE_NO_RELAUNCH=1 exported: 27 passed, 0 failed.

* fix(core): keep --batch out of derived configs (F4)

The sandbox verification measured two remote jobs for one `qwen -p "say hi"
--batch` run, and the second one is the memory-extraction subagent: it runs
on a config built by `deriveConfig`, which is prototype-delegated, so
`getBatchMode()` reached it from the parent. Every subagent and scoped
context inherited it the same way — each paying its own >=24h window and
queue wait for work the user never asked to defer, and contradicting the
feature's own claim that only the main loop's turn is marked.

A derived config is never the main loop, so `deriveConfig` now pins
`getBatchMode()` to false; an explicit override in `DerivedConfigOverrides`
still wins, for a caller that some day wants a batched subagent.

Measured on the fake server, same command before and after: 2 batch jobs ->
1 batch job + 1 realtime call. Pinned by a unit test and by regression.sh's
new R8 (30 assertions, all passing).

* fix(core): stop a failed poll from creating a second batch job (F3)

A transient error while polling propagated straight out of
runBatchCompletion. The caller's retryWithBackoff retries transport errors
and 429/5xx, and a retry of this function uploads a new input file and
creates a SECOND batch — while the first keeps running, billing, and
unreachable. The `finally` also deleted the still-running job's input file
on the way out.

Poll failures are now absorbed (five consecutive ones before giving up), and
giving up throws a plain error the outer retry will not act on, naming the
job and `qwen batch fetch <id>` to recover it. An abandoned job keeps its
files; an aborted one is still cancelled and cleaned up as before.

Driven end to end, not inferred: the harness's new `unpollable` scenario
fails every status poll, and R9 asserts one job created (not two), the input
file kept, and the recovery hint on stderr. 34 assertions passing.

* fix(core,cli): make the batch give-up non-retryable by type, and gate --batch on DashScope

Two findings from triage on bbf79d1.

Blocking: the "outer retry will not act on it" invariant was a comment. The
give-up messages interpolate provider-authored detail, and this repo's
classifier reads provider payloads out of message TEXT — getErrorCode parses
the first {...}, getErrorStatus matches HTTP_STATUS/ddd, isStatuslessThrottle
sniffs for rate-limit bodies. Measured against the real classifier: a give-up
message carrying a rate_limit_error body, or an HTTP_STATUS/503, classifies
as RETRYABLE. A retry re-enters runBatchCompletion and creates a second paid
job while the first keeps running, unreachable — failure mode #4 verbatim,
and throttling is the expected transient failure across a multi-hour poll.
Every give-up now throws BatchNotRetryableError, and shouldRetryOnError fails
fast on its identity rather than on its text. Three tests pin it, including
one that asserts the plain-Error form WOULD be retried, so the guard cannot
rot into a no-op assertion.

--batch is also now gated on an OpenAI-compatible key at a DashScope endpoint.
executionMode is read by the OpenAI pipeline alone — the Responses, Anthropic,
Gemini and Vertex generators ignore it — so the flag used to be a silent
no-op on those: the run went realtime at full price while the user believed
it was deferred and discounted. Reuses the existing isDashScopeProvider
predicate (study §8.3's fail-fast layer), with loopback allowed for a local
proxy and for the regression harness.

Regression: 34 passed / 0 failed. Unit: 2366 across the touched suites.

* docs(users): document batch mode — the command, the flag, and its limits

A user-visible command and flag shipped without user docs. Covers when batch
is the right tool at all (fan-out, not the agent loop, with the cache-hit
arithmetic that decides it), all four subcommands with real output, the
multi-turn shape (N turns = N jobs = N queue waits, no server-side state),
recovery by batch id, and the unmeasured limits.

* fix(cli,core): address review findings on batch mode

- make the batch give-up structurally non-retryable: bounded cause-chain
  identity check, fast-fail in the stream rate-limit loop and the model
  fallback gate, and typed errors for every post-create escape
- stop double-billing paths: maxRetries 0 on the non-idempotent batches.create,
  short-timeout no-retry cancel that keeps files and warns on failure,
  expires_at deadline on the poll loop, per-poll child abort signal
- scope the executionMode stamp to the main loop on the primary route
  (no forked chats, no fallback-served turns)
- qwen batch: install the proxy dispatcher before the first fetch, surface
  resolver warnings, fail closed on a mismatched declared url/method, stream
  the input with located parse errors and BOM stripping, print and clean up
  the uploaded file when the create fails, non-fatal remote cleanup on fetch,
  status-first phase in describeBatch
- gate: require an explicit DashScope host (empty baseUrl no longer passes),
  bracket-stripped IPv6 loopback, FatalConfigError, and reject --batch with
  --acp/--experimental-acp/--input-format stream-json

* docs(verification): harden batch harness; reconcile docs with shipped behavior

- 03: strict argv parsing, poll failures keep ids pending, awaited cleanup,
  atomic per-id pending-file updates, summary dedupe/status-filter/failure reason
- 02: persist both batch ids immediately and resume, arm-distinct cache
  prefixes, per-line cost with a completeness gate, non-fatal control loop
- lib: delete the orphaned input file when batches.create fails
- fake: settled jobs immutable, cancel of a settled batch refused, tools
  scenario keyed off the request body, DELETE 404 for unknown ids
- regression: mktemp scratch, tracked server PIDs, timeout/gtimeout resolution,
  scratch cwd so repo-local config cannot leak, event-wait instead of a fixed
  sleep for the SIGINT arm, settled-cancel refusal pinned
- docs: auth prerequisite trio and provider limits, per-request failures live
  inside the output file, multi-turn scoped to the provider-side unknown,
  README scenario/assertion counts corrected, plan doc §8-§9 reconciled
  (symbols over drifted line numbers, live purpose query, honest ledger,
  §9.7 convention claim corrected)

* fix(cli,core): make the batch gates follow the resolved wire, and stop destroying paid results

Five review findings, each one a path where `--batch` or `qwen batch` did
something other than what its own guard claimed:

- The `--batch` startup gate tested the *selected* auth type while `Config` is
  built with the *resolved* one. A model pinned to `wireApi: "responses"`
  resolves an `openai` startup to `openai-responses`, whose generator ignores
  `executionMode`, so the gate waved through the exact silent full-price no-op
  it exists to prevent. It now tests the value the runtime dispatches on, in
  both directions.
- The "non-interactive only" guard tested the raw variadic positional, and the
  normalizer below drops an empty join — so `qwen --batch ''` on a TTY started
  the TUI with batch mode on, and the first turn typed there became a >=24h
  paid job. The guard now trims what the normalizer keeps.
- The batch upload handed the raw `customHeaders` map to the global fetch,
  bypassing the session-aware wrapper that expands `${session_id}` per request
  and *deletes* an entry it cannot resolve. Literal placeholder text went on
  the wire and the fail-closed drop did not apply. The upload now resolves
  headers the way every other request path does.
- A job that settled without completing had its `output_file_id` registered for
  cleanup and then deleted by the `finally`, destroying lines the user had
  already paid for (`expired`, or `failed` after partial progress) — with no
  recovery path in the error. Those files are now kept and stderr names
  `qwen batch fetch <id>`.
- `qwen batch` dropped the `--proxy` flag (the highest-priority proxy source
  everywhere else in the CLI) and `--insecure`, because this command path exits
  before `loadCliConfig` runs. Both are threaded now.

Also, from the same read: `qwen batch` refused on the selected auth type but
then dropped the resolver's effective one, so a responses-wire model uploaded a
body the provider rejects per line; a create that failed ambiguously (5xx, or
an unreadable body after acceptance) deleted the input file of a job that may
exist and be billing; a download cut short left a truncated file under the
exact name a complete one has; a `custom_id` written on a bare body was lost as
the identifier *and* sent to the provider as an unknown field; duplicate ids
made the output rows unmappable; an envelope line with no `body` had its
declared method/url silently rewritten to the defaults; a UTF-16LE input file
(the PowerShell 5.1 `>` default the BOM comment names) failed with a message
that did not name the cause; and a job reporting no `expires_at` — observed
null on a live endpoint — had no upper bound on the wait at all.

Every fix carries a test, and each test was mutation-checked: reverting the
fixed line alone turns exactly its own test red (9 mutants run).

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

* docs(batch): record the live measurements, and stop the harness from certifying falsely

The shipped docs still described a pre-measurement world: `batch.md` declared
the two facts that decide `--batch`'s worth "Not measured yet" although both
were measured against the live API a day before that sentence landed (probe 01
passed — tools, tool_calls and an assistant+tool history all pass through a
batch body; probe 02 found `cached_tokens: 0` on both batch arms against a
0.647 realtime control, i.e. `cost_vs_realtime` 1.03). The page now states the
measured result, the latency (596 s for 3 lines, 3718 s for 1000), and what it
means: `--batch` is a switch for a run you leave going, sparing the realtime
quota at roughly realtime cost — not a discount. The auth paragraph now says
what the gates actually check (the resolved wire, and where the two entry
points differ on the host), and the feasibility study's handoff section records
the results instead of listing them as pending.

In the probe harness:

- `submit()` re-sent the non-idempotent `POST /batches` under the SDK's default
  two retries, so one transient provider error could create up to two extra
  live 24h jobs whose ids were never recorded. It now passes `maxRetries: 0`,
  as the production path does, and only deletes the orphaned input file on a
  definite 4xx.
- `collect()` labelled every output-file row `ok` without reading
  `response.status_code`, and the provider writes a failed request into the
  *output* file with `request_counts.failed: 0` — so `00-plumbing`'s pass gate
  (and the decision matrix that consumes it) reported PASS for a batch in
  which all three requests failed. `collect()` now splits rows into
  `ok`/`failed`, the gate checks both, and `02-cache` summarises the unfiltered
  rows so its own completeness gate keeps working.
- `02-cache` wrote its resume file only after *both* paid submits resolved, so
  a failure of the second arm discarded the first arm's already-created batch
  id, and the documented rerun bought that arm a second time — contaminating
  the cache-isolation measurement the probe exists to make. Each id is now
  persisted the moment its own submit resolves, and a resume submits only the
  arm whose id is missing.

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

* docs(verification): split the Bailian Batch API probe harness out of #11874

Moves `docs/verification/batch-api/` — four live probes, a fake DashScope
server and the CLI regression script — into its own PR, per the scope ruling on
#11874: the harness's output is measurement rather than product surface, its
results are already frozen into that PR's comments, and it was consuming most
of the additions there and a large share of its open findings, so every
reviewer had to separate product defects from scaffolding defects across 44
threads. It gets its own review round here instead.

Carried over with three fixes that round asked for, all of them ways the
harness could certify falsely or buy a job twice:

- `submit()` re-sent the non-idempotent `POST /batches` under the SDK's default
  two retries, so one transient provider error could create up to two extra
  live 24h jobs whose ids were never recorded. It now passes `maxRetries: 0`
  (as the production path does) and only deletes the orphaned input file on a
  definite 4xx — an ambiguous failure keeps it, because the job may exist.
- `collect()` labelled every output-file row `ok` without reading
  `response.status_code`. The provider writes a failed request into the
  *output* file with `request_counts.failed: 0`, so `00-plumbing`'s pass gate —
  and the decision matrix that consumes it — reported PASS for a batch in
  which all three requests failed. `collect()` now splits `ok`/`failed`, the
  gate checks both, and `02-cache` summarises the unfiltered rows so its own
  completeness gate keeps working.
- `02-cache` wrote its resume file only after *both* paid submits resolved, so
  a failure of the second arm discarded the first arm's already-created batch
  id and the documented rerun bought that arm a second time — contaminating
  the cache-isolation measurement the probe exists to make. Each id is now
  persisted the moment its own submit resolves, and a resume submits only the
  arm whose id is missing.

The README records what this directory depends on (`qwen batch` / `--batch`
arrive with #11874) and the two known-broken defaults found while running the
probes live, so the next round starts from the facts rather than rediscovering
them.

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

* docs(verification): move the batch probe harness to its own PR (#12297)

Executes the scope ruling on this PR: `docs/verification/batch-api/` — four
live probes, the fake DashScope server and the CLI regression script — now
lands as #12297 instead of riding along here.

The ruling's three reasons, all still true: the harness's output is
measurement rather than product surface and that measurement is already frozen
into this PR's comments and the docs below; live runs found two broken defaults
in it, so it needs a review round of its own; and it accounted for most of this
PR's additions and a large share of its open findings, which made every
reviewer separate product defects from scaffolding defects.

The three harness findings from that review round (a non-idempotent
`POST /batches` re-sent under the SDK's default retries, a pass gate blind to
`response.status_code` inside the output file, and a resume file written only
after both paid submits resolved) were fixed before the move and travel with
the files, so #12297 opens with them closed.

References updated rather than left dangling: `batch.md`'s local-verification
section and the feasibility study's inventory and reproduction steps now point
at #12297. No code or CI referenced the directory.

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

* refactor(cli,core): drop headless --batch, keep the qwen batch command

The probe harness registered its acceptance table before the runs: with
01 (tool shapes) passing and 02 (cache inside a batch) failing, the ruling
was "only ship form A -- fan-out". That is what the live runs returned.
Both batch arms came back `cached_tokens: 0` -- implicit and explicit
`cache_control` alike -- against a realtime control that hit 0.647 on the
same prompts, putting `cost_vs_realtime` at 1.03. Routing an agent's own
turns through the Batch API is not a discount; it is 3% more expensive and
hours slower.

So the `--batch` half comes out. Removed: the flag and its three `.check()`
guards, the DashScope gate with `isLoopbackEndpoint`, `Config.batchMode` /
`getBatchMode` and its derived-config override, `executionMode?: 'batch'`,
the pipeline and llm-chat branches, and `openaiContentGenerator/batch.ts`
with its tests.

`qwen batch submit|status|fetch|cancel` stays untouched -- fan-out is the
shape the 04 probe measured at a true 0.50x, and it is what the ruling kept.
The two mixed files were split by hand rather than reverted: `config.ts`
keeps the `batchCommand` import, its `.command()` registration and the
subcommand self-exit entry, and `config.test.ts` keeps the handler mock and
the exit-list case.

What was removed is not a finished feature and should not be restored as-is:
its own docs listed "no batch-id persistence across processes beyond the
stderr line" as a limit, and persistence plus session resume is where the
real work is. Whether to retire it permanently turns on how common the
"spare the realtime quota" premise actually is -- a product call, left open.
The v1 is preserved whole at `archive/headless-batch-mode-11874`.

Docs follow the code: `docs/users/features/batch.md` documents one path
again, and the design plan records the verdict against the pre-registered
criterion in section 6 and section 9.4.

Not run locally (no build/test on this box): typecheck, unit tests. Prettier
(--experimental-cli) and eslint --max-warnings 0 pass on the touched files.

* docs(verification): drop the --batch scenarios the harness can no longer drive

#11874 removed the headless `--batch` flag: the pre-registered acceptance
table in this directory rules "ship form A only" when 01 passes and 02
fails, and that is what the live runs returned (`cached_tokens: 0` on both
batch arms against a 0.647 realtime control, `cost_vs_realtime = 1.03`).

R4, R5, R6, R8, R9 and R7's SIGINT half all drove that flag, so they would
fail against a CLI that no longer has it. They are removed, along with the
`tools`, `failed` and `unpollable` servers only they started; the surviving
`fetch`-before-settle case is renumbered R4. What is left — R1 through R4 on
the `happy` and `stuck` servers — covers the four `qwen batch` subcommands,
which is the half that ships.

`fake-dashscope.mjs` keeps its now-unused scenario branches, so the removed
cases can be restored from `archive/headless-batch-mode-11874` if the
product call ever goes the other way; say the word and they come out too.

README updated to match: the dependency note, the coverage list, the
now-moot `--batch` acceptance section, and the verdict table, which now
records which row the measurements landed on.

Not run here (this box does not build or test this repo): `regression.sh`
itself, which needs `npm run dev`. `bash -n` and prettier pass. Note for
whoever does run it — `shellcheck --enable=all --severity=style` reports
SC2312 on the R1 `wc -l` line, and did so before this change too.

* fix(cli,docs): enforce the documented submit ceilings, and correct the fan-out cost claim

R2-54: submitBatch named the provider's 500 MB / 50 000-line ceiling as its
design constraint and enforced none of it, and forwarded --window verbatim.
A 50 001-line file or a 12h window was refused only after the whole file had
been uploaded — the billable half of the mistake. All four limits the shipped
doc states are now checked locally: window format and 24h..14d range before
the file is opened at all, then per-line, request-count and total-byte
ceilings inside the streaming loop, each naming the offending line.

The user doc claimed fan-out is half price with the cache-hit rate at 0, and
presented that row under a heading that says the table was measured. Only the
agent-loop row was measured; the fan-out row is derived, and its h = 0 holds
only when the requests share nothing. Most real fan-out shares a system
prompt, and the realtime side caches it: a 500-token instruction over
2 000-token documents saves ~40%, while a 5 000-token few-shot preamble over
500-token items costs MORE than realtime. The table now marks source per row
and a new section gives the 62.5% prefix-share rule, both worked examples, and
how to read h off a single realtime call before committing to a job.

Also folds #12297's harness back in: the two PRs were coupled (its
regression.sh assumes a CLI with no --batch), so splitting them only bought
a smaller diff at the cost of merge ordering and CI blind spots.

Verified: harness 20/20 against the fake server, cli suites 493 passed,
prettier + eslint clean.

* test(cli): pin the batch phase column so the guard cannot be deleted silently

`describeBatch` emits the status column unconditionally, so the three
`toContain('\tfailed\t')`-style assertions were satisfied by it whether or not
the `SETTLED.has(job.status) ? … :` phase branch existed. Deleting that branch
— the exact regression the test's own comment names, a settled job reading as
still running — left the suite green.

Anchor each case on the phase column instead (`'\tfailed\texpires'`) and assert
neither `running` nor `queued` appears. Mutation-checked: replacing the branch
condition with `false` now turns this test red, where before it did not.

Test-only; no production behaviour changes. Closes the surviving entrance
instance of R2-50 on this file.

* feat(cli): add agent-prepared Batch API workflow

/batch --api lets the agent prepare a bulk task as a small plan file
(shared rules + source/target items), and the new qwen batch
run/collect/retry/list subcommands assemble the requests, submit them,
track the job on disk, and deliver validated results as files. The
workflow persists submit intent before upload so a lost create answer
is reconciled instead of resubmitted, holds delivery on source changes
or target conflicts, retries only failed items, and keeps Batch usage
out of the interactive session's realtime cache statistics.

* refactor(cli): split Batch API mode into /batch-api and harden the executor

Move the agent-prepared workflow out of `/batch --api` into its own bundled
skill, `/batch-api`, with `disable-model-invocation: true` and read-only
allowedTools. `/batch` and BundledSkillLoader are back to their #11874 form:
no batch-specific branch in the generic loader, and the model-invoked Skill
tool path can no longer land in the wrong mode.

Executor fixes from review:
- items remember the attempt that owns them; an older attempt's result
  lines can no longer re-open an item that a running retry owns (which
  let the next retry bill it again)
- an attempt left `uploaded` by a process that died mid-create is treated
  as ambiguous and reconciled, never resubmitted; 408 is ambiguous too
- per-task lock file around run/collect/retry/cancel, stale locks taken over
- reconcile marks the attempt's items submitted so missing lines fail
- usage is recomputed from the whole result file, so re-collect keeps it
- retry enforces the plan's maxCostUsd and refuses a settled-but-uncollected
  batch; collected attempts are replayed locally without provider calls
- delivery publishes with link() so a target that appears mid-delivery is
  held instead of overwritten
- a source that symlinks outside the project is refused before upload
- enable_thinking defaults to false; plans opt in
- malformed result lines are skipped and reported; empty tool_calls is fine
- the ledger directory gets a .gitignore; refused plans leave no task
- the skill runs the CLI as "${QWEN_CODE_CLI:-qwen}"

* test(batch): make the e2e re-collect checks assert what they name

The idempotency step captured the attempt directory listing into an unused
variable (failing lint) and checked `deleted.length >= 2`, which the first
collect already guaranteed. Compare the remote-delete count and the attempt
files before and after the re-collect instead.

* fix(cli): freeze realtime settings, add batch check, gate truncated retries

Four gaps against the design contract, each of which either spends money
for nothing or skews the Batch-vs-realtime comparison:

- Batch now runs the user's realtime settings. `run` freezes the configured
  sampling parameters, output limit and thinking switch (verified
  chat-completions body fields only) into the task, and retries reuse
  them. This replaces the Batch-only `enable_thinking: false` default,
  which made cost/quality comparisons against realtime meaningless. A
  reasoning effort with no verified Batch equivalent is reported as a
  note; `enableThinking: false` is refused for thinking-mandatory models.
- New `qwen batch check`: proves credentials, endpoint and the Batch route
  with one unbilled list call and shows what a run would freeze. The
  /batch-api skill runs it first, so an unusable setup (e.g. Qwen OAuth)
  stops before any preparation is paid for.
- `maxCostUsd` is described as what it is — an estimate gate, not a cap on
  the bill — in the skill and docs.
- `retry` skips items truncated at the output limit (resending unchanged
  fails the same way and bills again) unless `--max-output-tokens` raises
  the limit they failed at; the attempt records the limit it ran with.

Not run locally (no vitest/tsc on the dev box); CI's Test and Lint lanes
cover it, and the e2e script gains `check` and truncated-retry steps.

* fix(cli): keep batch tasks per user and pin them to their endpoint

- Task records move from `<cwd>/.qwen/batch` to the user's qwen home
  (`~/.qwen/batch`, honouring QWEN_HOME / QWEN_BATCH_HOME) with 0700/0600
  modes. They hold full copies of the sources and outputs, so they belong
  outside every repository, and `collect`/`retry`/`list` now work from any
  directory; `list` shows each task's project. The agent's plan files stay
  in the project under `.qwen/batch/`, which `run` git-ignores. `run` also
  resolves the plan path against the project root it was given.
- `run` freezes the base URL and a 12-hex sha256 prefix of the API key
  (never the key). `collect`, `retry` and `cancel` refuse under different
  settings: a batch is invisible from another account or region, so they
  would otherwise report it missing or reconcile against the wrong list.
- The skill and docs point users at `!qwen batch …` for status, collect,
  retry and cancel inside a session, which spends no model turn.

Not run locally; CI's Test and Lint lanes cover the unit tests.

* feat(cli): honest batch accounting, safer lock takeover, and batch clean

Remaining gaps against the design contract:

- Usage: a result line without usage no longer counts as zero; the attempt
  records `missing` and `collect` reports Batch usage summed across all
  attempts, marked INCOMPLETE when any line lacked usage.
- Prices: `QWEN_BATCH_PRICE_SOURCE` (URL, date checked) is recorded with the
  task estimate and printed; without it the estimate says the source is not
  recorded. With prices set, `run`/`retry` state the realtime cache-hit rate
  at which Batch generation stops being cheaper (0.5·(I+O) − 0.8·h·I = 0)
  instead of implying a saving, and every estimate and report says that
  preparation in the interactive session is not measured.
- Lock: records pid and host; takeover only for a dead pid on this host. A
  reused pid can only cause a safe refusal, a lock from another host (shared
  home) is never taken over, and the message names the file to delete.
- `qwen batch clean <task-id>`: deletes the local record without needing
  credentials; refuses while a batch may be running, ambiguous or
  uncollected unless `--force`, cancels nothing, and notes kept remote files.
- Verification README: e2e now covers check/clean (27 checks) and a stage-B
  brief for the paid real-document comparison, which has not been run.

Not run locally; CI's Test and Lint lanes cover the unit tests.

* docs(batch): step-by-step stage B brief with measurement helpers

Rewrites the stage B section of the verification README into a runnable
procedure for the paid comparison the design's §10 requires, and adds the
three helpers it needs so no number is copied by hand:

- stage-b-session-usage.mjs: sums usageMetadata from a session JSONL in a
  time window — the only source for preparation cost, which the executor
  cannot see; flags records without usage.
- stage-b-realtime-replay.mjs: layer 1 — streams an attempt's frozen
  input.jsonl through the realtime API and records prompt/cached/completion
  usage, so the realtime cache-hit rate h is measured, not assumed; writes
  outputs at the items' target paths.
- stage-b-structure-check.mjs: identical structural scoring for every arm
  (headings, byte-identical code blocks, link targets, empty/short output).

The procedure fixes the sample (30 docs), freezes settings and the verdict
rules before any run, puts the PR build first on PATH so the skill cannot
fall back to an older `qwen`, runs three arms (/batch-api, replay, plain
realtime session), scores quality blind, and prices both layers with the
§5.2 formulas before reconciling against the console bill. Nothing here
has been run; the helpers were only smoke-tested on local fixtures.

* docs(batch): add team overview and first measured results

- docs/design/2026-09-23-batch-api-overview.zh-CN.md: a team explainer —
  what the Bailian Batch API is (sequence diagram), its price, cache and
  latency traits, when it actually saves money, mermaid architecture
  diagrams of the plain agent loop, the parallel /batch skill and
  /batch-api, a comparison table, current usage, and the auto-collect
  proposal (options, recommended in-session + next-start collection,
  implementation points, three open decisions).
- docs/verification/batch-api/results-2026-09-23.md: the first manual test
  run (functional checks all passed; cost for 2/20/50 documents across the
  three paths, re-derived from list prices), an analysis of which
  conclusions hold and which cannot be extrapolated yet (toy-sized
  documents, no quality scoring, queue luck, no same-request realtime
  control, single runs), the issues found with their planned fixes, and
  the follow-up measurement plan.
- Link both from the design contract and the verification README.

* feat(cli): auto-collect /batch-api tasks; surface provider rejections

Collection no longer needs the user:

- batch-auto-collect.ts: an interactive session starts a collector after
  first render (startPostRenderPrefetches). It scans the local task records
  for open tasks of this project, polls each batch over HTTP with a per-task
  backoff (1 -> 5 min), and runs the same collectTask when one settles. The
  first pass at startup collects tasks that finished while no session was
  open. One info notice per change (delivered targets, failures with the
  retry command, held items, provider errors) goes through the existing
  update-notice channel, which defers it while a response streams. No model
  call, never an automatic retry; no Batch credentials disables it; tasks
  pinned to another endpoint/key are left alone.
- general.batchAutoCollect: deliver (default) | notify | off. Headless,
  serve, ACP and web-shell do not auto-collect.
- collectTask now returns a CollectSummary of what the call changed.

Provider-side rejections (test report issue 1):

- collect records the job's final status and job-level `errors` and uses
  them as each unanswered item's failure reason instead of "no result line".
- run/retry wait out the provider's validation window (only when create
  returns `validating`, <= ~30s) and report a batch rejected as a whole
  (e.g. a model without Batch support) immediately.

Skills (issues 3 and 4):

- /batch-api: a hard rule to report and stop on any failed `qwen batch`
  command or unsuitable task — never do the transform in-session unasked;
  an unknown command/argument is explained as an outdated `qwen` on PATH;
  collection is described as automatic and the model must not poll.
- /batch's model-visible description says the model may suggest the user
  type /batch-api, but cannot invoke it.

Docs: user guide, settings reference and schema, design contract (EN/zh),
team overview (auto-collect now implemented) and the results issue table.
Not run locally; CI's Test and Lint lanes cover the unit tests.

* fix(cli): harden batch auto-collect, task lock and collect after review

Four independent reviews (money safety, integration/types/platform,
docs/skill consistency, test tracing) of the auto-collect commit:

Auto-collect
- A submission that cannot be reconciled is announced once ("may exist
  and be billing — check with qwen batch collect") and then no longer
  polled, instead of paging the provider's batch list every 5 min forever.
  A task locked by another process (e.g. a run still submitting) is not
  mistaken for a lost submission.
- No usable Batch credentials: said once, retried every 5 min, never
  latched off for the session. The endpoint is resolved per pass, and a
  task pinned to another endpoint/key resumes when the session switches
  back.
- Notify mode dedups by batch id, so a later retry is announced; it also
  refuses a wrong endpoint/key before querying the provider.
- Tasks run from a subdirectory of the session root are collected.
- resolveEndpoint takes the session's loaded settings and a warn sink:
  no settings re-read from disk mid-session, no stderr drawn over the TUI.

Collect
- One batch failing to collect no longer hides what the others delivered:
  every attempt is tried, the summary rides on the thrown error, and the
  CLI still exits 1.
- A completed batch whose result file is not published yet stays
  awaiting instead of failing every item (which invited a paid retry).
- A batch that create already returns as failed is reported at once.

Task lock
- An empty/unparseable lock (created but not yet written) counts as held;
  only a well-formed lock from this host with a dead pid is taken over.
- Release removes the lock only if it still holds this acquisition's
  token. CLI commands wait up to 15 s for a lock (an open session's
  auto-collector holds it briefly); the message says so.
- Task-file rename retries briefly on Windows EPERM/EBUSY.

Paths: isInsideRoot via path.relative (roots ending in a separator,
`..name` files, other drives); win32 compares case-insensitively; the
no-hard-link delivery fallback holds on EEXIST instead of throwing.

Skill/docs: plan-field errors are the single exception to "report and
stop"; id length; `collect later with` is only the manual fallback;
latency figures reconciled; dangling section references and an internal
billing detail removed; stage B uses notify mode so auto-collect does not
skew timing; `list` help text. Tests for each fix above.

* fix(cli): no false "may be billing" warning from batch auto-collect

The lost-submission warning now needs a reconcile that actually ran and
found nothing ("has no submitted batch"): a transient provider error
during reconcile, or a lock held by a run still submitting, no longer
triggers it (and no longer stops polling the task). Notify mode, which
takes no lock, only counts a recorded lost create answer (`unknown`), not
an `uploaded` attempt that may still be in flight. Also moves two comments
back above the code they describe.

* test(cli): isolate the batch auto-collect startup test from the update check

With the update check also running, both deferred tasks import the mocked
update emitter in the same tick and vitest hands the auto-collect task the
real module, so the notify assertion saw no call (CI run 35880613603).
Production code has no mocks and is unaffected; the test turns the update
check off so only the path under test imports the emitter.

* fix(cli): close batch review findings on billing, path safety, and accounting

- collect: treat a completed batch without request_counts as "results
  expected" instead of failing every item as "no result line" — the old
  guard condemned successful work and invited a paid retry.
- collect: mark an attempt remoteCleaned/collected only when every remote
  deletion succeeded; a failed deletion stays on the retry path (the next
  collect re-fetches the settled batch) instead of leaking files while the
  ledger claims they are gone. The cleanup retry is not re-announced as a
  new settlement.
- retry: gate the plan budget on the real re-assembled requests rather than
  the run-time per-item average, which undercounts retries of large items
  or of sources that grew after run.
- fetch: validate the batch id as a single safe path component before it
  becomes a URL segment and a filename under --out.
- deliver: prove the nearest existing ancestor is inside the project before
  recursive mkdir, so a symlinked parent can no longer create directories
  outside the project; the created parent is still revalidated after.
- deliver the completion content verbatim (trim only guards emptiness):
  leading whitespace and the trailing newline are part of the document.
- refreshTaskStatus: an ambiguous latest attempt outranks partial progress
  so `batch list` says reconcile-first instead of hiding a possibly billing
  submission behind "partial".
- collect --wait: cap each backoff sleep at the remaining --timeout budget
  instead of overshooting the documented deadline.
- batch list: read the local task store without resolving an endpoint or
  requiring credentials, like clean.

* docs(batch): retire one-off probes, and record the re-run verification

- Delete the 00-03 live probes, their lib, and the stage-B helpers: the
  probes answered their questions (verdict kept in the README: tools pass
  through, no cache hits in batch, fan-out only), two of them shipped with
  confirmed defects (00's guaranteed-failing default model, 03's racy
  pending-id rewrite), and the stage-B helpers were never driven. They
  remain in git history if the paid comparison is ever run.
- Delete docs/plans/2026-09-14-batch-api-feasibility.md: superseded by the
  2026-09-23 design doc, and its status sections contradicted each other.
- Slim fake-dashscope.mjs to the scenarios regression.sh actually drives
  (happy/stuck, plus the documented-but-unwired slow).
- Record the truth about verification: the 27-check workflow E2E was re-run
  on 2026-09-24 against the review-fix head (d400a1c3) and passes; note the
  two review-driven behavior changes to the raw verbs (fetch id validation,
  credential-free list), and state the two-track design (raw verbs vs
  workflow) explicitly.

* fix(cli): keep the API key on Batch API paths; close patrol round-2 items

Decision item 1 from the round-2 patrol, option (a):

- `status` and `cancel` validate the batch id like `fetch` already did
  (shared `assertBatchId`): `qwen batch status '../../x'` sent
  `GET /x` with the bearer token attached.
- Defense in depth for every caller, including ledger- and
  provider-sourced ids in the workflow: `batchRequest` — the one place the
  API key is attached — refuses any route that is not a `/batches` or
  `/files` shape before sending.

regression.sh R3 (red on Linux, 18/20): the auth-gate check only moved
HOME, so a QWEN_HOME or a machine-wide system settings file in the
environment won over the qwen-oauth fixture and the command ran with the
API key. The script now unsets QWEN_HOME and points the system settings
and defaults paths at non-existent files. Not re-run here (no local
builds); the fix targets the two sources that can override HOME.

Docs and tests:
- batch.md: fix the design-doc link (../../design), and say the 256K
  context cap is provider-side and not checked locally, unlike the three
  file limits.
- Design contract: `/batch` behavior is unchanged but its model-visible
  description gained one sentence; "cannot leave the mode" dropped (only
  "cannot enter" is enforced); allowedTools stated exactly, including the
  read-family expansion (all read-only).
- English version of the team overview (docs were zh-only against the
  bilingual rule), cross-linked with the zh one.
- New batch-api SKILL.test.ts: user-invoked only, the grant's effect
  (read family allowed; write_file/edit still ask), check before run, the
  report-and-stop rule, no polling from the model.
- batch.test.ts: status ids and off-path routes are refused before fetch.

* docs(batch): replace the four design docs with one design doc

Merge the design contract and the team overview (each in English and
Chinese) into docs/design/2026-09-23-batch-api-design.md, keeping the
architecture, invariants, plan schema, boundary behaviors and
auto-collect design. Provider analysis, latency measurements and cost
derivations are dropped; measured data stays in
docs/verification/batch-api/results-2026-09-23.md. Links updated.

* refactor(cli): trim duplication and dead flexibility in qwen batch

- batch.ts: use the batch-client helpers for create/get/cancel/delete
  instead of re-implementing them; drop the SETTLED alias and dead
  re-exports; drop UTF-16 input detection (UTF-8 BOM is still stripped).
- batch-client.ts: drop assertBatchId; BATCH_ROUTE already refuses any
  id that is not a single path component before a request is sent.
- batch-workflow.ts: share the result-line lookup between the output and
  error loops; store usage as computed; retry refuses an uncollected
  batch without polling it first.
- batch-auto-collect.ts: drop the wrong-endpoint cache (the per-task
  backoff already rate-limits the refusal) and the duplicate stop().
- batch-docs.ts: deliver with an exclusive create (wx) instead of a
  temp file + link() with a wx fallback; same no-overwrite guarantee.
- batch-api SKILL.md: state the no-realtime-fallback rule once; point
  to the user guide for list/cancel/clean.

* refactor(cli): drop the raw qwen batch submit/status/fetch verbs

/batch-api only drives run/collect/retry/cancel/list/check/clean; the raw
transport verbs duplicated #11874, which keeps them for comparison. The
line limit is now a single 1 MB check (the provider documents disagree
on 1 MB vs 6 MB), and `cancel` takes the task id positionally like the
other workflow commands. The regression script and fake server that only
exercised the raw verbs are removed; workflow-e2e.mjs remains.

* refactor(cli): drop the post-create validation poll in qwen batch

A batch rejected as a whole is already reported by collect (job-level
errors become the item failure reason) and by the session auto-collector
within about a minute, so run/retry no longer poll the validation window.

* refactor(cli): make general.batchAutoCollect a boolean

Drop the notify-only mode: the collector either collects and delivers
(default) or is off.

* refactor(cli): drop the break-even text and price-source record

Keep the token estimate, the optional price-based estimate and the
maxCostUsd gate; drop the break-even cache-hit sentence,
QWEN_BATCH_PRICE_SOURCE and the write-only task.estimate record.

* refactor(cli): drop collect --keep-remote and BatchTaskStore.exists

Remote files are always deleted once results are local; a failed
deletion leaves the attempt uncollected so the next collect retries it,
which makes the separate remoteCleaned flag redundant.

* docs(batch): update docs for the trimmed qwen batch surface

Rewrite the user guide around /batch-api (the raw-verb sections and the
long cost analysis go), and align the design doc, the skill, and the
verification README with the removed verbs, the boolean auto-collect
setting and the dropped validation poll.

* fix(cli): close review findings in qwen batch

- collect: a 404 on remote-file cleanup counts as deleted, so one
  transient failure no longer leaves the attempt uncollected forever.
- collect --wait polls without holding the task lock; cancel, retry and
  the auto-collector stay usable while a batch runs.
- A cancelled or expired batch that reports finished requests but has no
  result file yet is deferred instead of failing (and re-billing) them.
- Items held because their source changed are resubmitted by retry.
- Drop the markdown fence-count check: truncation is finish_reason, and
  the count rejected valid, billed outputs.
- Claim task ids with an exclusive mkdir; reuse core isPidAlive for the
  lock (EACCES on Windows counts as alive).
- O(1) item lookups in assemble/collect; cap the output estimate at the
  output limit.
- auto-collect: stale poll times of warned tasks no longer shrink the
  scan interval to one second.

* feat(batch-api): wait in a background shell and wake the agent once

After `run`, the skill starts `qwen batch collect <task-id> --wait` as a
background shell and ends its turn. The process polls over HTTP with no
model call; when the batch settles it collects and exits, and the shell
registry notifies the model once, so the agent reports the result and
does only the follow-up the user asked for (never a retry).
`collect --wait` now waits until the batch settles unless --timeout is
given. Design and user docs describe the waiter as the primary path and
the session auto-collector as the fallback.

* fix(cli): close the expert-review findings on qwen batch

- run: a create answer with no batch id is ambiguous (reconciled), not
  recorded as created with an undefined id (F8).
- collect: when result files exist but no line maps to the attempt while
  the provider reports finished requests, fail nothing, keep the remote
  files and drop the local copies for a fresh download (F5).
- plan: refuse targets inside .git/, .github/, .husky/ or .qwen/, since
  delivery runs hours after approval with nobody watching (F6).
- auto-collect: re-read a task record only when its mtime/size changed
  (F4); startBatchAutoCollect runs for the process lifetime and returns
  nothing, instead of a stop function no caller keeps.
- WorkflowApi.probe is required, so check never reports ready without a
  request.
- Tests: retry under a held lock bills nothing (F3); the 1 → 2 → 4 → 5
  minute poll schedule (F9); plus one test per fix above.

* fix(cli): keep one bad target, a lost create, or a switched key from misleading qwen batch

- collect: a target that cannot be written (a directory, a parent that is
  a file, no permission) holds that item with the reason instead of
  aborting the whole attempt, so the other paid results still deliver.
- run/retry: a create whose answer was lost is still recorded as
  submit-unknown, and the command now exits with an error, so neither the
  user nor the agent reports it as submitted.
- auto-collect: a task pinned to another endpoint or key is reported once,
  instead of backing off silently while the user waits for a notice.
- Reuse the cli's isWithinRoot instead of a local copy.

* fix(cli): close the round-3 review findings on qwen batch

Correctness:
- freeze samplingParams and extra_body verbatim with extra_body merged
  last, as the realtime wire does (the thinking switch was resolved in
  the opposite order, and knobs such as thinking_budget were dropped).
- a lock that exists but cannot be read counts as held (no busy loop past
  the wait deadline); releasing the lock can no longer throw out of the
  finally block.
- a recorded local result file that disappeared is downloaded again
  instead of read as "no results".
- provider limits are checked before assembly (request count) and while
  the file is built (bytes).
- item ids are capped at 60 characters so `<id>#<attempt>` fits in 64;
  nested plan objects are strict too.
- token estimates count CJK at ~1.5 chars/token instead of 3.
- QWEN_TLS_INSECURE disables TLS verification for qwen batch as it does
  elsewhere; output is flushed before the process exits.
- results are downloaded owner-only (0600).
- auto-collect says once when a task fails three passes in a row, and
  polls a task again once it is no longer ambiguous.
- retry names items still awaiting their batch instead of "nothing to do".

Tests and harness: pagination of the batch list, the completion window
reaching create, held/awaiting notices, the session wiring of
resolveEndpoint and the working directory; workflow-e2e.mjs isolates
QWEN_HOME and system settings, drops dead state, and checks the
re-collect exit code and mtimes.

Docs: add the zh-CN design doc (repo rule for design docs) and link both.

* test(cli): type the resolveEndpoint mock so the cli test build compiles

`vi.fn(() => …)` has a zero-arity type, so spreading the forwarded
arguments into it failed `tsc --build` (TS2556) and took down every CI
lane that builds the cli.

* feat(batch-api): approve a previewed snapshot, not just the run command

Suggested in review: approving `qwen batch run <plan>` authorised "run
this file", while the item set, frozen settings and estimate were only
computed after that approval and billed at once.

- `qwen batch run <plan> --dry-run` assembles every request, checks the
  budget and prints the item count, frozen settings, estimate and a
  snapshot digest, then uploads nothing and keeps no task.
- `qwen batch run <plan> --expect <digest>` recomputes the digest (over
  every request line, the completion window and the account) right before
  upload and refuses if the plan, a source or the settings changed.
- /batch-api previews first, shows the user the preview, then submits
  with --expect, so the approval prompt is the spending decision.
- Tests: preview uploads nothing; a matching digest submits; a changed
  instruction, item set, output limit or source is refused.

* fix(cli): count a collect that delivered nothing as a failed pass

collectOne swallowed every error carrying a partial summary, so a provider
error that delivered nothing never reached the consecutive-failure counter:
the "cannot be collected automatically" notice its own test pins could not
fire. Rethrow when the partial summary has nothing to report, so the pass is
counted and the user is told once the failures repeat.

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

* fix(cli): close the round-4 review findings on qwen batch

- clean refuses to remove the only copy of a held, already-billed
  result unless forced
- cancel refuses while an ambiguous submission may exist and be
  billing, and skips attempts that were already collected
- collect counts the pass that finishes a harvest as the first
  harvest, so an aborted pass cannot silence the settled notice
- collect delivers from the local result copies when the provider
  cannot be re-fetched for the cleanup retry
- collect keeps the remote files when the local copies account for
  fewer results than the provider reports finished
- a retry assembles without moving the source baseline; the new hash
  lands on the items only when the attempt provably becomes a batch
- delivery removes its own partial write, so a failed write cannot
  wedge the target as a phantom conflict
- the stale-lock takeover renames before deleting, so it cannot
  remove a live successor's lock
- the missing-source assertion matches platform path separators

* fix(cli): close the round-5 Critical findings on qwen batch

- R2-7/R2-8: plan targets must stay inside the project and outside any
  hidden path at any depth (`packages/app/.qwen/settings.json` and
  `vendor/x/.git/hooks/*` passed the first-segment denylist), checked
  before anything is billed. The preview lists the target directories
  and the snapshot digest covers every target.
- R2-27: disabled reasoning is frozen in the Qwen wire shape realtime
  uses (`reasoning_effort: "none"` for the tiered family,
  `enable_thinking: false` otherwise) and only reported for other model
  families; a plan `enableThinking` on a non-Qwen model is refused.
- R2-30: the plan/retry output limit is written under the budget key the
  frozen params already use (`max_completion_tokens` / `max_new_tokens`,
  else `max_tokens`) instead of double-specifying it, and the limit shown
  is read from that key.
- R2-22: /batch-api probes the CLI with `batch --help` (never a model
  call) before `batch check`, which an older CLI turns into a billed
  prompt.

* fix(cli): serialize batch recovery and redownload partial results

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

* feat(batch): select a model independently from conversation settings

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

* fix(batch): enforce delivery boundaries and normalize thinking overrides

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

* fix(batch): match realtime thinking, honor an empty batch home, outlast a poll blip

The three findings the maintainer verification at 8560fe95 ruled worth fixing
before merge:

- freezeRequest kept a preset's `extra_body.enable_thinking: true` even under
  `reasoning: false`, while realtime applies the disable after merging
  extra_body, so Batch billed thinking tokens the user had turned off.
- `QWEN_BATCH_HOME=` — an empty value, as an env file writes it — resolved to
  the current directory, putting the task store and its `.gitignore` of `*` in
  the project root and silently hiding the user's own files from a commit.
- One transient 503/429 on a status poll ended `collect --wait`, so the
  background waiter /batch-api relies on died on the first blip of an
  hours-long wait. Only a definite client error now ends the wait early.

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

* fix(batch): keep a forced clean and a bare re-collect from losing paid state

Two round-2 suggestions still open on the PR, both re-verified against the
current source before fixing:

- `clean --force` destroyed the only copy of held results without saying so,
  while the same path does warn about an uncancelled batch. Reachable without
  editing JSON: a retry leaves an uncollected attempt, so the refusal the user
  meets first is the open-batch one, whose own text ends "or pass --force".
- A re-collect after the local `attempt-NNN/output.jsonl` is gone — disk
  cleanup, or a batch-home migration that copied task.json but not the attempt
  dirs — recomputed usage from nothing and wrote the zeros over the billed
  totals. The attempt is already collected, so `getBatch` is never called and
  no re-download is offered: the only record of a paid batch's tokens
  disappeared, and the "Batch usage, all attempts" line with it, contradicting
  the invariant this file states at the parse loop. The write is now gated on
  having actually read a result file this pass; the incomplete-harvest path
  that deliberately persists partial usage is untouched.

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

---------

Co-authored-by: 易良 <jinjing.zzj@gmail.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-09-25 18:31:27 +00:00
..
.gitignore feat(cli): agent-prepared Batch API workflow (/batch-api) (#12492) 2026-09-25 18:31:27 +00:00
README.md feat(cli): agent-prepared Batch API workflow (/batch-api) (#12492) 2026-09-25 18:31:27 +00:00
results-2026-09-23.md feat(cli): agent-prepared Batch API workflow (/batch-api) (#12492) 2026-09-25 18:31:27 +00:00
workflow-e2e.mjs feat(cli): agent-prepared Batch API workflow (/batch-api) (#12492) 2026-09-25 18:31:27 +00:00

Batch API 验证工具

本目录是 /batch-api 工作流(PR #12492)的离线验证工具: 假服务器 + 真实构建产物,不联网、不需要凭证、不花钱。设计约定见 docs/design/2026-09-23-batch-api-design.md, 首轮人工实测数据与分析见 results-2026-09-23.md。

工作流 E2E(主验证入口)

workflow-e2e.mjs 自带假 Batch API、隔离 HOME,驱动构建产物 dist/cli.js 走完整链路:预检、提交、运行中收取、--wait 收取、文件交付、幂等重收(不重下载、 不重删远端)、截断失败、截断项不带更高上限时被跳过、提高上限后重试、目标冲突 held、 解决后交付、任务取消、清理(未收取时拒绝),共 27 项断言:

npm run build && npm run bundle
node docs/verification/batch-api/workflow-e2e.mjs "$(pwd)/dist/cli.js"

最近一次:2026-09-24 在 review 修复后的 head(d400a1c3)上 27/27 通过 (macOS arm64 / Node 24);更早一次在 06a1278572(macOS / Node 22)通过。

一次性探针与阶段 B(已删除,结论保留)

早期评估用的线上探针 00-plumbing / 01-tools / 02-cache / 03-queue-timing (及其 lib.mjs)、阶段 B 付费对照的辅助脚本 stage-b-*,连同 docs/plans/2026-09-14-batch-api-feasibility.md 都已完成使命并从分支删除 (需要时从 git 历史恢复)。它们的结论保留如下:

  • 判定(2026-09-18/19 实测,华北2·北京,qwen3.7-max):工具调用能穿过 batch body(01 通过);batch 内不命中 context cache(02 两臂 cached_tokens: 0, 实时对照 0.647,cost_vs_realtime = 1.03)→ 只做形态 A(扇出),不做 headless --batch 置换(已从 #11874 移出)。
  • 阶段 B(真实付费对照)还没跑:方法纲要在 results-2026-09-23.md 第六节; 在跑完之前,任何"省钱"结论都只是估算。