fix(SPEC-DFLASH2): key the draft context by request id, so a condensed batch row cannot take it from its owner (#2008) - #2010
Open
localai-bot wants to merge 7 commits into
Open
fix(SPEC-DFLASH2): key the draft context by request id, so a condensed batch row cannot take it from its owner (#2008)#2010localai-bot wants to merge 7 commits into
localai-bot wants to merge 7 commits into
Conversation
…ense moves a live request between rows (#2008) DFlash2 serves exactly one sequence. At concurrency 2 the engine refuses on its own invariant and EngineCore stops, taking every later request on that server with it. This spec records the diagnosis and the shape of the repair before any of it is written. The operator's isolation settles which layer is at fault: with `--speculative-config` omitted and everything else identical, the same two concurrent requests both complete. So batching, scheduling, the paged KV cache, the block tables and the sampler all serve two sequences correctly, and the defect is confined to the draft's per-request context accumulation. That accumulation lives in four `GPUModelRunner` arrays indexed by BATCH ROW, and a row index is not stable for a request's lifetime here. `InputBatch::condense` slides a live request down into the hole a finished neighbour left, and `swap_states` exchanges two live rows. Both permute every per-slot array they own, including the block-table rows, and neither knows the runner's four exist. The survivor of a completed pair therefore meets the departed request's bookkeeping, its store is reset to empty, and the invariant correctly refuses rather than drafting from a foreign context. `ok=1` at c=2 is that mechanism's signature rather than an incidental count. Upstream has no analogue of the host-side counter at all. Read beyond our parity pin at `b389ac2946`, DFlash and DFlash2 address the draft KV by absolute position, re-read their one anchor from the target's own positions every step, and index every cross-step tensor by the persistent request slot through `idx_mapping` — in a V2 runner that has no `condense` at all. The legacy V1 runner does condense, and there the draft's block-table row moves with the request. Both upstream shapes key the draft context to the REQUEST; ours keys it to neither, which is the whole defect. The spec also records what a repair must not do. Marking the moved row disabled makes the throw go away and emits IDENTICAL TOKENS, because the verify is lossless and a request that stops speculating just runs slower. A token gate cannot see that, so the gate this row lands asserts the survivor keeps proposing and that its drafts are the ones it would have proposed alone. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: CLAUDE-CODE:claude-opus-5 [Claude Code]
…d batch row cannot take it from its owner (#2008) DFlash2 served exactly one sequence. At concurrency 2 the engine refused on its own position invariant one request in, EngineCore stopped, and every later request on that server came back `[request submitted to a stopped AsyncLLM]`. The measured c=2 signature was ok=1 failed=7, and ok=1 is the mechanism rather than an incidental count. The runner held the draft context in four arrays indexed by batch row. A row index is not stable for a request's lifetime here: `InputBatch::condense` slides a live request down into the hole a finished neighbour left, and `swap_states` exchanges two live rows. Both permute every per-slot array they own, including the block-table rows, and neither knows these four exist, because they live on `GPUModelRunner` rather than in `InputBatch`. So when the first of two concurrent requests finished, the survivor was moved onto the departed request's bookkeeping, the reused-slot test read a changed occupant, and the store was reset to EMPTY under a request still using it. The invariant then refused, which is what it is for. Keying the state by request id is what upstream does on both of its paths, not a workaround for what upstream does. Its V2 runner, where DFlash2 lives, has no `condense` at all — a finished request's slot returns to a free list and stays that request's — and every cross-step speculator tensor is indexed through `req_state_idx = idx_mapping[req_idx]`; its legacy V1 runner does condense, and there the draft's block-table row moves with the request. A request id is the key that survives any reordering of our batch, so every permutation the batch can perform is now a no-op for the draft context. The reused-slot test goes away with it. "Has this row's occupant changed" was only ever a proxy for "is this state this request's", and the proxy is precisely what broke: after a condense move the answer was yes for a request whose context was perfectly valid. Keyed by id the question cannot be asked wrongly, a first sight of an id constructs the entry, and that construction is the reset. The `swap_states` reorder is fixed for free; it is inert for a Qwen3 target today, so it is not the reproduction, but it is the same defect. The gate drives two concurrent requests through the synchronous production front and reproduces the discontinuity on CPU, which is what converts this from something only a GPU run can see. Its load-bearing leg is not "it did not throw": marking the moved row disabled also makes the throw stop and emits IDENTICAL tokens, because the verify is lossless and a request that stops speculating just runs slower. Built on the pre-change code, that repair leaves the first case green and turns the propose count to nine steps of `NO proposing rows`, which is the leg that refuses it. One leg was written and removed rather than shipped: comparing the drafted blocks against a solo control is a tautology on this fixture, whose synthetic draft emits `12 12 12` at every step regardless of context. The tail of the test file records that, and the position invariant it leaves ungated is filed as #2009. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: CLAUDE-CODE:claude-opus-5 [Claude Code]
…t gated nothing (#2008, #2009) Two corrections to the spec, both of which change what a reader would conclude. The decode-first reorder is not "inert for a Qwen3 DFlash2 target today". It runs unconditionally on every step and has no model-family gate at its call site. What is true is narrower: it emits no swap while the batch's arrival order already puts decodes before prefills, which is the common case because condense keeps older decoding requests at low rows and a new arrival appends at the end as a prefill. That is why the measurement met the condense move first, and not a reason the reorder cannot desynchronise two live requests' draft contexts. The block-comparison leg the spec claimed as its anti-masking gate is a tautology on this fixture, and was written and run before that was found: with the position invariant deleted and the context reset at every row move, the synthetic draft still emits the same block at every step of both runs, because seeded-noise weights over a 24-token vocabulary collapse the selector walk to one id. Nine passing string comparisons measured nothing. The leg is removed, the mutation table records what each rival repair actually did, and the invariant it leaves ungated is filed as #2009 rather than left implicit. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: CLAUDE-CODE:claude-opus-5 [Claude Code]
…ed hypothesis (#2008, #2009) The spec reported, beside the fix, "a distinct c=1 defect this found and did not fix" — that a prefix-cache hit or a resumed request is admitted with `num_computed_tokens > 0`, has no draft context for the cache-supplied tokens, and trips the same position invariant. That was asserted from reasoning and had never been reproduced. It still has not been. A throwaway probe on the shared DFlash2 fixture forced `EngineParams::enable_prefix_caching = true`, which `ResolveEnablePrefixCaching` honours verbatim, and issued the same 20-token prompt twice through the synchronous production front. Both requests completed, nothing threw, and the engine's own `prefix_cache_metrics()` reported `queries=40 hits=0`. The cache never engaged, so the probe measured nothing about the hypothesis rather than refuting it: the fixture's target is a GDN hybrid, the family upstream defaults prefix caching OFF for, and no prefix-cache hit is reachable on it. A probe whose own precondition failed is not evidence in either direction, so the claim is downgraded to a labelled hypothesis and the measurement that failed to test it is recorded beside it. Confirming or killing it needs a decoder-only DFlash2-capable target. Also links #2009 from the spec's `## Owed`, which `check-agent-record.py` requires of an issue filed without a fix, and repairs that issue's index row: its Row column used an ASCII hyphen where the format wants an em dash for "no owning row", which the same checker refuses. Nothing about the #2008 fix, its gate or its mutation evidence changes. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: CLAUDE-CODE:claude-opus-5 [Claude Code]
`origin/main` moved to 2a42cb3 while this row was under gate, which made the staged preflight's commit-trailer check SKIP rather than run: the recorded base was no longer an ancestor of HEAD, and a diff-scoped gate that skips still exits zero. Merging restores the ancestry so that check has something to measure. What arrives is #1999, the recurrent-state pool bounded by the KV budget. It is one of the four fixes the #2008 measurement ran on top of, so this branch now carries it too. The merge is clean and touches nothing this row changes: #1999 adds `hybrid_kv_budget`, and this row edits only the DFlash2 draft context on the runner. The append-only issue index union-merged with both of this row's rows intact, which was verified rather than assumed. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: CLAUDE-CODE:claude-opus-5 [Claude Code]
This was referenced Aug 26, 2026
main advanced 45 files past this row's last merge base (2a42cb3) and GitHub reported the PR `CONFLICTING`. Two of this row's six files overlap that delta, and both were resolved by the AGENTS.md `## Records` rule rather than by accepting the automatic merge. `.agents/issue-index.md` was union-appended and then checked by row-ID set difference rather than by reading the diff: 730 base + 2 ours (#2008, #2009) + 15 theirs = 747 expected, 747 actual, 0 lost, 0 invented, 0 duplicated. Every merged row is byte-identical to a row in one of the three sources, and the preamble is byte-identical to base on all three sides. GitHub does not honour the `merge=union` driver, which is why this file is the whole reason the PR read dirty. `tests/CMakeLists.txt` was rebuilt rather than trusted: origin/main's complete file (blob 6a616d7) with this row's seven lines re-applied after the `test_dflash2_ctx_capacity` source line, an anchor asserted to occur exactly once. The rebuild is byte-identical to the three-way result. Nothing in the delta touches `runner.h` or `runner.cpp`, so this row's request-keyed draft context arrives unchanged: `git diff origin/main` is this row's original six files, 786 insertions and 68 deletions, and `git diff 3d895a2` reproduces origin/main's entire 45-file, 6785- insertion, 333-deletion delta with nothing dropped. No conflict region was reconstructed, so no take-both brace hoist is possible here; the resolved tree was built and run anyway. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [Claude Code]
…URRENCY Two commits landed while this row waited. #2000 is the first commit on main ever to touch the two files this row rewrites, so the runner is now a real interaction surface and is checked as one rather than accepted on git's word. #1997 arrived during the resolution; the merge onto #2000 alone was never pushed, so it was discarded and this is one merge onto the final head rather than two stacked ones. #1997 DOES NOT TOUCH THE RUNNER. Its 28 files are the sampling defaults, the Gumbel kernels, config resolution and the OpenAI protocol surface; neither `runner.h` nor `runner.cpp` is among them. The runner overlap is entirely #2000's, and it is checked rather than assumed because a prediction about which file would conflict was already wrong once on this branch. BOTH RUNNER FILES THREE-WAY MERGED WITH NO CONFLICT, and the reason is that the changes are disjoint by a wide margin rather than that git was lucky. Against the common base b758127, #2000 edits `runner.h` at 357-474 and `runner.cpp` at 334-823; this row edits `runner.h` at 836-870 and `runner.cpp` at 2752-3148. The merge is verified in both directions: this row's delta for each file is byte-identical to `git diff b758127 2ea92d2`, and main's delta for each file is byte-identical to `git diff b758127 origin/main`, at shifted offsets only. Brace balance is checked arithmetically for the take-both failure this branch already recorded, even though no conflict region existed to reconstruct: `runner.h` 53 base / 54 ours / 54 theirs, expected 55, merged 55; `runner.cpp` 335 / 337 / 335, expected 337, merged 337; both counts for `{` and for `}`. The issue index was union-appended and checked by row-ID set difference against the CURRENT base rather than by reusing either earlier round's numbers: 745 base + 2 ours (#2008, #2009) + 5 theirs (#1963, #1966 from #2000; #1984, #1985, #2002 from #1997) = 752 expected, 752 actual, 0 lost, 0 invented, 0 duplicated, every merged row byte-identical to a row in one of the three sources and the preamble unchanged on all three sides. `tests/CMakeLists.txt` was rebuilt from the new origin/main (blob d4d6cff, sha256 d4011b98eb1dedae75f2563400a408561cae89996d90d045279a0d38420a6dd5) with this row's seven lines re-applied after the `test_dflash2_ctx_capacity` source line, an anchor asserted to occur exactly once. The rebuild is byte-identical to the three-way result, `git diff origin/main` is 7 added and 0 deleted, and both #2000's `target_include_directories` line and #1997's `test_generation_config` registration survive in it. This row's reviewed change is unchanged across all three re-merges. Measured against the original reviewed delta `git diff 2a42cb3 3d895a2`, the added and removed line sets are identical: 786 added and 68 removed excluding file headers, 792 and 74 including them. The spec and `test_dflash2_concurrency.cpp` are blob-identical to the reviewed head. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [Claude Code]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DFlash2 served exactly one sequence. At concurrency 2 the engine refused on its own position invariant one request in,
EngineCorestopped, and every later request on that server came back[request submitted to a stopped AsyncLLM]. This is the blocking defect for the #1574 three-engine comparison: vLLM and SGLang were both measured at c=1..32 with the same DFlash2 draft at k=8, and we could produce c=1 and nothing above it.Closes #2008.
Why the accumulation desynchronises
The runner held the draft context in four arrays indexed by batch row. A row index is not stable for a request's lifetime here:
InputBatch::condenseslides a live request down into the hole a finished neighbour left (input_batch.cpp:686-706), andswap_statesexchanges two live rows (:762-847). Both permute every per-slot array they own —req_ids,num_computed_tokens_cpu,num_accepted_tokens,last_sampled_tokens,prefill_len, the block-table rows, the index-keyed sampling maps — and neither knows these four exist, because they live onGPUModelRunnerrather than inInputBatch.So when the first of two concurrent requests finished, the survivor was moved onto the departed request's bookkeeping. The reused-slot test at
runner.cpp:2895-2906read a changed occupant, allocated a fresh empty store and setctx_len = 0— under a request still using it — and the invariant at:2939-2945then refused rather than drafting from a foreign context.ok=1at c=2 is that mechanism's signature, not an incidental count.At c=1 the only row is row 0: a request finishes, the next is admitted into the same row, and the reset is correct, because the new occupant is a fresh prefill whose first position is 0. The condense move that breaks the state exists only when a second live request has to be slid over a departed one.
The operator's isolation had already settled the layer: with
--speculative-configomitted and everything else identical, the same two concurrent requests both complete.This tree already names the bug class, in a comment above the log written to solve it for a different array (
input_batch.h:240-252): "Upstream needs no equivalent because it never condenses ... the slot index is stable for the request's lifetime. This log is the price of our condensed dense batch." The DFlash2 arrays never got that treatment.Upstream
Read beyond our parity pin at
b389ac2946(the clone is shallow and checked out at the pin, so anchors were read withgit show). Upstream has no analogue of the host-side counter at all. DFlash and DFlash2 address the draft KV by absolute position (dflash/speculator.py:562-590), re-read their one anchor from the target's own positions every step (:553), and index every cross-step tensor by the persistent request slot throughreq_state_idx = idx_mapping[req_idx](:536,dflash2/speculator.py:95) — in a V2 runner that has nocondenseat all (gpu/states.py:29,100,132). Its legacy V1 runner does condense, and there the draft's block-table row moves with the request (gpu_input_batch.py:786→block_table.py:367-373).Both upstream shapes key the draft context to the request. Ours keyed it to neither.
The change
One
unordered_map<string, DflashReqCtx>holding the store, the context length and the disabled flag. Every row permutation the batch can perform — today's two and any future one — is now a no-op for the draft context.The reused-slot test goes away with it. "Has this row's occupant changed" was only ever a proxy for "is this state this request's", and the proxy is precisely what broke: after a condense move the answer was yes for a request whose context was perfectly valid. Keyed by id the question cannot be asked wrongly; a first sight of an id constructs the entry, and that construction is the reset.
dflash_ctx_disabled_'s own comment — "the flag is a property of the REQUEST, not of the row" — becomes literally true. The decode-first reorder is fixed for free, and it is a second live trigger rather than a hypothetical one.reorder_batch_to_split_decodes_and_prefillsruns unconditionally every step (runner.cpp:1324) and swaps live rows throughswap_states. It emits no swap only while arrival order already puts decodes before prefills — the common case, since condense keeps older decoding requests at low rows and a new arrival appends at the end as a prefill — which is why the #2008 measurement met the condense move first. A batch whose regions are out of order does swap, and on the pre-change code that exchanged two live requests' draft contexts. (The commit body and an earlier draft of the spec called this path "inert for a Qwen3 target today"; that was wrong and the spec now says so — the reorder has no model-family gate at its call site.)Entries are pruned each propose against
InputBatch's membership — the authority on residency, unlikeexec_state_.req_ids, which lists only the rows scheduled this step. Each entry owns a device allocation, so pruning is part of the change rather than a follow-up.One detail a reviewer will want checked: the prune is guarded by
size() > num_reqs(), and a departure paired with an arrival in the same step can leave the sizes equal with a stale entry present. That entry survives exactly one further step and cannot be read in the meantime, because lookup is by current request id. It is then reclaimed: the resolve loop inserts the newcomer, so the next prune seessize() > num_reqs()and fires. The lag is bounded at one step and is self-correcting; it does not accumulate.Adopting upstream's paged shape — the draft context as a real KV cache group carried by
MultiGroupBlockTable::move_row— is the right end state and is recorded as owed, not attempted. It replacesDflashDeviceKVStore, changes how #1919 sizes the draft budget and how #2007 splits the pools, and would land the concurrency repair behind a rewrite.The gate
tests/vllm/v1/spec_decode/test_dflash2_concurrency.cppdrives two concurrent requests through the synchronous production front and reproduces the discontinuity on CPU, which is what converts this from something only a GPU run can see.Its load-bearing leg is not "it did not throw". Marking the moved row disabled — the #1919 fallback, which
continues before the invariant — also makes the throw stop and emits identical tokens, because the verify is lossless and a request that stops speculating just runs slower. So the gate asserts that the survivor keeps proposing, and at every step it is alive for.Mutations
Each rival repair was built on the pre-change code and measured, not argued about.
none_lines == 9— the survivor stopped proposing for all nine remaining steps.propose_drafts_blockcall site inpropose_drafts_dflash.One leg was written, run, and removed rather than shipped. Comparing the drafted blocks against a solo control is a tautology on this fixture: with the invariant deleted and the context reset at every row move, the synthetic draft still emits
12 12 12at every step of both runs — seeded-noise weights over a 24-token vocabulary, and the selector walk collapses to one id. Nine passing string comparisons measured nothing. The test file and the spec both record it, because the next person to reach for that leg will find the same fixture. That B′ is consequently ungated is #2009, not a thing this row quietly left out.What this does not do, and what must be measured before it is trusted
A concurrency change does not merge here on a green compile. The end-to-end ladder is the operator's to run — rungs c = 1, 2, 4, 8, 16 at the #2008 flags. Predictions and falsifiers are in the spec under
## Gates. In short: c=1 must be inert (24.70 out tok/s, TPOT 37.90 ms), no rung may VOID or reportfailed > 0, and c=2 must not come in below c=1. A rung that lands low but completes is #2007 and theP == 1capture gate being measured for the first time — this change is what lets a batch reachP > 1at all — and those are named, owned and out of scope.Gates run
cmake --build build -j 4(CI's CPU configuration)error:linesctest --test-dir build --output-on-failuretest_dflash2_concurrencyin that suitetest_dflash2_runner_reach(sibling DFlash2 gate)scripts/agent-preflight.sh --stagedcheck-agent-record.pycheck-commit-trailers.py/check-commit-style.pyExit codes were captured explicitly rather than read off the tail, which is how the first preflight run's
role-undeclaredfailure was caught behind a clean-looking output. The staged preflight's trailer check initially SKIPPED becauseorigin/mainhad moved and was no longer an ancestor of HEAD — a diff-scoped gate that skips still exits zero.origin/mainis merged (bringing #1999) and both checks were then rerun against the new base.Found and not fixed
num_computed_tokens > 0and has no draft context for the cache-supplied tokens, so its first propose would readpositions[rows[0]] > 0againstL == 0. It was not reproduced. A throwaway probe forcedenable_prefix_caching = trueand issued the same 20-token prompt twice; both completed, nothing threw, and the engine's ownprefix_cache_metrics()reportedqueries=40 hits=0— the cache never engaged, so the probe measured nothing about the hypothesis. The fixture's target is a GDN hybrid, the family upstream defaults prefix caching OFF for. Reported as a hypothesis, not a defect; confirming it needs a decoder-only DFlash2-capable target.Re-merge onto
main(b758127ec)origin/mainmoved tob758127ec(#1994) and GitHub reported this PRCONFLICTING. Re-merged; head is2ea92d2fc. The sole conflict under GitHub's merge is.agents/issue-index.md— themerge=uniondriver resolves it locally and GitHub does not honour that driver.tests/CMakeLists.txtmerges clean either way, and no source file conflicted, so no take-both resolution exists on this branch..agents/issue-index.mdwas union-appended and then checked by row-ID set difference rather than by reading the diff: base 730 + ours 2 (#2008, #2009) + main 15 = 747 expected, 747 actual, 0 lost / 0 invented / 0 duplicated, with every merged row byte-identical to a row in one of the three sources.tests/CMakeLists.txtwas rebuilt fromorigin/main(blob6a616d781) with this row's edit re-applied after thetest_dflash2_ctx_capacitysource line, an anchor asserted to occur exactly once. The rebuild is byte-identical to the three-way result;git diff origin/mainis 7 added, 0 deleted.Nothing this row owns moved. Comparing the reviewed pre-merge delta against the post-merge delta, normalised for hunk offsets, the added and removed line sets are identical (792 added, 74 removed) — the only difference is two context lines, where main's #1982 and #1992 index rows now sit adjacent to this row's. Main's delta touches neither
runner.hnorrunner.cpp, and both, along withtest_dflash2_concurrency.cpp, are blob-identical to the reviewed head3d895a202, so the request-id keying of the draft context is untouched.Re-merged again onto
2c27f0d57, which carries #2000 and #1997. Head is53e144f37. #2000 is the first commit onmainto touchrunner.handrunner.cpp; #1997 touches neither. Both runner files three-way merged with no conflict because the edits are disjoint against the common baseb758127ec— #2000 atrunner.h357-474 andrunner.cpp334-823, this row atrunner.h836-870 andrunner.cpp2752-3148 — and the merge is verified in both directions, each side's per-file delta byte-identical to its own source diff at shifted offsets. Brace balance checked arithmetically anyway:runner.hexpected 55, merged 55;runner.cppexpected 337, merged 337; both for{and}. Index re-checked against the current base: 745 + 2 ours (#2008, #2009) + 5 theirs (#1963, #1966, #1984, #1985, #2002) = 752 expected, 752 actual, 0 lost / 0 invented / 0 duplicated.tests/CMakeLists.txtrebuilt from blobd4d6cff71at the same uniqueness-asserted anchor, 7 added / 0 deleted, with #2000's and #1997's own lines intact. The reviewed change is unchanged across all three re-merges: added/removed line sets identical togit diff 2a42cb369 3d895a202(786/68 excluding file headers, 792/74 including them).The full
ctestsuite has NO local verdict on this head, and CI carries it. The box was at 98% disk; a full build drove the root filesystem to 100% with 4.1 GiB free, which is the ENOSPC state that makes unrelated checkers emit false policy refusals, so it was aborted and the space reclaimed. The focused suites were re-run after the abort from a clean rebuild —ctest -R dflash27/27,test_dflash2_concurrency2/2 (21 assertions),test_dflash2_runner_reach8/8,test_dflash2_ctx_capacity6/6,test_runner20/20, every one rc=0 — andscripts/agent-preflight.sh --stagedis green.FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: CLAUDE-CODE:claude-opus-5 [Claude Code]