mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-22 06:49:52 +00:00
* 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 of004b7b9fe8(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 onbbf79d1. 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 at8560fe95ruled 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>
497 lines
16 KiB
JavaScript
497 lines
16 KiB
JavaScript
// End-to-end acceptance for the agent-prepared batch workflow, driving the
|
|
// real built CLI (dist/cli.js) against a fake DashScope Batch API. No real
|
|
// network, no credentials, no money.
|
|
//
|
|
// Usage: npm run build && npm run bundle, then
|
|
// node docs/verification/batch-api/workflow-e2e.mjs ../../../dist/cli.js
|
|
// (the argument is the path to the built bundle; pass an absolute path).
|
|
// HOME is isolated inside the script so user settings cannot redirect the
|
|
// CLI at a real endpoint.
|
|
import http from 'node:http';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { spawn } from 'node:child_process';
|
|
|
|
const CLI = process.argv[2];
|
|
if (!CLI) throw new Error('usage: node batch-e2e.mjs <path-to-cli.js>');
|
|
|
|
const state = {
|
|
files: new Map(), // id -> content
|
|
jobs: new Map(), // id -> job
|
|
polls: new Map(), // job id -> poll count
|
|
deleted: [],
|
|
nextFile: 1,
|
|
nextJob: 1,
|
|
// job behavior: 'auto' completes after 2 status polls; 'stay' never does.
|
|
behavior: 'auto',
|
|
failItem: null, // custom_id to fail with finish_reason=length
|
|
};
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve) => {
|
|
const chunks = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
});
|
|
}
|
|
|
|
function settle(job) {
|
|
job.status = 'completed';
|
|
job.completed_at = Math.floor(Date.now() / 1000);
|
|
const input = state.files.get(job.input_file_id);
|
|
const outLines = [];
|
|
// The upload is stored as the raw multipart body; the JSONL payload is
|
|
// exactly the lines that start with '{'.
|
|
for (const raw of input.split('\n')) {
|
|
if (!raw.trim().startsWith('{')) continue;
|
|
const line = JSON.parse(raw);
|
|
const customId = line.custom_id;
|
|
const src = line.body.messages.at(-1).content;
|
|
const docMatch = src.match(
|
|
/<document path="[^"]*">\n([\s\S]*)\n<\/document>/,
|
|
);
|
|
if (state.failItem === customId) {
|
|
outLines.push(
|
|
JSON.stringify({
|
|
custom_id: customId,
|
|
response: {
|
|
status_code: 200,
|
|
body: {
|
|
choices: [
|
|
{
|
|
finish_reason: 'length',
|
|
message: { role: 'assistant', content: 'cut off' },
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 50, completion_tokens: 2 },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
outLines.push(
|
|
JSON.stringify({
|
|
custom_id: customId,
|
|
response: {
|
|
status_code: 200,
|
|
body: {
|
|
choices: [
|
|
{
|
|
finish_reason: 'stop',
|
|
message: {
|
|
role: 'assistant',
|
|
content: `TRANSLATED(${line.body.model}): ${docMatch ? docMatch[1] : src}`,
|
|
},
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 50, completion_tokens: 10 },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
const outId = `file-${state.nextFile++}`;
|
|
state.files.set(outId, outLines.join('\n') + '\n');
|
|
job.output_file_id = outId;
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url, 'http://x');
|
|
const send = (code, obj) => {
|
|
res.writeHead(code, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify(obj));
|
|
};
|
|
if (req.method === 'POST' && url.pathname === '/v1/files') {
|
|
const body = await readBody(req);
|
|
const id = `file-${state.nextFile++}`;
|
|
// Multipart: just keep the raw body; the fake settles from input_file_id.
|
|
state.files.set(id, body.toString('utf8'));
|
|
return send(200, { id });
|
|
}
|
|
if (req.method === 'POST' && url.pathname === '/v1/batches') {
|
|
const body = JSON.parse((await readBody(req)).toString('utf8'));
|
|
const id = `batch-${state.nextJob++}`;
|
|
// The input file stays stored as the raw multipart body; settle() reads
|
|
// the JSONL lines out of it.
|
|
const job = {
|
|
id,
|
|
status: 'in_progress',
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
input_file_id: body.input_file_id,
|
|
request_counts: { total: 0, completed: 0, failed: 0 },
|
|
};
|
|
state.jobs.set(id, job);
|
|
state.polls.set(id, 0);
|
|
return send(200, job);
|
|
}
|
|
const batchMatch = url.pathname.match(/^\/v1\/batches\/([^/]+)(\/cancel)?$/);
|
|
if (batchMatch) {
|
|
const job = state.jobs.get(batchMatch[1]);
|
|
if (!job) return send(404, { error: 'no such batch' });
|
|
if (req.method === 'POST' && batchMatch[2] === '/cancel') {
|
|
job.status = 'cancelled';
|
|
return send(200, job);
|
|
}
|
|
if (req.method === 'GET') {
|
|
const polls = state.polls.get(job.id) + 1;
|
|
state.polls.set(job.id, polls);
|
|
if (
|
|
state.behavior === 'auto' &&
|
|
polls >= 2 &&
|
|
job.status === 'in_progress'
|
|
) {
|
|
settle(job);
|
|
}
|
|
return send(200, job);
|
|
}
|
|
}
|
|
if (req.method === 'GET' && url.pathname === '/v1/batches') {
|
|
return send(200, { data: [...state.jobs.values()], has_more: false });
|
|
}
|
|
const fileMatch = url.pathname.match(/^\/v1\/files\/([^/]+)(\/content)?$/);
|
|
if (fileMatch) {
|
|
const content = state.files.get(fileMatch[1]);
|
|
if (
|
|
req.method === 'GET' &&
|
|
batchMatch === null &&
|
|
fileMatch[2] === '/content'
|
|
) {
|
|
if (content === undefined) return send(404, { error: 'no such file' });
|
|
res.writeHead(200, { 'content-type': 'application/octet-stream' });
|
|
return res.end(content);
|
|
}
|
|
if (req.method === 'DELETE') {
|
|
state.deleted.push(fileMatch[1]);
|
|
state.files.delete(fileMatch[1]);
|
|
return send(200, { id: fileMatch[1], deleted: true });
|
|
}
|
|
}
|
|
return send(404, { error: `${req.method} ${url.pathname}` });
|
|
});
|
|
|
|
const results = [];
|
|
function check(name, cond, detail = '') {
|
|
results.push([name, Boolean(cond), detail]);
|
|
console.log(
|
|
`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`,
|
|
);
|
|
}
|
|
|
|
// The fake server lives in THIS process, so the event loop must stay alive
|
|
// while the CLI child runs — a sync exec would deadlock against our own
|
|
// server.
|
|
function run(cwd, env, args) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn('node', [CLI, 'batch', ...args], {
|
|
cwd,
|
|
env: { ...process.env, ...env },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (c) => (stdout += c));
|
|
child.stderr.on('data', (c) => (stderr += c));
|
|
child.on('error', reject);
|
|
child.on('exit', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const port = server.address().port;
|
|
|
|
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'batch-e2e-project-'));
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'batch-e2e-home-'));
|
|
// Isolate settings: user, QWEN_HOME and system settings would all merge in,
|
|
// and a provider config there would override the env endpoint and point the
|
|
// command at a real, billable API. Every settings source is redirected into
|
|
// empty temp paths.
|
|
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'batch-e2e-fakehome-'));
|
|
fs.mkdirSync(path.join(project, 'docs', 'zh'), { recursive: true });
|
|
fs.writeFileSync(path.join(project, 'docs', 'zh', 'a.md'), '# A\n\n甲文档。\n');
|
|
fs.writeFileSync(path.join(project, 'docs', 'zh', 'b.md'), '# B\n\n乙文档。\n');
|
|
fs.writeFileSync(
|
|
path.join(project, 'plan.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
name: 'e2e-translate',
|
|
kind: 'document-transform',
|
|
shared: { instructions: 'Translate to English. Return only the document.' },
|
|
items: [
|
|
{ id: 'a', source: 'docs/zh/a.md', target: 'docs/en/a.md' },
|
|
{ id: 'b', source: 'docs/zh/b.md', target: 'docs/en/b.md' },
|
|
],
|
|
}),
|
|
);
|
|
const env = {
|
|
HOME: fakeHome,
|
|
USERPROFILE: fakeHome,
|
|
QWEN_HOME: path.join(fakeHome, '.qwen'),
|
|
QWEN_CODE_SYSTEM_SETTINGS_PATH: path.join(fakeHome, 'system-settings.json'),
|
|
QWEN_CODE_SYSTEM_DEFAULTS_PATH: path.join(fakeHome, 'system-defaults.json'),
|
|
OPENAI_API_KEY: 'fake-key',
|
|
OPENAI_BASE_URL: `http://127.0.0.1:${port}/v1`,
|
|
OPENAI_MODEL: 'qwen-plus',
|
|
QWEN_BATCH_HOME: home,
|
|
NO_COLOR: '1',
|
|
};
|
|
|
|
try {
|
|
// 0. check: proves the route before any preparation, bills nothing
|
|
const checkOut = await run(project, env, ['check']);
|
|
check(
|
|
'check reports a ready endpoint and submits nothing',
|
|
/ready: /.test(checkOut.stdout) && state.jobs.size === 0,
|
|
checkOut.stdout + checkOut.stderr,
|
|
);
|
|
|
|
// 1. run
|
|
const runOut = await run(project, env, ['run', 'plan.json']);
|
|
const taskId = /task (\S+):/.exec(runOut.stdout)?.[1];
|
|
check('run prints a task id', Boolean(taskId), taskId);
|
|
check(
|
|
'run prints the batch job id',
|
|
/batch job: batch-1/.test(runOut.stdout),
|
|
);
|
|
check(
|
|
'run prints the collect hint',
|
|
runOut.stdout.includes(`collect ${taskId}`),
|
|
);
|
|
|
|
// 2. collect while running (no wait): nothing delivered yet
|
|
state.behavior = 'stay';
|
|
const early = await run(project, env, ['collect', taskId]);
|
|
check(
|
|
'collect reports in_progress before settle',
|
|
/in_progress/.test(early.stdout),
|
|
);
|
|
check(
|
|
'nothing delivered before settle',
|
|
!fs.existsSync(path.join(project, 'docs', 'en', 'a.md')),
|
|
);
|
|
|
|
// 3. collect --wait: settles, delivers both
|
|
state.behavior = 'auto';
|
|
const collected = await run(project, env, [
|
|
'collect',
|
|
taskId,
|
|
'--wait',
|
|
'--timeout',
|
|
'60',
|
|
]);
|
|
check(
|
|
'collect reports 2 delivered',
|
|
/2 delivered/.test(collected.stdout),
|
|
collected.stdout.trim().split('\n').pop(),
|
|
);
|
|
const aContent = fs.readFileSync(
|
|
path.join(project, 'docs', 'en', 'a.md'),
|
|
'utf8',
|
|
);
|
|
check(
|
|
'target a has the transformed content',
|
|
aContent.includes('TRANSLATED(qwen-plus): # A'),
|
|
);
|
|
check('usage is reported', /usage/.test(collected.stdout));
|
|
check(
|
|
'remote input/output deleted after collect',
|
|
state.deleted.length >= 2,
|
|
state.deleted.join(','),
|
|
);
|
|
|
|
// 4. idempotent re-collect
|
|
const attemptDir = path.join(home, 'tasks', taskId, 'attempt-001');
|
|
// Names and mtimes: a re-download rewrites a file under the same name.
|
|
const snapshot = () =>
|
|
fs
|
|
.readdirSync(attemptDir)
|
|
.sort()
|
|
.map((f) => `${f}:${fs.statSync(path.join(attemptDir, f)).mtimeMs}`)
|
|
.join(',');
|
|
const filesBefore = snapshot();
|
|
const deletesBefore = state.deleted.length;
|
|
const again = await run(project, env, ['collect', taskId]);
|
|
check(
|
|
're-collect is a no-op success',
|
|
again.code === 0 && /2 delivered/.test(again.stdout),
|
|
`exit ${again.code}`,
|
|
);
|
|
check(
|
|
'no extra remote deletes on re-collect',
|
|
state.deleted.length === deletesBefore,
|
|
state.deleted.join(','),
|
|
);
|
|
check('no re-download on re-collect', snapshot() === filesBefore);
|
|
|
|
// 5. list
|
|
const listOut = await run(project, env, ['list']);
|
|
check(
|
|
'list shows the task with progress',
|
|
listOut.stdout.includes('2/2 delivered'),
|
|
);
|
|
|
|
// 6. failure + retry: new plan where item b is truncated once
|
|
fs.writeFileSync(
|
|
path.join(project, 'plan2.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
name: 'e2e-translate-2',
|
|
kind: 'document-transform',
|
|
shared: {
|
|
instructions: 'Translate to English. Return only the document.',
|
|
},
|
|
items: [
|
|
{ id: 'a', source: 'docs/zh/a.md', target: 'docs/en/a2.md' },
|
|
{ id: 'b', source: 'docs/zh/b.md', target: 'docs/en/b2.md' },
|
|
],
|
|
}),
|
|
);
|
|
const run2 = await run(project, env, ['run', 'plan2.json']);
|
|
const taskId2 = /task (\S+):/.exec(run2.stdout)?.[1];
|
|
state.failItem = 'b#1';
|
|
state.behavior = 'auto';
|
|
const collect2 = await run(project, env, [
|
|
'collect',
|
|
taskId2,
|
|
'--wait',
|
|
'--timeout',
|
|
'60',
|
|
]);
|
|
check(
|
|
'truncated item is failed, not delivered',
|
|
/1 delivered, 0 held, 1 failed/.test(collect2.stdout),
|
|
collect2.stdout,
|
|
);
|
|
check('failure reason names truncation', /truncated/.test(collect2.stdout));
|
|
check(
|
|
'failed target was not written',
|
|
!fs.existsSync(path.join(project, 'docs', 'en', 'b2.md')),
|
|
);
|
|
|
|
// 7. a truncated item is not resent with the same limit (it would fail
|
|
// and bill again); raising the limit resubmits only that item
|
|
state.failItem = null;
|
|
const skipOut = await run(project, env, ['retry', taskId2]);
|
|
check(
|
|
'retry skips a truncated item without a larger limit',
|
|
/skipping 1 truncated item/.test(skipOut.stdout) &&
|
|
!/batch job:/.test(skipOut.stdout),
|
|
skipOut.stdout,
|
|
);
|
|
const retryOut = await run(project, env, [
|
|
'retry',
|
|
taskId2,
|
|
'--max-output-tokens',
|
|
'8192',
|
|
]);
|
|
check(
|
|
'retry prints a new batch job',
|
|
/batch job: batch-3/.test(retryOut.stdout),
|
|
retryOut.stdout,
|
|
);
|
|
const collect3 = await run(project, env, [
|
|
'collect',
|
|
taskId2,
|
|
'--wait',
|
|
'--timeout',
|
|
'60',
|
|
]);
|
|
check(
|
|
'retry delivers the failed item',
|
|
/2 delivered, 0 held, 0 failed/.test(collect3.stdout),
|
|
collect3.stdout,
|
|
);
|
|
check(
|
|
'b2 written after retry',
|
|
fs
|
|
.readFileSync(path.join(project, 'docs', 'en', 'b2.md'), 'utf8')
|
|
.includes('TRANSLATED'),
|
|
);
|
|
|
|
// 8. target conflict: b3 exists with other content -> held -> resolve -> delivered
|
|
fs.writeFileSync(
|
|
path.join(project, 'plan3.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
name: 'e2e-translate-3',
|
|
kind: 'document-transform',
|
|
shared: {
|
|
instructions: 'Translate to English. Return only the document.',
|
|
},
|
|
items: [{ id: 'b', source: 'docs/zh/b.md', target: 'docs/en/b3.md' }],
|
|
}),
|
|
);
|
|
fs.writeFileSync(path.join(project, 'docs', 'en', 'b3.md'), 'user edits');
|
|
const run3 = await run(project, env, ['run', 'plan3.json']);
|
|
const taskId3 = /task (\S+):/.exec(run3.stdout)?.[1];
|
|
const collect4 = await run(project, env, [
|
|
'collect',
|
|
taskId3,
|
|
'--wait',
|
|
'--timeout',
|
|
'60',
|
|
]);
|
|
check('conflicting target is held', /1 held/.test(collect4.stdout));
|
|
check(
|
|
'held target keeps user content',
|
|
fs.readFileSync(path.join(project, 'docs', 'en', 'b3.md'), 'utf8') ===
|
|
'user edits',
|
|
);
|
|
fs.rmSync(path.join(project, 'docs', 'en', 'b3.md'));
|
|
const collect5 = await run(project, env, ['collect', taskId3]);
|
|
check(
|
|
'held item delivers after conflict resolved',
|
|
/1 delivered, 0 held/.test(collect5.stdout),
|
|
);
|
|
|
|
// 9. cancel: fresh task, cancel before settle
|
|
fs.writeFileSync(
|
|
path.join(project, 'plan4.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
name: 'e2e-translate-4',
|
|
kind: 'document-transform',
|
|
shared: {
|
|
instructions: 'Translate to English. Return only the document.',
|
|
},
|
|
items: [{ id: 'a', source: 'docs/zh/a.md', target: 'docs/en/a4.md' }],
|
|
}),
|
|
);
|
|
const run4 = await run(project, env, ['run', 'plan4.json']);
|
|
const taskId4 = /task (\S+):/.exec(run4.stdout)?.[1];
|
|
state.behavior = 'stay';
|
|
const cancelOut = await run(project, env, ['cancel', taskId4]);
|
|
check(
|
|
'cancel warns about billed partials',
|
|
/still billed/.test(cancelOut.stdout),
|
|
);
|
|
|
|
// 10. clean: refuses while the cancelled batch is uncollected; a collected
|
|
// task goes without touching the provider or the delivered files
|
|
const cleanRefused = await run(project, env, ['clean', taskId4]);
|
|
check(
|
|
'clean refuses a task whose batch is not collected',
|
|
cleanRefused.code !== 0 && /--force/.test(cleanRefused.stderr),
|
|
cleanRefused.stderr,
|
|
);
|
|
const cleanOut = await run(project, env, ['clean', taskId]);
|
|
check(
|
|
'clean removes a collected task and keeps its delivered files',
|
|
/removed local record/.test(cleanOut.stdout) &&
|
|
!fs.existsSync(path.join(home, 'tasks', taskId)) &&
|
|
fs.existsSync(path.join(project, 'docs', 'en', 'a.md')),
|
|
cleanOut.stdout + cleanOut.stderr,
|
|
);
|
|
|
|
const failed = results.filter(([, ok]) => !ok);
|
|
console.log(
|
|
`\n${results.length - failed.length}/${results.length} checks passed`,
|
|
);
|
|
process.exitCode = failed.length ? 1 : 0;
|
|
} finally {
|
|
server.close();
|
|
fs.rmSync(project, { recursive: true, force: true });
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
fs.rmSync(fakeHome, { recursive: true, force: true });
|
|
}
|