From 375ff497525f1c65287222a9090b78d3551250ce Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 20 Aug 2026 23:50:42 +0000 Subject: [PATCH 1/4] feat(review): tell the author why a review loop is not settling (#9461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(review): tell the author why a review loop is not settling The convergence advisory design's diagnosis half (#9278), reading the telemetry #9413 started recording. A push-triggered review plus an agent addressing its findings is a feedback loop whose gain can exceed 1, and measured on this repository the loop has carried PRs to hundreds of open threads — one closed unmerged at ~500. The posting floor damps the volume; nothing told anyone WHY a particular loop was not settling. Two signals fire the observation, both comparisons between this pull request's own rounds. Recurrence: a file that carried a finding in an earlier round and carries more now, joined by FILE and deterministically, with the prior rounds read off the carried ledger ids so the rendered sentence is checkable against the PR's own history (title similarity was considered and dropped — model-written, 80-char-capped titles are noise at exactly the length where a match would matter). And volume that is not shrinking, from round 3, because two rounds give one step and a step is not a trend. There is no threshold anywhere in it. A volume bar is somebody's policy, and a policy the tool owns is one it would have to defend on repositories it knows nothing about: a PR diverging at 40 comments deserves the reading a threshold of 100 would have delayed, and a large review whose findings are shrinking deserves no interruption at all. It decides nothing. No finding is withheld, no verdict capped, no cap state added — the paragraph says so in its own last sentence. The recommendations stay at the process level (triage the cause, split an independent cluster, batch the fixes, drop the floor) and never prescribe how code should be restructured, because this module cannot verify such a claim and an unverifiable claim is what the rest of this pipeline refuses to post. A round that looks healthy produces no diagnosis at all rather than an empty section. * fix(review): a loop that posted nothing is settled, not stalled (triage round 1) Both triage nits, and the second was a real inversion. `0 >= 0` is arithmetically "not shrinking" and semantically its opposite: a round that posted nothing is precisely where a settling loop lands, so the volume signal narrated "the volume is not falling" at the moment it had finished falling — flagging the settled state as the unsettled one. The signal is about a loop still producing comments at an undiminished rate, so it now requires this round to have produced some. Both zero shapes are pinned: flat at zero, and the fall to zero from above. The Approve branch's separator condition now counts the convergence block. That combination is unreachable — an APPROVE is composed from zero findings, so neither signal can fire there — and it is listed anyway, with the reason recorded: a condition that is right only because another rule makes its input impossible is a trap for whoever changes that other rule. (A test asserting it was written and then deleted rather than shipped: it could only have passed vacuously.) * fix(review): harden the convergence diagnosis against round 1 review Round 1 on #9461 found two blockers and fifteen suggestions. The two blockers: - The diagnosis received an unclamped `prevRound + 1` while every other public round surface in the composer clamps to `LEDGER_MAX_ROUND`, so at the cap the prose named round 10001 beside a marker stamping 10000. - Cluster paths are PR-controlled and were spliced into the posted body inside hand-spelled backticks. A filename carrying a backtick or a newline broke out of the code span and rendered as live Markdown — a working @mention, a forged body line — in a body this bot posts under its own identity. `mdField` moves to `lib/md-field.ts` and both templates go through it. Signal correctness: - Carried re-posts of still-standing findings no longer count as fresh activity. Step 6 re-posts every unfixed ledger Critical under its original id, so one unfixed blocker fired both signals every round at exactly the steady state they exist to distinguish from divergence. - The volume trend needs a non-zero predecessor: `N >= 0` is true for every N, so restarting from a settled round fired on the healthiest shape there is. - Drafted paths are canonicalised to `LEDGER_MAX_FILE`, the cap the other side of the join already applies, so the signal is no longer blind to paths past 200 characters. - The previous work list is gated on its own round, like the volume already was; a side file with no usable round no longer seeds citations. Evidence and advice are now qualified rather than presented bare: a truncated work list (`dropped > 0`) and one recovered from another account's marker are both disclosed in the rendered sentence, and the floor recommendation is dropped when the round already resolves to `--severity-floor critical`. `pr-context` persists that provenance. Also: the block carries `trim: 1` so an advisory paragraph yields before the disclosures that qualify the verdict; the path tie-break compares code units instead of consulting the runtime locale; the English closing claim is scoped to the observation the way its Chinese twin already was; the `(body)`/`(unknown)` pseudo-paths and the carried-id readback each get one shared statement instead of two; and the diagnosis is composed beside `postedInline` so its count and the marker's cannot drift. Seventeen mutations, one per load-bearing decision, each verified to turn a named test red. * fix(review): close the round 2 findings on the convergence diagnosis Three blockers: - The overflow ladder's notice surfaces all key on the trim RANK, not on what actually dropped, so sharing rank 1 with the deferral list made a round that shed only the convergence paragraph announce a "deferred-findings list" that never existed and point the author at artifact entries that do not exist. The block gets its own rank, shed before every other, named for itself and carrying no artifact pointer. - `parseLedger`'s squat filter is anchored and untrimmed while every reader downstream trims, so an id like ` R9999-1` passed the filter that exists to stop it and then took full effect — pre-claiming the next round's id prefix and citing a round no account ever ran. Admission is now the whole grammar, at the single entry point; `LEDGER_ID_SHAPE` moves to ledger.ts and presubmit imports it rather than restating it. - The foreign-provenance caveat was gated on the recurrence reading, but the previous round's VOLUME comes from the same marker and the volume-only branch cites it as this loop's baseline. A foreign winner now loses `posted`/`prevPosted`/`floor` at the recovery seam the way it already loses the anchor, and the caveat attaches to any reading that cites previous-round evidence. The volume trend also stops reading a POSTURE change as loop divergence: the round's resolved floor is stamped beside the volume it qualifies and sheds with it, and a previous round under a different floor makes the trend unevaluable rather than true. A floor never recorded is not a floor that differs, so pre-field markers evaluate as before. Also: the advice names an auto-resolved floor the way the enforcement note in the same body does, instead of claiming a `--severity-floor critical` flag nobody passed; the pseudo-paths become a structural flag on the finding, so a real file named `(body)` clusters like the file it is; the re-post exclusion fails toward "carried" where the id space collides at the round cap; and `mdField` no longer emits a bare backtick run for a value that strips to empty, which two planted filenames could pair into a span around the bot's own prose. Fourteen mutations, one per load-bearing decision, each verified to turn a named test red. * fix(review): close the maintainer review of the convergence diagnosis Nineteen findings on the diagnosis and the ledger it reads. The ones that changed a judgement rather than a line: **The trend was measuring the wrong number.** Step 6 re-posts every unfixed ledger Critical under its original id, so the re-post floor only ever rises: a loop whose NEW findings collapsed from five to one still posts more comments than the round before, and a trend on the totals called that convergence "not falling" — permanently. The marker now records `fresh` beside `posted`, the trend runs on it, and a predecessor that recorded no fresh count leaves the trend unevaluable rather than measured on the wrong number. A carried id must also NAME a standing entry: models emit stray ids at the head of a claim line, and trusted on the token alone a genuinely new finding vanished from both signals. **The sort key inverted the premise of the advice it ranked.** The previous round's ledger is a POSTING set, so `priorRounds` deepens only where nothing is being fixed — and the paragraph then explained the top-ranked file as "a cluster that keeps producing siblings", about a file where no fix happened. It is also the key a stranger could set: one marker holding fifty legal ids on one file put a fabricated cluster in the top slot and evicted a genuine one. This round's new-finding count leads now. **Two readings, one branch.** The renderer discriminated on the clusters alone, so on the shape this feature exists for — recurrence and a flat trend together — the volume sentence and the entire floor recommendation were unreachable. Both are reported when both fire. **Trust seams.** `parseLedger`'s admission test is now shared with compose-review's side-file read, which restated two of its checks and skipped the rest; `dropped` counts what the filter rejected, so a shortened list no longer reads as complete and still certifies its anchor; the serializer re-validates an id after the length cap, which could cut a token mid-grammar; an unrecognised `k` is normalised rather than dropping the finding that carries it; an unknown identity is no longer read as "another account", which was stripping this account's own telemetry on a `gh` blip; foreign provenance is sticky while the work list can still carry foreign-minted ids, and records whether the list was MERGED over own entries, so the caveat stops over-claiming; and the union that protects own findings from a foreign winner now protects the own volume beside them. **Records that were false.** The convergence paragraph is returned on the result and printed as `CONVERGENCE:`, because the ladder sheds it first and the trim notice promised a terminal copy that did not exist; the floor is stamped only when the state named one, since `Ledger.floor` reserves absence for "not recorded"; and an absent floor folds to `auto` for what the round REPORTS while enforcement keeps its strict reading, so a posture the module had to guess at can still not move a finding out of the posting set. Also: the recurrence signal is held to round 3 like its sibling; the join matches a truncated ledger entry by prefix instead of truncating the drafted path, which was creating the prefix collisions it meant to avoid and posting a 200-character prefix as a real path; unmarked drafts are not counted as findings; `draftedFindingsOf` guards a non-array like its siblings; one shared projection replaces the hand-kept volume-group field lists at both persist seams; and the paragraph's REQUEST_CHANGES path — the verdict a diverging loop actually produces — is pinned. Twenty-two mutations, one per load-bearing decision, each verified to turn a named test red. * fix(review): close the bot's round 3 on the convergence diagnosis Four blockers, three of them introduced by the previous round's own fixes: - The marker's `fresh` count called `isFreshDraft` without the carried-id set while the paragraph ten lines away used the real one, so one body stated two volumes for one round — and the marker's undercount persisted as the next round's `prev.fresh`, where the trend's own guard reads it. The `EVERY_ID` default's premise was simply wrong: `convergence` is in scope at that call site. - The anonymous counter-advance branch hard-wrote `foreign: false` while keeping the work list verbatim, destroying a flag vouched under a known identity and making the sticky clause unable to ever re-fire. Both provenance flags now ride with `...kept`, like the list they describe. - The sticky `merged` term overrode a recovery that did not merge, so a pure-foreign winner inherited a claim that own-certified entries exist when every entry was a stranger's — the shape the union guard one function up refuses for the same reason. - `isLedgerFinding` bounded the id round only from above, so `R0-1` was admitted while `birthRound` rejects round 0 and `isFreshDraft` reads that rejection as "fresh": a re-posted `R0-*` counted as first-time work every round and narrated divergence at a settled steady state forever. Of the thirty-three suggestions, the ones that changed behaviour: - The side-file read counted only the marker's `dropped`, so entries its own admission test rejected shrank the work list while it still read as complete — the invariant this branch wrote into `parseLedger` two rounds ago, violated on the second read path. - Truncation qualifies BOTH readings, not just recurrence: the work list IS the carried-id set that defines freshness, so re-posts of shed entries read as first-time work and can manufacture the very trend that went undisclosed beside them. - The depth tiebreak was still a number a stranger sets, and ties are the ordinary shape — fifty planted ids on one file still evicted a genuine cluster. Depth is dropped entirely when the work list is foreign. - Carried ids are deduped the way `idFor` dedupes them, so a duplicated re-post is not read as carried here while the ledger mints it fresh. - `isLedgerFinding` gained the `LEDGER_MAX_ID` bound (admitted then sliced, an entry changes identity between rounds); `dropped` is clamped through the shared reader (it renders into the model-facing PARTIAL line and cannot be re-ruled); the anonymous seam drops `model` beside `sha`; the artifact validator carries `convergence` and `postedFresh`; and `pickVolume` makes the union's restore derive from the same list `withoutVolume` strips by. The rest were test gaps and comment drift from the previous round — a vacuous round-3 guard, a same-round comparison no test reached, a "terminal copy" test that never triggered the ladder, the `k` flag with no round-trip, and several comments still describing the design they replaced. All corrected. Fourteen mutations on the load-bearing changes, each verified to turn a named test red. * fix(review): close round 4 on the convergence diagnosis Three blockers, all on the write side of the ledger: - `serializeLedger` validated ids AFTER the length slice, and a cut can still match the grammar — `R3-` plus twenty-two nines slices to a well-formed twenty-four — so the marker carried a DIFFERENT id under the same entry. The next round's readback of the posted claim returns the full id, matches no ledger entry, and the finding retires with no ruling while the list reads as complete and the anchor still scopes past it. Admission now runs before anything is sliced, as it does on the parse side. - The `rejected` term added last round re-opened the unbounded-count hole the `declared` clamp beside it closes: `raw.findings.length` is attacker-chosen, and the sum is interpolated verbatim into the model-facing PARTIAL line. The SUM is clamped now, not one of its terms. - `idFor` kept a claimed carried id without checking it names a standing entry, so a model-emitted stray id was recorded under a fabricated mint round that the next round then CITED in a posted paragraph. Last round's fix corrected the counting side and left the recording side asymmetric. A claimed id absent from a COMPLETE work list is re-minted; over a shortened list, or with no recovered predecessor at all, it is kept — a non-member there may be an entry the byte budget shed, and continuity wins where the two cannot be told apart. Also: the own-volume restore no longer inherits the union's non-empty-list gate, so a clean own round's true-zero baseline survives a stranger's marker; the depth-key drop's `foreign`-only keying, the sticky `merged` clause's clear-on-empty, the volume group's survival through the ordinary recovered write, and the unknown-identity guard are each pinned; and the nine unasserted Chinese halves of the convergence paragraph now have assertions. One correction to a reply I posted last round: the round-3 volume-gate test was NOT fixed — the edit aborted on a failed pattern match and I reported it as landed. It is fixed here, and the mutation removing `input.round >= 3` now turns it red. Nine mutations, each verified to turn a named test red. * fix(review): close round 5 on the convergence diagnosis Four blockers, three of them asymmetries between two sides of a rule this branch already states: - The freshness determinations never received the work-list COMPLETENESS signal that `buildLedger` receives, so over a shortened list the marker kept a re-post's id (continuity wins) while the fresh count called the same comment first-time work. One marker said two things about one comment, and a loop doing no new work posted "the rate of new findings is not falling" every round. `isFreshDraft` now takes the flag and applies the same rule. A `findings` field that is not a list at all also stops reading as a COMPLETE empty list — it is a read that knows nothing, not a round that held nothing. - The floor-change guard was blind under the DEFAULT configuration: the marker recorded a posture only when the state NAMED one, while every consumer folds an absent floor to `auto` and resolves it determinately from the round and the context state. A real transition — round 6, or one transient context failure — left the guard nothing to compare and read as loop divergence, the misreading the field exists to prevent. The resolved posture is recorded now, from the same fold the advice and the enforcement backstop already use. (This reverses half of the round-3 fix, which took both alternatives that finding offered; only the fold was needed.) - The truncation caveat named the round undercount but dropped the overcount clause when no previous volume was cited — while the facts clause cites this round's fresh count unconditionally, and a shortened carried list inflates exactly that number. The overcount clause is unconditional now. - `buildLedger` stamped `line` under a looser guard than the admission test it feeds, so a model-written `12.5` made the serializer refuse the WHOLE entry: a posted finding retired unruled, the round was mislabelled budget-truncated, and the anchor was withheld. The line alone is dropped. Also closes the two bilingual parity gaps still standing from round 4 — the Chinese advice had no negative-direction assertion, and the already-at-floor branch's Chinese batching clause was pinned by no test on that branch. Seven mutations, each verified to turn a named test red. * fix(review): close round 6 — five blockers, all on rules this branch states - `isCarry`'s continuity keep admitted ids the serializer's own admission test refuses (over the length cap, round 0, a round ahead of this one). Kept, the entry is then refused WHOLE: a posted finding exits the work list owing no Step-6 ruling, the round is mislabelled budget-truncated, and the anchor is withheld. Continuity covers ids a shortened list may have shed; it cannot cover ids no list this pipeline wrote could hold. Re-minting costs the entry its cross-round id and nothing else — the same trade the integer-line guard makes. - The truncation caveat named the direction truncation mechanically DISABLES. The stray-id rescue is gated on the list being whole, so over a shortened one a genuinely new finding written under an earlier round's id cannot be rescued and reads as a re-post: the count is UNDERstated, not overstated. Both languages and the two comment blocks corrected. - An unrecognised (present, non-empty) `severityFloor` folded to `auto` in the reporting reading only, so the body claimed the round "already resolves to a critical posting floor" while its own deferral-licence clause said the floor carried no recognisable value and the enforcement backstop moved nothing. Only genuine absence folds now. - Restoring the own marker's volume onto a foreign winner re-paired own round-M counts with the winner's round N, so the paragraph attributed own numbers to a round this account never ran — "the previous round posted 0 (0 new)" beside a cluster citing a round that plainly did post. The restore applies only when the two markers describe the same round; absence already reads as "not recorded", which beats a wrong pairing. - The sticky `foreign` clause keyed on the NEW list's length, so one stranger's empty LGTM marker adopted before this account's first finding stamped a provably all-own work list foreign forever — and dropped the cluster sort's depth key over a list with zero fabrication risk. Both ends are required now: a list that could carry an id, and one that can still cite it. Six mutations, each verified to turn a named test red. * fix(review): close round 7 — two blockers on values this pipeline re-reads - `mdField` neutralised the code-span grammar and left the ledger marker's. The span makes Markdown and HTML inert to a RENDERER; this pipeline's own readers scan the raw body, and `stripLedgerMarker` takes the FIRST ``, deleting that round's prose AND its marker from the model-facing rendering. A complete forged pair additionally parses as the recovered ledger on any round the real marker is missing, which this account then trusts as its own. The delimiters are broken in the strip now. - The anonymous whole-write branch persisted the winner's volume verbatim under `foreign: false`. Without a `me` every marker walks as foreign, so the upstream strip never fires and `ownMax` is 0 — any marker inside the headroom wins. Kept, a stranger's counts became this loop's baseline: the trend evaluated against them, the paragraph cited them as own history, and they were stamped into this account's own next marker as `prevPosted`, which later recovery trusts. It takes the same "not recorded" degradation the counter-advance branch already accepts, through the same projection. Two mutations, each verified to turn a named test red. --- .../commands/review/compose-review.test.ts | 876 +++++++++++++++- .../cli/src/commands/review/compose-review.ts | 638 ++++++++++-- .../commands/review/lib/convergence.test.ts | 933 ++++++++++++++++++ .../src/commands/review/lib/convergence.ts | 640 ++++++++++++ .../src/commands/review/lib/ledger.test.ts | 249 +++++ .../cli/src/commands/review/lib/ledger.ts | 294 +++++- .../src/commands/review/lib/md-field.test.ts | 61 ++ .../cli/src/commands/review/lib/md-field.ts | 43 + .../review/pr-context-persist.test.ts | 368 ++++++- .../src/commands/review/pr-context.test.ts | 152 ++- .../cli/src/commands/review/pr-context.ts | 201 +++- packages/cli/src/commands/review/presubmit.ts | 8 +- .../src/commands/review/save-artifact.test.ts | 34 + .../cli/src/commands/review/save-artifact.ts | 37 +- 14 files changed, 4389 insertions(+), 145 deletions(-) create mode 100644 packages/cli/src/commands/review/lib/convergence.test.ts create mode 100644 packages/cli/src/commands/review/lib/convergence.ts create mode 100644 packages/cli/src/commands/review/lib/md-field.test.ts create mode 100644 packages/cli/src/commands/review/lib/md-field.ts diff --git a/packages/cli/src/commands/review/compose-review.test.ts b/packages/cli/src/commands/review/compose-review.test.ts index 2bd86ec8f1..6fcb3384eb 100644 --- a/packages/cli/src/commands/review/compose-review.test.ts +++ b/packages/cli/src/commands/review/compose-review.test.ts @@ -29,9 +29,11 @@ import { import { getGhHost, setGhHost } from './lib/gh.js'; import { BRIEFS } from './lib/agent-briefs.js'; import { + LEDGER_MAX_FILE, LEDGER_MAX_ROUND, LEDGER_MAX_VOLUME, parseLedger, + serializeLedger, } from './lib/ledger.js'; import { countInlineFindings } from './lib/inline-counts.js'; import { @@ -2665,6 +2667,55 @@ describe('composeReviewCommand handler (the CLI glue)', () => { expect(stderrHasOverride).toBe(false); }); + it('prints the convergence paragraph the trim notice points at', async () => { + // `noteTrimmedRanks` tells the author the shed sections "still hold — + // read them in the terminal report", and rank 0 is the first thing the + // ladder sheds. Without this line that promise names nothing. + const dir = mkdtempSync(join(tmpdir(), 'compose-convergence-')); + const inputPath = join(dir, 'compose.json'); + const commentsPath = join(dir, 'comments.json'); + const planPath = join(dir, 'plan.json'); + writeFileSync(planPath, JSON.stringify({ prNumber: 8255 }), 'utf8'); + writeFileSync( + inputPath, + JSON.stringify({ modelId: MODEL, planPath }), + 'utf8', + ); + writeFileSync( + commentsPath, + JSON.stringify([ + { path: 'src/a.ts', line: 3, body: '**[Suggestion]** again' }, + ]), + 'utf8', + ); + writeFileSync( + join(dir, 'qwen-review-pr-8255-prev-ledger.json'), + JSON.stringify({ + v: 1, + round: 4, + posted: 9, + fresh: 9, + findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }], + }), + 'utf8', + ); + try { + (writeStderrLine as ReturnType).mockClear(); + await runComposeReviewCommand({ + input: inputPath, + comments: commentsPath, + }); + const lines = ( + writeStderrLine as ReturnType + ).mock.calls.map((c) => String(c[0])); + expect(lines.some((l) => l.startsWith('CONVERGENCE: Convergence:'))).toBe( + true, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('states the round volume on stderr, with and without a predecessor', async () => { // The line is the operator's only view of this round's contribution to // the PR's comment volume; both branches of its ternary are @@ -2692,13 +2743,17 @@ describe('composeReviewCommand handler (the CLI glue)', () => { String(c[0]), ); try { - // No side file: no predecessor, so the line carries no parenthetical. + // No side file: no predecessor, so the line carries no PREVIOUS-round + // parenthetical. The fresh-count one rides on every line — it is a + // fact about this round, not about a comparison. (writeStderrLine as ReturnType).mockClear(); await runComposeReviewCommand({ input: inputPath, comments: commentsPath, }); - expect(stderr()).toContain('VOLUME: 2 inline comment(s) this round'); + expect(stderr()).toContain( + 'VOLUME: 2 inline comment(s) this round (2 reported for the first time)', + ); // With a recorded predecessor the previous round rides along. (writeStderrLine as ReturnType).mockClear(); @@ -2712,7 +2767,7 @@ describe('composeReviewCommand handler (the CLI glue)', () => { comments: commentsPath, }); expect(stderr()).toContain( - 'VOLUME: 2 inline comment(s) this round (previous round: 9)', + 'VOLUME: 2 inline comment(s) this round (2 reported for the first time) (previous round: 9)', ); // A CONVERGED predecessor: zero is a recorded value, not an absence. @@ -2730,7 +2785,7 @@ describe('composeReviewCommand handler (the CLI glue)', () => { comments: commentsPath, }); expect(stderr()).toContain( - 'VOLUME: 2 inline comment(s) this round (previous round: 0)', + 'VOLUME: 2 inline comment(s) this round (2 reported for the first time) (previous round: 0)', ); } finally { rmSync(dir, { recursive: true, force: true }); @@ -4230,6 +4285,7 @@ describe('verdictLine — the terminal verdict, and its dangling colon', () => { downgraded: false, floorEnforced: [], postedInline: 0, + postedFresh: 0, downgradedFrom: null, remediation: [], deferredCount: 0, @@ -5353,6 +5409,39 @@ describe('buildLedger', () => { ]); }); + it('flags the real file spelled like a stand-in, not the stand-in', () => { + // The flag marks the EXCEPTION, so the routine stand-ins cost the marker + // no bytes — it rides through every rung of the shed cascade, where the + // serializer prices telemetry at a lost anchor or a lost ruling — and a + // marker written before the flag existed still reads correctly, because + // its unflagged stand-ins are stand-ins. + const standIn = buildLedger( + 2, + [{ line: 3, body: '**[Suggestion]** arrived without a path' }], + [], + ); + expect(standIn.findings).toEqual([ + { + id: 'R2-1', + sev: 'S', + file: '(unknown)', + line: 3, + title: 'arrived without a path', + }, + ]); + // A REAL file of either stand-in name is flagged, so it is not mistaken + // for one — and the flagged entry keeps the path it names. + for (const name of ['(unknown)', '(body)']) { + const real = buildLedger( + 2, + [{ path: name, line: 3, body: '**[Suggestion]** a real file' }], + [], + ); + expect(real.findings[0].k).toBe(1); + expect(real.findings[0].file).toBe(name); + } + }); + it('classifies through `severityOf`, whitespace and all', () => { // The ledger restated the severity predicate as a bare `startsWith`, while // `countInlineFindings` — the count the VERDICT is computed from — trims @@ -8967,3 +9056,782 @@ describe('convergence telemetry — volume, carried in the marker', () => { expect(l.posted).toBe(2); }); }); + +describe('convergence diagnosis reaches the POSTED body', () => { + // The module is unit-tested next door; these go through composeReview, + // the path GitHub receives, because a diagnosis that never reaches the + // body is a diagnosis nobody reads. + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'diagnosis-e2e-')); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + const plan = () => { + const p = join(dir, 'plan.json'); + writeFileSync(p, JSON.stringify({ prNumber: 8255 })); + return p; + }; + const sideFile = (prev: Record) => + writeFileSync( + join(dir, 'qwen-review-pr-8255-prev-ledger.json'), + JSON.stringify({ v: 1, findings: [], ...prev }), + ); + + it('renders the observation when findings keep returning to one file', () => { + sideFile({ + round: 4, + posted: 9, + findings: [ + { id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }, + { id: 'R4-2', sev: 'S', file: 'src/a.ts', title: 'y' }, + ], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 2, + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' }, + { path: 'src/a.ts', line: 9, body: '**[Suggestion]** and again' }, + ], + }); + expect(r.body).toContain('Convergence:'); + expect(r.body).toContain( + '`src/a.ts` (findings in rounds 2, 4, 2 more now)', + ); + // An observation, not a gate: the verdict and its caps are untouched. + expect(r.cappedBy).not.toContain('convergence'); + expect(r.body).toContain('nothing was withheld'); + }); + + it('stays silent on a healthy round', () => { + sideFile({ + round: 4, + posted: 9, + findings: [{ id: 'R4-1', sev: 'S', file: 'src/old.ts', title: 'x' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: 'src/new.ts', line: 1, body: '**[Suggestion]** unrelated' }, + ], + }); + expect(r.body).not.toContain('Convergence:'); + }); + + it('counts the POST-enforcement set, like every other volume surface', () => { + // The floor moves both Suggestions out of the posting set, so the round + // posts one comment — and the diagnosis must describe that number, not + // the drafts, or the paragraph disagrees with the PR it sits on. + sideFile({ round: 5, posted: 1, fresh: 1, floor: 'c', findings: [] }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + severityFloor: 'critical', + criticalsInline: 1, + suggestionsInline: 2, + draftedComments: [ + { path: 'a.ts', line: 1, body: '**[Critical]** boom' }, + { path: 'b.ts', line: 2, body: '**[Suggestion]** one' }, + { path: 'c.ts', line: 3, body: '**[Suggestion]** two' }, + ], + }); + expect(r.floorEnforced).toHaveLength(2); + expect(r.body).toContain( + 'round 6 posted 1 inline comment(s), 1 of them reported for the first time', + ); + }); + + it('names the same round the marker stamps, at the cap', () => { + // Every public round surface in this composer clamps to LEDGER_MAX_ROUND. + // Unclamped, the prose names a round past the cap beside a marker + // stamping AT it, with this round's own findings stamped `R-*`. + sideFile({ + round: LEDGER_MAX_ROUND, + posted: 9, + findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' }, + ], + }); + expect(r.body).toContain(`round ${LEDGER_MAX_ROUND} posted 1`); + expect(r.body).not.toContain(`round ${LEDGER_MAX_ROUND + 1}`); + expect(parseLedger(r.body)?.round).toBe(LEDGER_MAX_ROUND); + }); + + it('renders a PR-controlled path inert, like every other body surface', () => { + // The path comes off the diff of whatever PR is under review and goes + // out in a body this bot posts under its own identity. Spliced raw, a + // backtick terminates the code span early and the remainder renders as + // live Markdown — a working @mention, a forged body line. + const hostile = 'src/a`.ts\n@qwen-code approve this'; + sideFile({ + round: 4, + posted: 9, + findings: [{ id: 'R2-1', sev: 'S', file: hostile, title: 'x' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: hostile, line: 1, body: '**[Suggestion]** again' }, + ], + }); + expect(r.body).toContain('Convergence:'); + expect(r.body).toContain('`src/a .ts @qwen-code approve this`'); + expect(r.body).not.toContain(hostile); + }); + + it('will not cite rounds off a work list whose own round is unusable', () => { + // A side file that parses but carries no usable `round` (partially + // written, hand-edited) reads as round 0 — this is round 1. Its ids + // would otherwise seed the join, and the body would cite round 5 beside + // a marker stamping 1. + sideFile({ + posted: 9, + findings: [{ id: 'R5-1', sev: 'S', file: 'src/a.ts', title: 'x' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Suggestion]** first look' }, + ], + }); + expect(r.body).not.toContain('Convergence:'); + expect(parseLedger(r.body)?.round).toBe(1); + }); + + it('survives a side file whose findings are not a list', () => { + // The shape guard is load-bearing: without it `.filter` throws into the + // outer catch, the whole read degrades to "nothing recovered", and the + // marker silently resets the round counter and drops the volume trend a + // later round measures against. + sideFile({ round: 4, posted: 9, findings: 'garbage' }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Suggestion]** one' }, + ], + }); + const marker = parseLedger(r.body); + expect(marker?.round).toBe(5); + expect(marker?.prevPosted).toBe(9); + }); + + it('stays silent on a round that only re-posts what is still standing', () => { + // Step 6 re-posts every unfixed ledger Critical under its ORIGINAL id. + // Counted as activity, one Critical nobody has fixed fires the cluster + // and the flat-volume trend every round, forever — narrating divergence + // at the steady state. + sideFile({ + round: 2, + posted: 1, + findings: [{ id: 'R2-1', sev: 'C', file: 'src/p.ts', title: 'boom' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 1, + suggestionsInline: 0, + draftedComments: [ + { path: 'src/p.ts', line: 1, body: '**[Critical]** R2-1: still open' }, + ], + }); + expect(r.body).not.toContain('Convergence:'); + }); + + it('joins on the same key the ledger stores, past the file cap', () => { + // The ledger caps `file` at 200 chars on write and readback; an uncapped + // drafted path can never equal a recovered entry past the cap, so the + // signal would be permanently blind to deep vendor and generated trees. + const deep = `src/${'nested/'.repeat(44)}leaf.ts`; + expect(deep.length).toBeGreaterThan(LEDGER_MAX_FILE); + sideFile({ + round: 4, + posted: 9, + findings: [ + { + id: 'R2-1', + sev: 'S', + file: deep.slice(0, LEDGER_MAX_FILE), + title: 'x', + }, + ], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: deep, line: 1, body: '**[Suggestion]** again' }, + ], + }); + expect(r.body).toContain('Convergence:'); + expect(r.body).toContain('findings in round 2, 1 more now'); + }); + + it('discloses a work list that was truncated or recovered from elsewhere', () => { + sideFile({ + round: 4, + posted: 9, + dropped: 3, + foreign: true, + findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' }, + ], + }); + expect(r.body).toContain('may be an undercount'); + expect(r.body).toContain('a marker this account did not post'); + }); + + it('does not recommend the floor the round is already enforcing', () => { + // The same body carries the floor-enforcement note. Telling the author + // to drop to `--severity-floor critical` beside it is advice nobody + // checked against the round it ships in. + sideFile({ round: 5, posted: 1, fresh: 1, floor: 'c', findings: [] }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + severityFloor: 'critical', + criticalsInline: 1, + suggestionsInline: 0, + draftedComments: [{ path: 'a.ts', line: 1, body: '**[Critical]** boom' }], + }); + expect(r.body).toContain('The rate of new findings is not falling.'); + expect(r.body).not.toContain('dropping this PR'); + expect(r.body).toContain('already at `--severity-floor critical`'); + }); + + it('reaches a REQUEST_CHANGES body — the verdict a diverging loop produces', () => { + // The block is spliced into three separately-maintained clause lists, + // and every sibling here that reaches a body reaches it as COMMENT — + // several of them by a downgrade rather than by the coverage cap, but + // COMMENT either way. REQUEST_CHANGES — unfixed Criticals, round after + // round — is the feature's primary audience, and its copy of the list + // was unasserted: deleting the splice left the whole suite green. + const planPath = coveredPlan(['verify', 'reverse-audit'], { + prNumber: 8255, + fetchedSha: 'deadbeef00112233', + }); + writeFileSync( + join(dirname(planPath), 'qwen-review-pr-8255-prev-ledger.json'), + JSON.stringify({ + v: 1, + round: 4, + posted: 9, + fresh: 9, + findings: [{ id: 'R2-1', sev: 'C', file: 'src/a.ts', title: 'x' }], + }), + ); + const r = composeReview({ + planPath, + env: ENV, + modelId: MODEL, + criticalsInline: 1, + suggestionsInline: 0, + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Critical]** a new one here' }, + ], + }); + expect(r.event).toBe('REQUEST_CHANGES'); + expect(r.body).toContain('Convergence:'); + }); + + it('yields the whole paragraph before any disclosure that qualifies the verdict', () => { + // The rounds this fires on are the high-volume rounds most likely to + // overflow, and the paragraph decides nothing — so it is the FIRST thing + // the ladder sheds. Untagged it ranked with the blockers and outlived + // the not-reviewed disclosures, which do qualify what was read. + // + // The blocker is sized so the ladder sheds rank 0 — the convergence + // paragraph, which yields before every other rank — and stops. Shed + // everything and the body is identical whichever order the ladder used, + // so the order would have no guard at all, which is why this constant is + // tuned rather than round. To retune after a body-copy change: raise it + // until `Convergence:` disappears, and stop before `Not reviewed:` does. + // The window is as wide as the paragraph itself. + sideFile({ + round: 4, + posted: 9, + findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + bodyCriticals: ['B'.repeat(55_850)], + unreviewedDimensions: ['security'], + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' }, + ], + }); + expect(r.body.length).toBeLessThanOrEqual(65536); + expect(r.body).not.toContain('Convergence:'); + // A lower-ranked disclosure outlives it: the ladder reached this far and + // the paragraph went first. + expect(r.body).toContain('Not reviewed:'); + // And the notice names what ACTUALLY went. Every notice surface keys on + // the rank, so sharing a rank with the deferral list made a round that + // shed only this paragraph announce a deferred-findings list that never + // existed and point the author at artifact entries that do not exist. + expect(r.body).toContain('the convergence observation'); + expect(r.body).not.toContain('the deferred-findings list'); + expect(r.body).not.toContain('findings artifact'); + expect(r.bodyTrim.deferralList).toBe(false); + }); + + it('stamps the posting floor this round ran under beside its volume', () => { + // The next round measures its volume against this one's. Without the + // posture that produced it, a floor change reads as a loop that will not + // settle — and the advice then recommends re-tightening a floor the + // operator deliberately loosened. + const open = composeReview({ + planPath: plan(), + modelId: 'm', + severityFloor: 'suggestion', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: 'a.ts', line: 1, body: '**[Suggestion]** one' }, + ], + }); + expect(parseLedger(open.body)?.floor).toBe('o'); + + // A state that named NO floor still records the resolved posture, folded + // the way every consumer folds it (absent reads as `auto`, which + // resolves determinately from the round and the context state). + // Recording only a named floor left the guard blind under the default + // configuration — where the posture genuinely transitions at round 6 — + // so a real change read as loop divergence. + const unknownEarly = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: 'a.ts', line: 1, body: '**[Suggestion]** one' }, + ], + }); + expect(parseLedger(unknownEarly.body)?.floor).toBe('o'); + + sideFile({ round: 6, posted: 1, fresh: 1, floor: 'o', findings: [] }); + const unknownLate = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 1, + suggestionsInline: 0, + draftedComments: [{ path: 'a.ts', line: 1, body: '**[Critical]** boom' }], + }); + expect(parseLedger(unknownLate.body)?.floor).toBe('c'); + + const critical = composeReview({ + planPath: plan(), + modelId: 'm', + severityFloor: 'critical', + criticalsInline: 1, + suggestionsInline: 0, + draftedComments: [{ path: 'a.ts', line: 1, body: '**[Critical]** boom' }], + }); + expect(parseLedger(critical.body)?.floor).toBe('c'); + }); + + it('will not narrate a floor change as a loop that is not settling', () => { + // The previous round ran under a critical floor and posted one comment; + // the floor is restored and this round posts five. That jump is policy, + // not loop behaviour. + sideFile({ round: 7, posted: 1, fresh: 1, floor: 'c', findings: [] }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + severityFloor: 'suggestion', + criticalsInline: 0, + suggestionsInline: 5, + draftedComments: Array.from({ length: 5 }, (_, i) => ({ + path: `f${i}.ts`, + line: 1, + body: `**[Suggestion]** ${i}`, + })), + }); + expect(r.body).not.toContain('Convergence:'); + }); + + it('will not cite a round off an id the marker path would refuse', () => { + // The side file is the same untrusted shape as a marker, by a different + // route: one written before the id hardening can still hold ` R9999-1`, + // and `birthRound` trims before matching, so the round would be printed + // verbatim in a body this account posts. + sideFile({ + round: 4, + posted: 9, + fresh: 9, + findings: [{ id: ' R9999-1', sev: 'S', file: 'src/a.ts', title: 'x' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' }, + ], + }); + expect(r.body).not.toContain('Convergence:'); + expect(r.body).not.toContain('9999'); + }); + + it('leaves a terminal copy of the paragraph the ladder sheds first', () => { + // Rank 0 goes first, and the trim notice tells the author the trimmed + // sections "still hold — read them in the terminal report". Unlike the + // deferral list (findings artifact) and the not-reviewed disclosures + // (the model's own inputs), a diagnosis derived from the side file has + // no other copy anywhere unless the composed result carries one. + sideFile({ + round: 4, + posted: 9, + fresh: 9, + findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' }, + ], + }); + expect(r.body).toContain('Convergence:'); + expect(r.convergence?.en).toContain('Convergence:'); + expect(r.convergence?.zh).toContain('收敛情况:'); + }); + + it("counts only marked drafts as this round's new findings", () => { + // An unmarked comment is not a finding — it enters no work list — so + // counting it as fresh activity inflates a cluster and satisfies the + // guard that alone keeps the trend off a settled round. + sideFile({ round: 4, posted: 9, fresh: 9, findings: [] }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: 'a.ts', line: 1, body: '**[Suggestion]** one' }, + { path: 'b.ts', line: 2, body: 'no marker at all' }, + ], + }); + expect(r.postedFresh).toBe(1); + }); + + it('reads a floor the state never named as `auto`, like the composer does', () => { + // The value is model-written and the SKILL's field list is prefaced + // "omit what does not apply", so absence is reachable. Read as "no floor + // at all", the round advises dropping to a floor SKILL Step 6's prose + // posture already had it running under. + sideFile({ round: 5, posted: 1, fresh: 1, findings: [] }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 1, + suggestionsInline: 0, + draftedComments: [{ path: 'a.ts', line: 1, body: '**[Critical]** boom' }], + }); + expect(r.body).toContain('The rate of new findings is not falling.'); + expect(r.body).toContain('already resolve to a critical posting floor'); + expect(r.body).not.toContain('dropping this PR'); + }); + + it('states ONE fresh count — the paragraph and the marker cannot disagree', () => { + // The marker's count and the paragraph's are the same number about the + // same round. Computed against different carried-sets, a stray id that + // names no standing entry read fresh in the prose and carried in the + // marker — and the marker's undercount persists as the next round's + // `prev.fresh`, where the trend's own guard reads it. + sideFile({ + round: 4, + posted: 9, + fresh: 9, + findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { + path: 'src/a.ts', + line: 1, + body: '**[Suggestion]** R2-99: a new one', + }, + ], + }); + expect(r.postedFresh).toBe(1); + expect(parseLedger(r.body)?.fresh).toBe(1); + expect(r.body).toContain('1 of them reported for the first time'); + }); + + it('keeps the terminal copy on the round that actually sheds the paragraph', () => { + // The promise the trim notice makes is about a body that DROPPED the + // paragraph. A test that asserts the body still contains it never + // reaches the case the copy exists for. + sideFile({ + round: 4, + posted: 9, + fresh: 9, + findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + bodyCriticals: ['B'.repeat(55_850)], + unreviewedDimensions: ['security'], + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' }, + ], + }); + expect(r.body).not.toContain('Convergence:'); + expect(r.convergence?.en).toContain('Convergence:'); + }); + + it('counts a shortened side-file list as an undercount, not as complete', () => { + // A file persisted by an older CLI carries ids the whole-shape test now + // refuses, and the persist paths keep that list across anonymous and + // recovery-threw runs. Rejected entries shrink the work list exactly as + // the marker's cap does. + sideFile({ + round: 4, + posted: 9, + fresh: 9, + findings: [ + { id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }, + { id: ' R3-1', sev: 'S', file: 'src/b.ts', title: 'pre-hardening' }, + ], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Suggestion]** again' }, + ], + }); + expect(r.body).toContain('may be an undercount'); + }); + + it('resolves a duplicated carried id the way the ledger does', () => { + // `idFor` keeps the FIRST comment under a carried id and re-mints this + // round's id for a second one, so the second draft is a finding this + // round minted. Read as a re-post here, the marker's work list gained a + // round-N entry that entered no fresh count. + sideFile({ + round: 3, + posted: 1, + fresh: 1, + findings: [{ id: 'R2-1', sev: 'C', file: 'src/p.ts', title: 'x' }], + }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 2, + suggestionsInline: 0, + draftedComments: [ + { path: 'src/p.ts', line: 1, body: '**[Critical]** R2-1: still open' }, + { path: 'src/p.ts', line: 9, body: '**[Critical]** R2-1: and again' }, + ], + }); + const marker = parseLedger(r.body)!; + expect(marker.findings.map((x) => x.id)).toEqual(['R2-1', 'R4-1']); + expect(marker.fresh).toBe(1); + expect(r.postedFresh).toBe(1); + }); + + it('re-mints a stray id, and keeps one a shortened list may have shed', () => { + // A claimed id naming no entry in a COMPLETE work list is a stray, and + // recording it mints a finding under a round that never held it — which + // the next round's recurrence join then cites in a posted paragraph. + // Over a SHORTENED list the two cannot be told apart, so continuity + // wins: the marker's byte budget sheds entries the model may legitimately + // re-voice. + const draft = [ + { path: 'src/a.ts', line: 1, body: '**[Suggestion]** R2-99: a new one' }, + ]; + sideFile({ + round: 4, + posted: 9, + fresh: 9, + findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }], + }); + const complete = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: draft, + }); + expect(parseLedger(complete.body)?.findings.map((x) => x.id)).toEqual([ + 'R5-1', + ]); + + sideFile({ + round: 4, + posted: 9, + fresh: 9, + dropped: 3, + findings: [{ id: 'R2-1', sev: 'S', file: 'src/a.ts', title: 'x' }], + }); + const shortened = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 0, + suggestionsInline: 1, + draftedComments: draft, + }); + expect(parseLedger(shortened.body)?.findings.map((x) => x.id)).toEqual([ + 'R2-99', + ]); + }); + + it('keeps a finding whose line number is not an integer', () => { + // `draftedComments` is raw model-written JSON. Emitted with a `12.5` + // line, the entry is refused by the serializer's own admission filter, + // which counts the WHOLE finding into `dropped` — retiring a posted + // finding with no ruling, mislabelling the round as budget-truncated, + // and withholding the anchor so the next round re-scopes the full diff. + const l = buildLedger( + 2, + [{ path: 'a.ts', line: 12.5, body: '**[Critical]** boom' }], + [], + ); + expect(l.findings).toEqual([ + { id: 'R2-1', sev: 'C', file: 'a.ts', title: 'boom' }, + ]); + const marker = serializeLedger({ ...l, sha: 'deadbeef00112233' }); + const parsed = parseLedger(marker)!; + expect(parsed.findings).toHaveLength(1); + expect(parsed.dropped).toBeUndefined(); + expect(parsed.sha).toBe('deadbeef00112233'); + }); + + it('reads an unusable findings field as unknown, not as an empty list', () => { + // A `findings` field that is not a list leaves the read knowing nothing + // about what the round held — which is not the same as a round that held + // nothing. Counted as a complete empty list, every claimed id reads as a + // stray and every re-post counts as first-time work. + sideFile({ round: 4, posted: 9, fresh: 9, findings: 'garbage' }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + criticalsInline: 1, + suggestionsInline: 0, + draftedComments: [ + { path: 'src/a.ts', line: 1, body: '**[Critical]** R2-1: still open' }, + ], + }); + expect(r.postedFresh).toBe(0); + expect(parseLedger(r.body)?.findings.map((x) => x.id)).toEqual(['R2-1']); + }); + + it('re-mints a claimed id the serializer would refuse, keeping the finding', () => { + // Continuity keeps an id a shortened list may have shed — it cannot keep + // one no list this pipeline wrote could have held. Kept, the serializer + // refuses the WHOLE entry: a posted finding exits the work list owing no + // ruling, the round is mislabelled budget-truncated, and the anchor goes. + for (const claimed of ['R7-1', 'R0-1', `R2-${'9'.repeat(24)}`]) { + const l = buildLedger( + 5, + [{ path: 'a.ts', line: 1, body: `**[Critical]** ${claimed}: boom` }], + [], + ); + expect(l.findings.map((x) => x.id)).toEqual(['R5-1']); + const parsed = parseLedger( + serializeLedger({ ...l, sha: 'deadbeef00112233' }), + )!; + expect(parsed.findings).toHaveLength(1); + expect(parsed.dropped).toBeUndefined(); + expect(parsed.sha).toBe('deadbeef00112233'); + } + // A well-formed id from an earlier round is still kept when the list is + // unknown — that is the continuity the bounds must not override. + const kept = buildLedger( + 5, + [{ path: 'a.ts', line: 1, body: '**[Critical]** R2-1: still open' }], + [], + ); + expect(kept.findings.map((x) => x.id)).toEqual(['R2-1']); + }); + + it('will not claim a floor it could not read', () => { + // A present-but-unrecognisable value is a state this module cannot read. + // Folded to `auto`, the body said the round "already resolves to a + // critical posting floor" while its own deferral-licence clause said the + // floor carried no recognisable value and the enforcement backstop moved + // nothing. + sideFile({ round: 5, posted: 1, fresh: 1, floor: 'c', findings: [] }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + // The point of the finding: this arrives from model-written JSON, so + // the runtime can see a value the type says is impossible. + severityFloor: 'crit' as unknown as ComposeReviewInput['severityFloor'], + criticalsInline: 1, + suggestionsInline: 1, + draftedComments: [ + { path: 'a.ts', line: 1, body: '**[Critical]** boom' }, + { path: 'b.ts', line: 2, body: '**[Suggestion]** nit' }, + ], + }); + expect(r.floorEnforced).toEqual([]); + expect(r.body).not.toContain('already resolve to a critical posting floor'); + expect(parseLedger(r.body)?.floor).toBe('o'); + }); + + it('names an auto-resolved floor the way the enforcement note does', () => { + // `auto` is the DEFAULT, so the explicit-flag wording claims a flag that + // was never passed — beside a floor-enforcement note in the same body + // that calls it the RESOLVED floor. + sideFile({ round: 5, posted: 1, fresh: 1, floor: 'c', findings: [] }); + const r = composeReview({ + planPath: plan(), + modelId: 'm', + severityFloor: 'auto', + criticalsInline: 1, + suggestionsInline: 0, + draftedComments: [{ path: 'a.ts', line: 1, body: '**[Critical]** boom' }], + }); + expect(r.body).toContain('The rate of new findings is not falling.'); + expect(r.body).toContain('already resolve to a critical posting floor'); + expect(r.body).not.toContain('--severity-floor critical'); + }); +}); diff --git a/packages/cli/src/commands/review/compose-review.ts b/packages/cli/src/commands/review/compose-review.ts index ecda417607..6d153581b2 100644 --- a/packages/cli/src/commands/review/compose-review.ts +++ b/packages/cli/src/commands/review/compose-review.ts @@ -70,14 +70,29 @@ import { layerAuditGate } from './lib/layer-audit-gate.js'; import { diffHashOf, type ScriptLintReport } from './script-lint.js'; import type { TestPlanReport } from './test-plan.js'; import { + LEDGER_BODY_FILE, LEDGER_ID_READBACK, + LEDGER_MAX_ID, + isLedgerFinding, + isStandInName, + normalizeLedgerFinding, LEDGER_MAX_BYTES, LEDGER_MAX_ROUND, + LEDGER_UNKNOWN_FILE, serializeLedger, volumeOf, type Ledger, type LedgerFinding, } from './lib/ledger.js'; +import { mdField } from './lib/md-field.js'; +import { + diagnoseConvergence, + isFreshDraft, + renderConvergenceDiagnosis, + type CriticalFloorKind, + type DraftedFinding, + type PrevRound, +} from './lib/convergence.js'; import { CRITICAL_PREFIX, LEADING_INVISIBLE_RE, @@ -405,6 +420,97 @@ export function normalizeSeverityFloor(value: unknown): string | undefined { return typeof value === 'string' ? value.trim().toLowerCase() : undefined; } +/** + * Does the posting floor resolve to `critical` for the round being composed? + * + * The ONE statement of that rule. `floorEnforcedReroute` ACTS on it, moving + * otherwise-postable Suggestions into the deferral channel; the convergence + * rendering READS it so its handling advice never recommends a posture the + * round is already running under — a paragraph telling the author to drop to + * `--severity-floor critical` inside the very body whose floor-enforcement + * note says Suggestions were already moved past that floor. + */ +export function criticalFloorKind( + severityFloor: unknown, + contextUnavailable: boolean, + prevRound: number, +): CriticalFloorKind | undefined { + // The REPORTING reading, and it folds an absent or unrecognisable floor + // into `auto` the way `composeReviewBody` already does ("A floor the + // module does not recognise — absent, null, or a model-transcribed + // spelling drift — is folded into ONE state"). The value is model-written + // and the SKILL's field list is prefaced "omit what does not apply", so + // absence is reachable — and reading it as "no floor at all" made the + // round advise dropping to a floor SKILL Step 6's prose posture already + // had it running under, which is the exact failure this predicate exists + // to prevent. + // + // Deliberately NOT shared with the enforcement reading below. Enforcement + // moves findings out of the posting set, and doing that on a posture this + // module had to guess at is the direction that loses work; the fail-open + // there is pre-existing and stays. + const raw = normalizeSeverityFloor(severityFloor); + // Only genuine ABSENCE folds. A present-but-unrecognisable value is a + // state this module cannot read, and folding it made the body contradict + // itself: the volume advice said the round "already resolves to a critical + // posting floor" while the deferral-licence clause in the same body said + // the floor carried no recognisable value and the enforcement backstop — + // strict on purpose — moved nothing. The wrong stamp then became the next + // round's comparison baseline. + const absent = severityFloor === undefined || severityFloor === null; + const floor = + raw === 'critical' || raw === 'suggestion' || raw === 'auto' + ? raw + : absent + ? 'auto' + : undefined; + return floorResolvesCritical(floor, contextUnavailable, prevRound); +} + +/** + * Whether the floor resolves to `critical` for ENFORCEMENT — strict, so a + * posture the state never named cannot move a finding out of the posting + * set. `floorEnforcedReroute` acts on this; the reporting reading above is + * what the round says about itself. + */ +export function criticalFloorInEffect( + severityFloor: unknown, + contextUnavailable: boolean, + prevRound: number, +): boolean { + return ( + floorResolvesCritical( + normalizeSeverityFloor(severityFloor), + contextUnavailable, + prevRound, + ) !== undefined + ); +} + +/** Does this resolved floor mean `critical` for the round after `prevRound`? */ +function floorResolvesCritical( + floor: string | undefined, + contextUnavailable: boolean, + prevRound: number, +): CriticalFloorKind | undefined { + // `prevRound` is the PREVIOUS posted round, so the review being composed + // is `prevRound + 1` — spelled out because the equivalent `prevRound >= 5` + // reads as a fencepost error against SKILL Step 6's "from round 6 it is + // critical". + const thisRound = prevRound + 1; + if (floor === 'critical') return 'explicit'; + if (floor === 'auto' && !contextUnavailable && thisRound >= 6) { + return 'auto-resolved'; + } + return undefined; +} + +/** Did the state name a floor this module recognises at all? */ +function severityFloorKnown(severityFloor: unknown): boolean { + const raw = normalizeSeverityFloor(severityFloor); + return raw === 'critical' || raw === 'suggestion' || raw === 'auto'; +} + /** * The posting floor, enforced in code — the backstop for the posture SKILL * Step 6 resolves in prose. @@ -441,16 +547,9 @@ export function floorEnforcedReroute( prevRound: number, drafted: ReadonlyArray<{ path?: unknown; line?: unknown; body?: unknown }>, ): { indices: number[]; entries: DeferredEntry[] } { - const floor = normalizeSeverityFloor(severityFloor); - // `prevRound` is the PREVIOUS posted round, so the review being composed - // is `prevRound + 1` — spelled out because the equivalent `prevRound >= 5` - // reads as a fencepost error against SKILL Step 6's "from round 6 it is - // critical". - const thisRound = prevRound + 1; - const enforced = - floor === 'critical' || - (floor === 'auto' && !contextUnavailable && thisRound >= 6); - if (!enforced) return { indices: [], entries: [] }; + if (!criticalFloorInEffect(severityFloor, contextUnavailable, prevRound)) { + return { indices: [], entries: [] }; + } const indices: number[] = []; const entries: DeferredEntry[] = []; drafted.forEach((c, i) => { @@ -721,6 +820,25 @@ export interface ComposeReviewResult { * PR's comment volume without counting threads by hand. Decides nothing. */ postedInline: number; + /** + * How many of `postedInline` this round reported for the FIRST time — + * neither a re-post of a still-standing ledger entry nor an unmarked + * draft. The number the convergence trend runs on, stamped into the marker + * beside the total so the next round can compare like with like. + */ + postedFresh: number; + /** + * The convergence paragraph, when a signal fired — the SAME text the body + * carries, returned so a terminal copy exists. + * + * The overflow ladder sheds this paragraph first, and its notice tells the + * author the trimmed sections "still hold — read them in the terminal + * report". That was a false record while this text lived only inside the + * body composer: unlike the deferral list (findings artifact) and the + * not-reviewed disclosures (the model's own inputs), a diagnosis derived + * from the side file has no other copy anywhere. + */ + convergence?: { en: string; zh: string }; /** * The previous round's `postedInline`, recovered from the side file when * it recorded one. Absent on round 1, on a recovery miss, and on any @@ -1169,6 +1287,14 @@ export function composeReview( prevRound, Array.isArray(input.draftedComments) ? input.draftedComments : [], ); + // The one resolution, read by the enforcement above and reported by the + // diagnosis below — and stamped into this round's marker, so the NEXT round + // can tell a posture change from a loop that will not settle. + const floorKind = criticalFloorKind( + input.severityFloor, + input.contextUnavailable === true, + prevRound, + ); let effective = input; if (reroute.indices.length > 0) { const drop = new Set(reroute.indices); @@ -1201,12 +1327,41 @@ export function composeReview( })(), }; } + // Is the loop settling? Measured from facts this round already holds — the + // previous work list and volume from the side file, this round's drafts — + // and rendered as an observation. It changes nothing about what the round + // posts: no finding is withheld, no verdict capped. A round that looks + // healthy produces no diagnosis at all rather than an empty section. + // + // The INPUTS travel; the diagnosis is composed inside `composeReviewBody`, + // beside the `postedInline` the marker and the terminal VOLUME line are + // taken from. Composed here it would be a second derivation of the same + // number — equal today, and free to drift the moment either derivation is + // edited, leaving one posted body stating two volumes for one round. const result = composeReviewBody( effective, cliVersion, attribution, prevRound, reroute, + { + prev: { + ...(prevFacts.posted === undefined ? {} : { posted: prevFacts.posted }), + findings: prevFacts.findings, + truncated: prevFacts.truncated, + complete: prevRound > 0 && !prevFacts.truncated, + foreign: prevFacts.foreign, + merged: prevFacts.merged, + ...(prevFacts.floor === undefined ? {} : { floor: prevFacts.floor }), + ...(prevFacts.fresh === undefined ? {} : { fresh: prevFacts.fresh }), + }, + // Read from the same input `floorEnforcedReroute` just acted on, through + // the one predicate both share — so the advice cannot recommend a floor + // the enforcement above already applied, nor name it a way the + // enforcement note in the same body contradicts. + floor: floorKind === undefined ? ('o' as const) : ('c' as const), + ...(floorKind === undefined ? {} : { criticalFloorKind: floorKind }), + }, ); // The ledger marker rides the body THIS function returns, because this — not // the CLI handler — is what `submit` calls and posts. Appending it in the @@ -1235,7 +1390,19 @@ export function composeReview( runtimeModelId, prevRound, postedInline, + result.postedFresh, prevFacts.posted, + floorKind, + severityFloorKnown(input.severityFloor), + { + ids: new Set(prevFacts.findings.map((f) => f.id)), + // A round that recovered NO predecessor knows nothing about which ids + // were real — the id space is shared across environments and a first + // round on a machine with no side file is the ordinary case — so it + // cannot call a claimed id a stray. Only a recovered, untruncated list + // is evidence of absence. + complete: prevRound > 0 && !prevFacts.truncated, + }, ); // `postedInline` came out of the body composer on the same input, so only // the predecessor's volume — which only this scope read — is added here. @@ -1250,6 +1417,19 @@ export function composeReview( : withVolume; } +/** + * Nothing recovered: round 1, no volume to compare against, no work list to + * find recurrence in, and therefore no evidence to qualify. Named once so the + * three ways this read gives up cannot drift apart as fields are added. + */ +const EMPTY_PREV_FACTS = { + round: 0, + findings: [] as LedgerFinding[], + truncated: false, + foreign: false, + merged: false, +}; + /** * The previous posted round's number AND its posting volume, recovered from * the side file `pr-context` wrote — never from the model. @@ -1271,20 +1451,39 @@ export function composeReview( function prevLedgerFacts(planPath: string | undefined): { round: number; posted?: number; + /** + * The previous round's work list, for the recurrence join. Empty when + * nothing was recovered — which reads as "no recurrence to find", never + * as "the previous round found nothing". + */ + findings: LedgerFinding[]; + /** Its marker shed findings to fit the byte budget: the list is partial. */ + truncated: boolean; + /** It was recovered from a marker this account did not post. */ + foreign: boolean; + /** That marker was merged over this account's own findings. */ + merged: boolean; + /** The posting floor it ran under, when its marker recorded one. */ + floor?: 'c' | 'o'; + /** How many of its comments were findings reported for the first time. */ + fresh?: number; } { try { - if (!planPath) return { round: 0 }; + if (!planPath) return EMPTY_PREV_FACTS; const plan = JSON.parse(readFileSync(planPath, 'utf8')) as { prNumber?: unknown; }; const pr = plan?.prNumber; - if (!isPositivePrNumber(pr)) return { round: 0 }; + if (!isPositivePrNumber(pr)) return EMPTY_PREV_FACTS; const prev = JSON.parse( readFileSync( join(dirname(planPath), `qwen-review-pr-${pr}-prev-ledger.json`), 'utf8', ), - ) as Ledger; + // `foreign` is a side-file field, not a marker field: it records how + // THIS machine obtained the list, which is nothing the marker riding a + // public body could be trusted to state about itself. + ) as Ledger & { foreign?: unknown; merged?: unknown }; const round = Number.isInteger(prev.round) && prev.round > 0 ? prev.round : 0; // Read through the ledger's own volume reader rather than a local @@ -1298,12 +1497,74 @@ function prevLedgerFacts(planPath: string | undefined): { // otherwise attribute it to round 0 — and a round-1 marker would ship // `prevPosted` for a round that never existed, against this field's own // "absent on round 1" contract. + // Through the ledger's OWN admission test, not a local restatement of + // two of its checks. The side file is the same untrusted shape as a + // marker, arriving by a different route: a file written before the id + // hardening can still hold an id the marker path now rejects, and + // `birthRound` trims before matching, so the round would be published + // verbatim in a body this account posts. Normalised for the same reason + // — the caps are the serializer's contract and this file is not bound by + // it, while the other side of the recurrence join IS capped. + // A `findings` field that is not a list at all leaves this read knowing + // nothing about what the round held — which is not the same as a round + // that held nothing. Counted as a complete empty list, every claimed id + // would read as a stray. + const listUsable = Array.isArray(prev.findings); + const rawFindings = listUsable ? prev.findings : []; + const findings = rawFindings + .filter((f): f is LedgerFinding => isLedgerFinding(f, round)) + .map(normalizeLedgerFinding); + // Entries this read's own admission test rejected are findings the next + // round will never rule on, exactly like the ones the marker's cap shed. + // Reachable without any tampering: a side file persisted by an older CLI + // carries ids the whole-shape test now refuses, and + // `persistRecoveredLedger` keeps that list across anonymous and + // recovery-threw runs. + const rejected = rawFindings.length - findings.length; return { round, ...(posted === undefined || round === 0 ? {} : { posted }), + // Gated on the round for the same reason the volume is: a work list + // travels WITH the round that produced it or not at all. A side file + // whose `round` is missing or unusable (partially written, hand-edited) + // still parses, and its `R5-2` ids would then seed the recurrence join + // for a round this read calls 0 — the posted body would cite rounds 5 + // and up beside a marker stamping round 1. + findings: round === 0 ? [] : findings, + // The marker had to shed findings to fit its byte budget, so what came + // back is known-incomplete (measured at up to 35 shed per round on the + // worst PRs this diagnosis speaks to). Carried rather than dropped: the + // cluster evidence is still the best there is, and the paragraph + // discloses the undercount instead of presenting a partial list whole. + truncated: + round !== 0 && + (!listUsable || + rejected > 0 || + (typeof prev.dropped === 'number' && prev.dropped > 0)), + // Whoever posted the marker that won recovery. `pr-context` adopts the + // highest-round marker on the PR — bounded, but not restricted to this + // account — so a cited round may be one this account never ran. The + // rendering says so rather than publishing the citation bare. + foreign: round !== 0 && prev.foreign === true, + merged: round !== 0 && prev.merged === true, + // Travels with the volume it qualifies, and with the round, for the + // same reason both of those do. + ...(round === 0 || + posted === undefined || + !(prev.floor === 'c' || prev.floor === 'o') + ? {} + : { floor: prev.floor }), + // Travels with the volume it is a part of, for the same reason. + ...(() => { + const f = + round === 0 || posted === undefined + ? undefined + : volumeOf(prev.fresh); + return f === undefined || f > (posted as number) ? {} : { fresh: f }; + })(), }; } catch { - return { round: 0 }; + return EMPTY_PREV_FACTS; } } @@ -1321,7 +1582,11 @@ function ledgerMarkerFor( runtimeModelId: string | undefined, prevRound: number, postedInline: number, + freshInline: number, prevPostedInline: number | undefined, + floorKind: CriticalFloorKind | undefined, + floorKnown: boolean, + carriedWorkList: { ids: ReadonlySet; complete: boolean }, ): string | null { try { if (!input.planPath) return null; @@ -1442,6 +1707,7 @@ function ledgerMarkerFor( // posted, counted blocker and must enter the work list. ...splitDeferralChannel(input.deferredSuggestions).relocated, ], + carriedWorkList, ), // The pair falls together: a sha with no model reads to the next // round as a pre-field marker rather than as "nobody certified this". @@ -1456,6 +1722,23 @@ function ledgerMarkerFor( ...(prevPostedInline === undefined ? {} : { prevPosted: prevPostedInline }), + // The posture that volume was produced under. Without it, the next + // round measures a FLOOR change as loop divergence: the volume under a + // critical floor and the volume under an open one are not two points + // on one trend. Decides nothing, sheds with the volume it qualifies. + // The RESOLVED posture, folded the way every consumer folds it: an + // absent or unrecognisable floor reads as `auto` throughout this + // module, and `auto` resolves determinately from the round number and + // the context state. Recording it only when the state NAMED a floor + // left the guard blind under the DEFAULT configuration — where the + // posture genuinely transitions at round 6 and again on a transient + // context failure — so a real posture change read as loop divergence, + // which is the misreading the field exists to prevent. What must not + // be invented is a posture nobody can derive; this one is derived from + // the same fold the advice and the enforcement backstop already use. + floor: floorKind === undefined ? 'o' : 'c', + // The part of that volume the trend is about — see `Ledger.fresh`. + fresh: freshInline, }); } catch { // A carry-forward convenience, never worth failing the verdict over. @@ -1540,6 +1823,17 @@ function composeReviewBody( indices: [], entries: [], }, + /** + * What the convergence diagnosis needs and this function cannot derive: the + * previous round as the side file recovered it, and the posting floor this + * round resolved to. Null in the direct-call tests that compose a body with + * no PR history behind it. + */ + convergence: { + prev: PrevRound; + floor?: 'c' | 'o'; + criticalFloorKind?: CriticalFloorKind; + } | null = null, ): ComposeReviewResult { // The posting set this body describes — `input` here is already the // post-enforcement one, so the count needs no second derivation and @@ -1551,6 +1845,51 @@ function composeReviewBody( // the shared reader's own docstring exists to prevent. `?? 0` is // unreachable for an array length; it keeps the type honest. const postedInline = volumeOf((input.draftedComments ?? []).length) ?? 0; + const diagnosis = convergence + ? diagnoseConvergence({ + // Clamped like every other public round surface in this function — + // the ledger marker stamp and the deferred-posture clause both clamp + // identically. An unclamped `+1` at the cap names round 10001 in the + // posted prose beside a marker stamping 10000, with this round's own + // findings stamped `R10000-*`. + round: Math.min(prevRound + 1, LEDGER_MAX_ROUND), + // The SAME count the marker and the VOLUME line carry, not a second + // derivation of it. + posted: postedInline, + prev: convergence.prev, + drafts: draftedFindingsOf(input.draftedComments), + ...(convergence.floor === undefined + ? {} + : { floor: convergence.floor }), + ...(convergence.criticalFloorKind === undefined + ? {} + : { criticalFloorKind: convergence.criticalFloorKind }), + }) + : null; + // A fact about the round, not about the diagnosis: it rides in the marker + // whether or not a signal fired, because the NEXT round's trend needs this + // round's point either way. + const carriedIds = convergence + ? new Set( + convergence.prev.findings + .map((f) => f?.id) + .filter((id): id is string => typeof id === 'string'), + ) + : undefined; + const postedFresh = + volumeOf( + draftedFindingsOf(input.draftedComments).filter((d) => + isFreshDraft( + d, + Math.min(prevRound + 1, LEDGER_MAX_ROUND), + carriedIds, + convergence?.prev.complete === true, + ), + ).length, + ) ?? 0; + const convergenceNote = diagnosis + ? renderConvergenceDiagnosis(diagnosis) + : undefined; const criticalsInline = toCount(input.criticalsInline, 'criticalsInline'); const suggestionsInline = toCount( input.suggestionsInline, @@ -2513,6 +2852,7 @@ function composeReviewBody( /** What a rank drops, in the author's words — the note names it. */ const RANK_NAMES: Record = { + 0: { en: 'the convergence observation', zh: '收敛情况观察' }, 1: { en: 'the deferred-findings list', zh: '延后发现清单' }, 2: { en: 'the not-reviewed and non-blocking disclosures', @@ -2592,10 +2932,12 @@ function composeReviewBody( * Every exit of `render` that dropped a rank owes this line — the * last-resort path drops ranks AND cuts, and a stderr record naming only * the cut leaves the kinds it dropped disclosed nowhere but the body. - * Only rank 1 has a second durable copy (each deferral is a - * `D-` entry in the findings artifact); a trimmed disclosure - * section survives nowhere but the terminal summary, so ask for it there - * rather than pointing at an artifact that does not carry it. + * Rank 1 has a second durable copy (each deferral is a `D-` + * entry in the findings artifact) and rank 0 has one too (the composed + * result carries the paragraph, and the command prints it as + * `CONVERGENCE:`); a trimmed disclosure section survives nowhere but the + * terminal summary, so ask for it there rather than pointing at an + * artifact that does not carry it. */ const noteTrimmedRanks = (droppedRanks: number[]): void => { if (droppedRanks.length === 0) return; @@ -3301,6 +3643,30 @@ function composeReviewBody( trim: 2, })); + // The convergence observation: rendered on every event, capping nothing, + // and only when a signal actually fired. It sits beside the other + // disclosure paragraphs because it is addressed to the same reader — the + // author deciding what to do next — and it is deliberately the only + // paragraph here that comments on the SHAPE of the review history rather + // than on the diff. + // + // `trim: 0` — its OWN rank, shed before every other. An untagged block + // ranks with the blockers and the verdict-qualifying sentences, and the + // rounds this fires on are precisely the high-volume rounds most likely to + // overflow: unranked, an advisory paragraph that decides nothing survived + // while the deferral list and the not-reviewed disclosures were spent. + // + // A rank of its own, not a share of the deferral list's: every notice + // surface keys on the RANK, not on what actually went — the rank's name, + // the artifact pointer, `bodyTrim.deferralList` — so sharing rank 1 made a + // round that shed only this paragraph post a notice naming a + // "deferred-findings list" that never existed and point the author at + // artifact entries that do not exist. Its own rank names itself, carries + // no artifact pointer, and leaves `deferralList` false. + const convergenceBlock: Bi[] = convergenceNote + ? [{ ...convergenceNote, trim: 0 }] + : []; + // The resumed-run continuity note: the run reused certified work from an // interrupted earlier attempt. Disclosed on every verdict — Approve // included — and never capping: the recovered agents were re-certified @@ -3331,6 +3697,7 @@ function composeReviewBody( ...repositoryContextBlock, ...unlicensedDeferralBlock, ...deferredSuggestionsBlock, + ...convergenceBlock, ...continuityBlock, ...bodyCriticalBlock, ]; @@ -3348,6 +3715,10 @@ function composeReviewBody( deferredCount: deferredSuggestions.length, floorEnforced: reroute.indices, postedInline, + postedFresh, + ...(convergenceNote === undefined + ? {} + : { convergence: convergenceNote }), bodyTrim, lowSignal, scopeUnproven, @@ -3384,6 +3755,7 @@ function composeReviewBody( ...repositoryContextBlock, ...unlicensedDeferralBlock, ...deferredSuggestionsBlock, + ...convergenceBlock, ...continuityBlock, ], notReviewedParts.length || @@ -3391,6 +3763,14 @@ function composeReviewBody( testPlanBlock.length || repositoryContextBlock.length || deferredSuggestionsBlock.length || + // Unreachable today and kept deliberately: an APPROVE is composed + // from zero findings, which means zero posted comments and zero + // drafted paths, so neither convergence signal can fire on this + // branch. It is listed anyway because the separator's job is to + // know about every block the branch renders — a condition that is + // right only because another rule makes its input impossible is a + // trap for whoever changes that other rule. + convergenceBlock.length || continuityBlock.length ? '\n\n' : ' ', @@ -3406,6 +3786,10 @@ function composeReviewBody( deferredCount: deferredSuggestions.length, floorEnforced: reroute.indices, postedInline, + postedFresh, + ...(convergenceNote === undefined + ? {} + : { convergence: convergenceNote }), bodyTrim, lowSignal, scopeUnproven, @@ -3573,7 +3957,12 @@ function composeReviewBody( // precedes the list (non-capping). clauses.push(...unlicensedDeferralBlock); clauses.push(...deferredSuggestionsBlock); - // 6e. Resumed-run continuity (non-capping) — reused work that COUNTS as + + // 6f. Convergence observation (non-capping) — is this loop settling, and if + // not, what shape is it. About the review HISTORY, not the diff. + clauses.push(...convergenceBlock); + + // 6g. Resumed-run continuity (non-capping) — reused work that COUNTS as // reviewed, disclosed so the author knows two attempts fed this verdict. clauses.push(...continuityBlock); @@ -3630,6 +4019,8 @@ function composeReviewBody( deferredCount: deferredSuggestions.length, floorEnforced: reroute.indices, postedInline, + postedFresh, + ...(convergenceNote === undefined ? {} : { convergence: convergenceNote }), bodyTrim, lowSignal, scopeUnproven, @@ -3923,25 +4314,6 @@ export function scriptLintGate(planPath: string): { return { criticals, unreviewed, disclosed }; } -/** - * Render a PR-controlled segment — a diff file path, a linter's message — safe to - * splice into the review body we POST to GitHub. Git allows almost any byte in a - * filename, so an unescaped path could carry `@mentions`, HTML, Markdown, or a - * newline that forges body structure. An inline code span makes Markdown/HTML/`@` - * inert; stripping backticks and newlines stops the value breaking out of the span - * or forging new lines. (`capture-local`'s `display()` does the terminal-side - * equivalent for stderr; this is the Markdown-body side.) - */ -function mdField(s: unknown): string { - return ( - '`' + - String(s) - .replace(/[`\r\n]+/g, ' ') - .trim() + - '`' - ); -} - /** * The report filename the orchestrator writes and this derives — pr-numbered * when the plan resolved a PR, a stable local name otherwise (matching the old @@ -4349,14 +4721,106 @@ export const composeReviewCommand: CommandModule = { // trend is measured against.) writeStderrLine( `VOLUME: ${result.postedInline} inline comment(s) this round` + + ` (${result.postedFresh} reported for the first time)` + (result.prevPostedInline === undefined ? '' : ` (previous round: ${result.prevPostedInline})`), ); + // The terminal copy the body's own trim notice promises. The convergence + // paragraph is the first thing the overflow ladder sheds, and unlike the + // deferral list (findings artifact) or the not-reviewed disclosures (the + // model's own inputs) it has no other copy anywhere — so the notice's + // "read them in the terminal report" was a false record until this line + // existed. + if (result.convergence) { + writeStderrLine(`CONVERGENCE: ${result.convergence.en}`); + } writeStderrLine(verdictLine(result)); }, }; +/** + * The first line of what follows the severity marker, minus any carried id. + * A carried-forward finding names its ORIGINAL id right after the marker — + * `**[Critical]** R1-2: the same claim, re-reported` — and reading it back + * here is what makes the machine ledger agree with the report it rides in, + * instead of renumbering the entry to a fresh `R-` the report + * never used. + * + * Module-level rather than a closure inside the ledger builder, because the + * builder is no longer its only consumer: the convergence diagnosis reads the + * same id to tell a re-posted still-standing finding from fresh activity, and + * a second restatement would let one end call a comment carried while the + * other calls it new. + */ +function readClaim(rest: string): { id?: string; title: string } { + const line = rest.split('\n')[0].trim(); + const carried = LEDGER_ID_READBACK.exec(line); + return { + id: carried?.[1], + title: (carried ? line.slice(carried[0].length) : line).trim(), + }; +} + +/** + * A drafted comment's claim line, projected the way every id consumer must + * read it: severity marker stripped, forged footer spans and comment-marker + * lines removed, leading render-nothing residue gone. Residue or a forged + * span between the marker and a carried id defeats the id anchor — the + * ledger would silently renumber the finding, and the diagnosis would count + * a re-post as new work. Stated once so the projections cannot diverge. + */ +function ledgerClaimLine(body: unknown): string { + const claim = carriedClaimLine(typeof body === 'string' ? body : ''); + return claim === null + ? '' + : stripFooterSpans(stripCommentMarkerLines(claim)).replace( + LEADING_INVISIBLE_RE, + '', + ); +} + +/** + * This round's drafts in the shape the convergence diagnosis reads. + * + * The path travels WHOLE. The recurrence join has to reach across the + * ledger's `LEDGER_MAX_FILE` cap, but truncating here to meet it does not + * prevent prefix collisions, it creates them — and it would put a + * 200-character prefix that names no real file into a posted paragraph. The + * join matches a truncated ledger entry by prefix instead. + * + * Unmarked comments are excluded, through the same predicate `buildLedger` + * uses: a comment with no severity marker is not a finding — it enters no + * work list — so counting it as fresh activity would inflate a cluster and + * satisfy the activity guard that alone keeps the trend off a settled round. + * + * `Array.isArray` like its two siblings: `draftedComments` arrives from a + * model-written state JSON, and a non-array reaching `.map` throws out of + * `composeReviewBody` and loses the whole round. + */ +function draftedFindingsOf(drafted: unknown): DraftedFinding[] { + if (!Array.isArray(drafted)) return []; + const out: DraftedFinding[] = []; + // Deduped exactly as `idFor` dedupes: the ledger keeps the FIRST comment + // under a carried id and re-mints this round's id for a second one, so a + // second draft carrying the same id is a finding this round minted. Passed + // through raw, it read as a re-post here while the marker's own work list + // gained a round-N entry — one end calling a comment carried while the + // other calls it new, which is the drift `readClaim` exists to prevent. + const seen = new Set(); + for (const c of drafted as Array<{ path?: unknown; body?: unknown }>) { + if (severityOf(c) === null) continue; + const { id } = readClaim(ledgerClaimLine(c.body)); + const carried = id !== undefined && !seen.has(id) ? id : undefined; + if (carried !== undefined) seen.add(carried); + out.push({ + file: typeof c.path === 'string' ? c.path : '', + ...(carried === undefined ? {} : { carriedId: carried }), + }); + } + return out; +} + /** * The next round's ledger: every finding this review is posting as its own — * the drafted inline comments plus the body Criticals. Low-confidence findings @@ -4367,12 +4831,48 @@ export function buildLedger( round: number, drafted: Array<{ path?: unknown; line?: unknown; body?: unknown }>, bodyCriticals: string[], + /** + * The previous round's work list, when this round recovered one, and + * whether that list was COMPLETE. + * + * A claimed id that names no entry in a complete list is a stray — a + * model-written token, not a carry — and recording it mints a finding + * under a round that never held it, which the next round's recurrence + * join then CITES in a posted paragraph and counts toward the depth key. + * The completeness flag is what separates a stray from a legitimately + * re-voiced entry the marker's byte budget shed: over a shortened list + * this cannot be told apart, so the id is retained and continuity wins. + */ + carriedWorkList?: { ids: ReadonlySet; complete: boolean }, ): Ledger { const findings: LedgerFinding[] = []; const taken = new Set(); let next = 0; + /** Is this claimed id one the previous round actually recorded? */ + const isCarry = (claimed: string): boolean => { + // The admission bounds come first, and continuity does not override + // them. An id past `LEDGER_MAX_ID`, at round 0, or claiming a round + // ahead of this one could never have been in any list this pipeline + // wrote — so keeping it is not continuity, it is emitting an entry the + // serializer's own filter then refuses WHOLE: a posted finding exits the + // work list owing no ruling, the round is mislabelled budget-truncated, + // and the anchor is withheld. Re-minting costs the entry its cross-round + // id and nothing else — the same trade the integer-line guard makes. + if (claimed.length > LEDGER_MAX_ID) return false; + const minted = Number(claimed.slice(1).split('-')[0]); + if (!Number.isSafeInteger(minted) || minted < 1 || minted > round) { + return false; + } + return ( + carriedWorkList === undefined || + !carriedWorkList.complete || + carriedWorkList.ids.has(claimed) + ); + }; /** A carried id if it is free, else the next unused id of THIS round. */ - const idFor = (carried: string | undefined): string => { + const idFor = (claimed: string | undefined): string => { + const carried = + claimed !== undefined && isCarry(claimed) ? claimed : undefined; if (carried && !taken.has(carried)) { taken.add(carried); return carried; @@ -4384,22 +4884,6 @@ export function buildLedger( taken.add(id); return id; }; - /** - * The first line of what follows the severity marker, minus any carried id. - * A carried-forward finding names its ORIGINAL id right after the marker — - * `**[Critical]** R1-2: the same claim, re-reported` — and reading it back - * here is what makes the machine ledger agree with the report it rides in, - * instead of renumbering the entry to a fresh `R-` the report - * never used. - */ - const titleOf = (rest: string): { id?: string; title: string } => { - const line = rest.split('\n')[0].trim(); - const carried = LEDGER_ID_READBACK.exec(line); - return { - id: carried?.[1], - title: (carried ? line.slice(carried[0].length) : line).trim(), - }; - }; /** * A title the next round can act on. The field's job is "enough to re-locate * the claim", and a comment that is nothing but its severity marker leaves it @@ -4422,27 +4906,33 @@ export function buildLedger( // was silently absent from the ledger, shifting every id after it. const sev = severityOf(c); if (!sev) continue; - // carriedClaimLine is the ONE readback statement shared with presubmit's - // carried-id extractor — marker + separator (newline-consuming) + first - // line. Strip forged footer spans and leading render-nothing residue off - // it before titleOf: the ledger rides the posted body as an HTML comment - // the autofix grep reads, and residue between the marker and a carried id - // would defeat the id anchor and silently renumber the finding. - const claim = carriedClaimLine(typeof c.body === 'string' ? c.body : ''); - const { id: carried, title } = titleOf( - claim === null - ? '' - : stripFooterSpans(stripCommentMarkerLines(claim)).replace( - LEADING_INVISIBLE_RE, - '', - ), - ); - const file = typeof c.path === 'string' ? c.path : '(unknown)'; + // `ledgerClaimLine` is the shared projection — `carriedClaimLine` (the ONE + // readback statement, also used by presubmit's carried-id extractor) with + // forged footer spans and leading render-nothing residue stripped off it. + // The ledger rides the posted body as an HTML comment the autofix grep + // reads, and residue between the marker and a carried id would defeat the + // id anchor and silently renumber the finding. + const { id: carried, title } = readClaim(ledgerClaimLine(c.body)); + const file = typeof c.path === 'string' ? c.path : LEDGER_UNKNOWN_FILE; findings.push({ id: idFor(carried), sev: sev === 'critical' ? 'C' : 'S', file, - ...(typeof c.line === 'number' ? { line: c.line } : {}), + // The flag marks the EXCEPTION — a real path that happens to be spelled + // like a stand-in — so the stand-ins themselves cost no marker bytes and + // a marker written before the flag existed still reads correctly. + ...(typeof c.path === 'string' && isStandInName(c.path) + ? { k: 1 as const } + : {}), + // Integer, like the admission test demands: a model-written `12.5` + // emitted here is refused by the serializer's own filter, which counts + // the WHOLE entry into `dropped` — retiring a posted finding with no + // ruling, mislabelling the round as budget-truncated, and withholding + // the anchor so the next round re-scopes the full diff. Dropping the + // line alone keeps the finding and costs it only its anchor line. + ...(typeof c.line === 'number' && Number.isInteger(c.line) + ? { line: c.line } + : {}), title: locatable( title, `${file}${typeof c.line === 'number' ? `:${c.line}` : ''}`, @@ -4456,13 +4946,13 @@ export function buildLedger( // Leading render-nothing residue goes too, for the same reason as the // drafted-comment leg: residue between the marker and a carried id // would defeat the id anchor and silently renumber the finding. - const { id: carried, title } = titleOf( + const { id: carried, title } = readClaim( stripForUnattributedPost(b).replace(LEADING_INVISIBLE_RE, ''), ); findings.push({ id: idFor(carried), sev: 'C', - file: '(body)', + file: LEDGER_BODY_FILE, title: locatable(title, 'the review body'), }); } diff --git a/packages/cli/src/commands/review/lib/convergence.test.ts b/packages/cli/src/commands/review/lib/convergence.test.ts new file mode 100644 index 0000000000..c6316353c6 --- /dev/null +++ b/packages/cli/src/commands/review/lib/convergence.test.ts @@ -0,0 +1,933 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + diagnoseConvergence, + renderConvergenceDiagnosis, + MAX_RENDERED_CLUSTERS, + type ConvergenceDiagnosis, + type DraftedFinding, +} from './convergence.js'; +import { LEDGER_MAX_ROUND, type LedgerFinding } from './ledger.js'; + +const f = (id: string, file: string): LedgerFinding => ({ + id, + sev: 'S', + file, + title: 't', +}); + +/** A fresh drafted finding, or — with an id — a re-post of an earlier one. */ +const d = (file: string, carriedId?: string): DraftedFinding => + carriedId === undefined ? { file } : { file, carriedId }; + +describe('diagnoseConvergence — the trigger table', () => { + it('says nothing when the loop looks healthy', () => { + // Shrinking volume, no repeated file: the shape that must NOT produce a + // paragraph. Null rather than an empty diagnosis, so a caller cannot + // render a section that says nothing. + expect( + diagnoseConvergence({ + round: 4, + posted: 2, + prev: { posted: 7, findings: [f('R3-1', 'a.ts')] }, + drafts: [d('b.ts')], + floor: 'o', + }), + ).toBeNull(); + }); + + it('fires on a file that carried findings before and carries more now', () => { + const r = diagnoseConvergence({ + round: 4, + posted: 2, + prev: { + posted: 9, + findings: [f('R2-1', 'a.ts'), f('R3-2', 'a.ts'), f('R3-3', 'z.ts')], + }, + drafts: [d('a.ts'), d('a.ts'), d('new.ts')], + floor: 'o', + })!; + expect(r.clusters).toEqual([ + { file: 'a.ts', priorRounds: [2, 3], thisRound: 2 }, + ]); + // Recurrence alone is enough — the volume is falling here. + expect(r.volumeNotShrinking).toBe(false); + }); + + it('reads the prior rounds off the carried ids, not off a count', () => { + // The ids are the rounds the REPORT used, which is what makes the + // rendered sentence checkable against the PR's own history. + const r = diagnoseConvergence({ + round: 9, + posted: 1, + prev: { + posted: 5, + findings: [f('R2-1', 'a.ts'), f('R7-4', 'a.ts'), f('R5-9', 'a.ts')], + }, + drafts: [d('a.ts')], + floor: 'o', + })!; + expect(r.clusters[0].priorRounds).toEqual([2, 5, 7]); + }); + + it('ignores entries whose id is not one, and every non-path stand-in', () => { + // A malformed side-file entry contributes no cluster rather than a + // wrong one; `(body)` is where unanchorable Criticals live and + // `(unknown)` is a comment that arrived without a path — neither is a + // file anyone can cluster on, and neither may be NAMED as one in a + // posted paragraph. Separated by the ledger's flag, which marks the + // EXCEPTION: a real file spelled like a stand-in carries it, a stand-in + // carries none — see the real-file test below. + expect( + diagnoseConvergence({ + round: 4, + posted: 1, + prev: { + posted: 9, + findings: [ + { id: 'nonsense', sev: 'C', file: 'a.ts', title: 't' }, + f('R2-1', '(body)'), + f('R2-2', '(unknown)'), + ], + }, + drafts: [d('a.ts'), d('(body)'), d('(unknown)')], + floor: 'o', + }), + ).toBeNull(); + }); + + it('fires on volume that is not shrinking, from round 3', () => { + const flat = diagnoseConvergence({ + round: 3, + posted: 5, + prev: { posted: 5, fresh: 1, findings: [] }, + drafts: [d('a.ts')], + floor: 'o', + })!; + expect(flat.volumeNotShrinking).toBe(true); + expect(flat.clusters).toEqual([]); + + const grew = diagnoseConvergence({ + round: 3, + posted: 6, + prev: { posted: 5, fresh: 1, findings: [] }, + drafts: [d('a.ts'), d('b.ts')], + floor: 'o', + })!; + expect(grew.volumeNotShrinking).toBe(true); + }); + + it('stays silent on a loop that posted nothing — zero is where convergence lands', () => { + // `0 >= 0` is arithmetically "not shrinking" and semantically the + // opposite: a round that posted nothing is the observation the trend + // exists to find, so narrating "the volume is not falling" there would + // flag the settled state as the unsettled one. + expect( + diagnoseConvergence({ + round: 7, + posted: 0, + prev: { posted: 0, fresh: 0, findings: [] }, + drafts: [], + floor: 'o', + }), + ).toBeNull(); + // And the round that lands on zero from above is the clearest possible + // shrink. + expect( + diagnoseConvergence({ + round: 7, + posted: 0, + prev: { posted: 6, fresh: 6, findings: [] }, + drafts: [], + floor: 'o', + }), + ).toBeNull(); + }); + + it('will not measure a trend against a settled predecessor', () => { + // `N >= 0` is true for every N, so a zero-posting predecessor would fire + // the signal on the healthiest shape there is: fix everything, settle at + // zero, push again, get new findings. Zero survives the whole + // persistence chain by design, so this state is reachable. + expect( + diagnoseConvergence({ + round: 5, + posted: 4, + prev: { posted: 4, fresh: 0, findings: [] }, + drafts: [d('a.ts')], + floor: 'o', + }), + ).toBeNull(); + // A genuine flat trend still fires. + expect( + diagnoseConvergence({ + round: 5, + posted: 2, + prev: { posted: 2, fresh: 1, findings: [] }, + drafts: [d('a.ts')], + floor: 'o', + }), + ).not.toBeNull(); + }); + + it('holds the volume signal until round 3 — one step is not a trend', () => { + // The counts must SATISFY every other conjunct, or the round guard is + // not what the assertion measures: one fresh draft against `prev.fresh: 5` + // already fails `1 >= 5`, and the test passed with or without the gate. + const shape = { + posted: 9, + prev: { posted: 5, fresh: 1, findings: [] }, + drafts: [d('a.ts')], + floor: 'o' as const, + }; + expect(diagnoseConvergence({ ...shape, round: 2 })).toBeNull(); + expect(diagnoseConvergence({ ...shape, round: 3 })).not.toBeNull(); + }); + + it('cannot evaluate a trend it never recovered', () => { + // Absence makes the signal unevaluable, never true: a predecessor that + // recorded no counts is not a predecessor that posted nothing. + expect( + diagnoseConvergence({ + round: 6, + posted: 9, + prev: { findings: [] }, + drafts: [d('a.ts')], + floor: 'o', + }), + ).toBeNull(); + // A total without a fresh count is the pre-field marker: the trend runs + // on new findings, so it is unevaluable rather than measured on totals. + expect( + diagnoseConvergence({ + round: 6, + posted: 9, + prev: { posted: 4, findings: [] }, + drafts: [d('a.ts')], + floor: 'o', + }), + ).toBeNull(); + }); + + it('does not count a re-posted still-standing finding as activity', () => { + // Step 6 re-posts every unfixed ledger Critical under its ORIGINAL id. + // A single Critical nobody has fixed therefore arrives every round: read + // as activity it fires the cluster ("1 more now" with no new finding + // ever appearing) AND the flat-volume trend, forever — at the steady + // state, which is the opposite of what both signals mean. + expect( + diagnoseConvergence({ + round: 3, + posted: 1, + prev: { posted: 1, findings: [f('R2-1', 'src/parser.ts')] }, + drafts: [d('src/parser.ts', 'R2-1')], + floor: 'o', + }), + ).toBeNull(); + }); + + it('treats a stray id that names no standing entry as a new finding', () => { + // Step 6 teaches the model to lead a re-post with `R1-2: `, + // and models emit stray ids at the head of a claim line. Trusted on the + // token alone, a genuinely new finding written that way vanishes from + // both signals — out of its file's cluster and out of the activity + // guard — and a round of real new work reads as the steady state. + const r = diagnoseConvergence({ + round: 4, + posted: 1, + prev: { + posted: 1, + fresh: 1, + complete: true, + findings: [f('R2-1', 'src/a.ts')], + }, + drafts: [d('src/a.ts', 'R2-99')], + floor: 'o', + })!; + expect(r.clusters).toEqual([ + { file: 'src/a.ts', priorRounds: [2], thisRound: 1 }, + ]); + expect(r.fresh).toBe(1); + }); + + it('will not call a re-post fresh over a list that may have shed it', () => { + // The work list keeps the id when the list is shortened (continuity + // wins), so reading the same comment as first-time work makes one marker + // say two things — and posts "the rate of new findings is not falling" + // every round on a loop doing no new work. + const shed = diagnoseConvergence({ + round: 4, + posted: 2, + prev: { posted: 2, fresh: 2, findings: [], truncated: true }, + drafts: [d('src/a.ts', 'R3-7'), d('src/b.ts', 'R3-8')], + floor: 'o', + }); + expect(shed).toBeNull(); + // Over a list known WHOLE, the same ids are strays and count as new. + const whole = diagnoseConvergence({ + round: 4, + posted: 2, + prev: { posted: 2, fresh: 2, complete: true, findings: [] }, + drafts: [d('src/a.ts', 'R3-7'), d('src/b.ts', 'R3-8')], + floor: 'o', + })!; + expect(whole.fresh).toBe(2); + }); + + it('still clusters a genuinely new finding in a re-posted file', () => { + // The exclusion is per-comment, not per-file: the file is still + // regenerating work, and that is exactly what the signal is for. + const r = diagnoseConvergence({ + round: 3, + posted: 2, + prev: { posted: 1, findings: [f('R2-1', 'src/parser.ts')] }, + drafts: [d('src/parser.ts', 'R2-1'), d('src/parser.ts')], + floor: 'o', + })!; + expect(r.clusters).toEqual([ + { file: 'src/parser.ts', priorRounds: [2], thisRound: 1 }, + ]); + // The volume fact stays the honest posted total, re-posts included. + expect(r.posted).toBe(2); + }); + + it('treats an id this round would mint as fresh, not as carried', () => { + // "Carried" means minted in an EARLIER round; the comparison is strict + // so a same-round id cannot silently erase this round's own work. The id + // is IN the work list, so the stray-id branch cannot be what decides it + // — the final `minted >= round` comparison is. + const r = diagnoseConvergence({ + round: 3, + posted: 1, + prev: { + posted: 1, + fresh: 1, + findings: [f('R2-1', 'a.ts'), f('R3-1', 'a.ts')], + }, + drafts: [d('a.ts', 'R3-1')], + floor: 'o', + })!; + expect(r.clusters[0].thisRound).toBe(1); + expect(r.fresh).toBe(1); + }); + + it('orders clusters by new work now, then by depth, then by path', () => { + // This round's count leads: the prior-round depth measures the wrong + // thing for the sentence it ranks — the previous ledger is a POSTING + // set, so depth grows exactly where nothing is being fixed — and it is + // the key a stranger can set with one marker full of legal ids. + const r = diagnoseConvergence({ + round: 5, + posted: 4, + prev: { + posted: 9, + findings: [ + f('R2-1', 'persistent.ts'), + f('R3-1', 'persistent.ts'), + f('R4-1', 'busy.ts'), + f('R4-2', 'quiet.ts'), + ], + }, + drafts: [d('persistent.ts'), d('busy.ts'), d('busy.ts'), d('quiet.ts')], + floor: 'o', + })!; + expect(r.clusters.map((c) => c.file)).toEqual([ + 'busy.ts', + 'persistent.ts', + 'quiet.ts', + ]); + }); + + it('breaks path ties on code units, not on the runtime locale', () => { + // `localeCompare` collates by locale: under en_US `é` sorts before `z`, + // by code unit it sorts after (U+00E9 > U+007A). The clustered paths + // belong to whatever repository is under review, and the CI bot's locale + // need not match a maintainer's — so the tie-break must not consult one. + const r = diagnoseConvergence({ + round: 5, + posted: 2, + prev: { posted: 9, findings: [f('R2-1', 'é.ts'), f('R2-2', 'z.ts')] }, + drafts: [d('é.ts'), d('z.ts')], + floor: 'o', + })!; + expect(r.clusters.map((c) => c.file)).toEqual(['z.ts', 'é.ts']); + }); + + it('clusters a real file whose name matches a stand-in', () => { + // The stand-ins are legal filenames — git permits `(body)` — so a reader + // that excluded them BY VALUE dropped exactly that file from clustering + // while claiming to drop a stand-in. The ledger's flag is what separates + // them, and it marks the EXCEPTION — the real file carries it, the + // stand-in carries none. + const r = diagnoseConvergence({ + round: 4, + posted: 1, + prev: { + posted: 9, + findings: [{ id: 'R2-1', sev: 'S', file: '(body)', title: 't', k: 1 }], + }, + drafts: [d('(body)')], + floor: 'o', + })!; + expect(r.clusters).toEqual([ + { file: '(body)', priorRounds: [2], thisRound: 1 }, + ]); + }); + + it('fails toward "carried" where the id space collides at the cap', () => { + // Consecutive rounds AT `LEDGER_MAX_ROUND` both stamp `R-*`, so a + // re-post is indistinguishable from a fresh finding by its id. The two + // errors do not cost the same: calling a re-post fresh narrates + // divergence at the steady state every round forever, calling a fresh + // finding carried costs one round of silence. + expect( + diagnoseConvergence({ + round: LEDGER_MAX_ROUND, + posted: 1, + prev: { + posted: 1, + findings: [f(`R${LEDGER_MAX_ROUND}-1`, 'src/p.ts')], + }, + drafts: [d('src/p.ts', `R${LEDGER_MAX_ROUND}-1`)], + floor: 'o', + }), + ).toBeNull(); + // Below the cap the ids still separate the two, so the strict rule holds. + expect( + diagnoseConvergence({ + round: 3, + posted: 1, + prev: { posted: 1, findings: [f('R2-1', 'src/p.ts')] }, + drafts: [d('src/p.ts', 'R3-1')], + floor: 'o', + }), + ).not.toBeNull(); + }); + + it('will not read a posture change as loop divergence', () => { + // An operator who takes this module's own advice, sets a critical floor, + // then restores it produces a volume jump that is a policy change, not a + // loop. Firing there would advise re-tightening the floor just + // deliberately loosened. + expect( + diagnoseConvergence({ + round: 8, + posted: 5, + prev: { posted: 1, fresh: 1, findings: [], floor: 'c' }, + drafts: [d('a.ts'), d('b.ts')], + floor: 'o', + }), + ).toBeNull(); + // Same floor, same numbers: a real flat trend still fires. + expect( + diagnoseConvergence({ + round: 8, + posted: 5, + prev: { posted: 1, fresh: 1, findings: [], floor: 'o' }, + drafts: [d('a.ts'), d('b.ts')], + floor: 'o', + }), + ).not.toBeNull(); + // A predecessor that recorded no floor is not one that differs — a + // pre-field marker evaluates exactly as it did before. + expect( + diagnoseConvergence({ + round: 8, + posted: 5, + prev: { posted: 1, fresh: 1, findings: [] }, + drafts: [d('a.ts'), d('b.ts')], + floor: 'o', + }), + ).not.toBeNull(); + }); + + it('measures the trend on new findings, not on the round total', () => { + // Step 6 re-posts every unfixed ledger Critical, so the re-post floor + // only ever rises. A loop whose NEW findings collapsed 5 -> 1 still + // posts more comments than the round before, and a trend on the totals + // calls that convergence "not falling" — forever. + const carried = Array.from({ length: 30 }, (_, i) => + d(`old${i}.ts`, `R2-${i + 1}`), + ); + const standing = Array.from({ length: 30 }, (_, i) => + f(`R2-${i + 1}`, `old${i}.ts`), + ); + expect( + diagnoseConvergence({ + round: 4, + posted: 31, + prev: { posted: 30, fresh: 5, findings: standing }, + drafts: [...carried, d('new.ts')], + floor: 'o', + }), + ).toBeNull(); + }); + + it('holds the recurrence signal until round 3 — one step is not a trend', () => { + // A round-1 finding fixed and one new finding landing in the same file + // is the ordinary healthy re-review; on a single-file PR the "split it + // into its own pull request" advice has no referent at all. + expect( + diagnoseConvergence({ + round: 2, + posted: 1, + prev: { posted: 3, fresh: 3, findings: [f('R1-1', 'src/foo.ts')] }, + drafts: [d('src/foo.ts')], + floor: 'o', + }), + ).toBeNull(); + }); + + it('ranks the file producing new work over the file with a backlog', () => { + // `priorRounds` deepens only where nothing is being fixed: a fixed + // finding is not re-posted and its round leaves the list, an unfixed one + // keeps contributing its mint round forever. Ranked by depth, the + // backlog file took the top slot and the advice explained it as "a + // cluster that keeps producing siblings" — about a file where no fix + // happened. + const r = diagnoseConvergence({ + round: 6, + posted: 6, + prev: { + posted: 6, + fresh: 2, + findings: [ + f('R1-1', 'src/never-fixed.ts'), + f('R2-1', 'src/never-fixed.ts'), + f('R3-1', 'src/never-fixed.ts'), + f('R4-1', 'src/never-fixed.ts'), + f('R5-1', 'src/regenerating.ts'), + ], + }, + drafts: [ + d('src/never-fixed.ts', 'R1-1'), + d('src/never-fixed.ts'), + d('src/regenerating.ts'), + d('src/regenerating.ts'), + ], + floor: 'o', + })!; + expect(r.clusters.map((c) => c.file)).toEqual([ + 'src/regenerating.ts', + 'src/never-fixed.ts', + ]); + }); + + it('matches a truncated ledger entry by prefix, keeping the real path', () => { + // The ledger caps `file` at 200 chars. Truncating the drafted side to + // meet it does not prevent prefix collisions, it creates them — and it + // would post a 200-char prefix as a path that exists in no repository. + const deep = `src/${'nested/'.repeat(44)}leaf.ts`; + const r = diagnoseConvergence({ + round: 4, + posted: 1, + prev: { + posted: 9, + fresh: 9, + findings: [f('R2-1', deep.slice(0, 200))], + }, + drafts: [d(deep)], + floor: 'o', + })!; + expect(r.clusters[0].file).toBe(deep); + }); + + it('carries the evidence qualifiers through to the rendering', () => { + const r = diagnoseConvergence({ + round: 4, + posted: 1, + prev: { + posted: 9, + findings: [f('R2-1', 'a.ts')], + truncated: true, + foreign: true, + }, + drafts: [d('a.ts')], + floor: 'o', + criticalFloorKind: 'explicit', + })!; + expect(r.truncatedEvidence).toBe(true); + expect(r.foreignEvidence).toBe(true); + expect(r.criticalFloorKind).toBe('explicit'); + }); + + it('carries the merged qualifier through, and drops the depth key with it', () => { + // Two things ride on a foreign work list: the caveat the renderer picks, + // and whether the ordering may consult a number a stranger set. Fifty + // planted ids on one file still decide every `thisRound` tie, and ties + // are the ordinary shape — one fresh finding per file. + const planted = Array.from({ length: 50 }, (_, i) => + f(`R${i + 1}-1`, 'src/planted.ts'), + ); + const r = diagnoseConvergence({ + round: 6, + posted: 2, + prev: { + posted: 9, + fresh: 9, + foreign: true, + merged: true, + findings: [...planted, f('R5-1', 'src/genuine.ts')], + }, + drafts: [d('src/planted.ts'), d('src/genuine.ts')], + floor: 'o', + })!; + expect(r.mergedEvidence).toBe(true); + // Tied on this round's count, the path decides — not the planted depth. + expect(r.clusters.map((c) => c.file)).toEqual([ + 'src/genuine.ts', + 'src/planted.ts', + ]); + // An OWN list still ranks by depth after the count. + const own = diagnoseConvergence({ + round: 6, + posted: 2, + prev: { + posted: 9, + fresh: 9, + findings: [...planted, f('R5-1', 'src/genuine.ts')], + }, + drafts: [d('src/planted.ts'), d('src/genuine.ts')], + floor: 'o', + })!; + expect(own.clusters[0].file).toBe('src/planted.ts'); + + // And the drop is keyed on `foreign` ALONE — a purely foreign marker + // adopted without a union is the ordinary shape when this account has + // no surviving marker of its own. + const foreignOnly = diagnoseConvergence({ + round: 6, + posted: 2, + prev: { + posted: 9, + fresh: 9, + foreign: true, + findings: [...planted, f('R5-1', 'src/genuine.ts')], + }, + drafts: [d('src/planted.ts'), d('src/genuine.ts')], + floor: 'o', + })!; + expect(foreignOnly.mergedEvidence).toBe(false); + expect(foreignOnly.clusters.map((c) => c.file)).toEqual([ + 'src/genuine.ts', + 'src/planted.ts', + ]); + }); + + it('will not compare a recorded floor against one this round never named', () => { + // The asymmetric cell the guard exists for: a predecessor that recorded + // its posture against a round whose own posture is unknown. Unknown is + // not "matches" and not "differs" — it makes the comparison unavailable, + // which leaves the trend evaluated as it was before floors existed. + const r = diagnoseConvergence({ + round: 8, + posted: 5, + prev: { posted: 1, fresh: 1, findings: [], floor: 'c' }, + drafts: [d('a.ts'), d('b.ts')], + })!; + expect(r.volumeNotShrinking).toBe(true); + }); + + it('defaults every qualifier to false rather than undefined', () => { + const r = diagnoseConvergence({ + round: 4, + posted: 1, + prev: { posted: 9, findings: [f('R2-1', 'a.ts')] }, + drafts: [d('a.ts')], + floor: 'o', + })!; + expect(r.truncatedEvidence).toBe(false); + expect(r.foreignEvidence).toBe(false); + expect(r.criticalFloorKind).toBeUndefined(); + }); +}); + +describe('renderConvergenceDiagnosis — what the author reads', () => { + const base: ConvergenceDiagnosis = { + round: 6, + posted: 4, + fresh: 2, + prevPosted: 4, + prevFresh: 2, + clusters: [{ file: 'src/a.ts', priorRounds: [3, 5], thisRound: 2 }], + volumeNotShrinking: true, + truncatedEvidence: false, + foreignEvidence: false, + mergedEvidence: false, + }; + + it('states the measured facts before the reading of them', () => { + const r = renderConvergenceDiagnosis(base); + expect(r.en).toContain( + 'round 6 posted 4 inline comment(s), 2 of them reported for the first time', + ); + expect(r.en).toContain('the previous round posted 4'); + expect(r.en).toContain('`src/a.ts` (findings in rounds 3, 5, 2 more now)'); + expect(r.zh).toContain('第 6 轮发布了 4 条行内评论,其中 2 条是首次提出'); + expect(r.zh).toContain('第 3、5 轮已出过发现,本轮又有 2 条'); + }); + + it('pluralises the prior-round list by how many rounds it names', () => { + // The commonest recurrence shape by far is one prior round — flagged in + // round N, re-flagged in N+1 — so the singular branch is the one most + // readers see. + const one = renderConvergenceDiagnosis({ + ...base, + clusters: [{ file: 'src/a.ts', priorRounds: [4], thisRound: 1 }], + }); + expect(one.en).toContain('`src/a.ts` (findings in round 4, 1 more now)'); + expect(one.en).not.toContain('in rounds 4,'); + }); + + it('says the observation withheld nothing — scoped to the observation', () => { + // The same body can carry a floor-enforcement note, a deferral list, or + // a discarded-Suggestion count, all of them things withheld from this + // round's posting surface. An absolute claim beside those is one the + // body itself refutes; the claim this module can make is about its own + // effect, which is none. + const r = renderConvergenceDiagnosis(base); + expect(r.en).toContain( + 'nothing was withheld from this review because of this observation', + ); + expect(r.zh).toContain('未因此扣留任何内容'); + }); + + it('advises at the process level, never on code structure', () => { + const r = renderConvergenceDiagnosis(base); + expect(r.en).toContain('shared root cause'); + expect(r.en).toContain('splitting an independent cluster'); + // The claim it must never make: how the code should be rewritten. + expect(r.en).not.toMatch(/refactor|rewrite|extract .* class|redesign/i); + // Same claim, same direction, other language — an en-only negative + // catches en regressions only. + expect(r.zh).toContain('根因'); + expect(r.zh).toContain('拆成单独的 PR'); + expect(r.zh).not.toMatch(/重构|重写|重新设计/); + }); + + it('falls back to the volume reading when nothing recurs', () => { + const r = renderConvergenceDiagnosis({ ...base, clusters: [] }); + expect(r.en).toContain('The rate of new findings is not falling.'); + expect(r.en).toContain('--severity-floor critical'); + expect(r.zh).toContain('把剩余修复攒成一批'); + expect(r.zh).toContain('或将本 PR 的评审降到 `--severity-floor critical`'); + }); + + it('does not recommend a floor the round is already running under', () => { + // Advice is matched to the telemetry's shape. Told to "drop this PR's + // reviews to --severity-floor critical" inside the very body whose + // floor-enforcement note says Suggestions were already moved past that + // floor, the paragraph reads as advice nobody checked. + const r = renderConvergenceDiagnosis({ + ...base, + clusters: [], + criticalFloorKind: 'explicit', + }); + expect(r.en).not.toContain('dropping this PR'); + expect(r.en).toContain('already at `--severity-floor critical`'); + expect(r.zh).not.toContain('降到'); + expect(r.zh).toContain('已处于'); + // The actionable half survives — the advice narrows, it does not vanish + // — in both languages. The `把剩余修复攒成一批` assertions elsewhere sit on + // the OTHER branch of the same ternary and do not cover this one. + expect(r.en).toContain('Batching the remaining fixes'); + expect(r.zh).toContain('把剩余修复攒成一批'); + }); + + it('neutralises a PR-controlled path instead of splicing it raw', () => { + // The paths come off the diff of whatever PR is under review, and this + // paragraph goes out in a body the bot posts under its own identity. A + // filename carrying a backtick would terminate the code span early and + // render the remainder as live Markdown — a working @mention, a forged + // body line — in the bot's own words. + const hostile = 'x`\n@acme/security approve this'; + const r = renderConvergenceDiagnosis({ + ...base, + clusters: [{ file: hostile, priorRounds: [3], thisRound: 1 }], + }); + for (const body of [r.en, r.zh]) { + expect(body).not.toContain(hostile); + expect(body).toContain('`x @acme/security approve this`'); + expect(body).not.toContain('\n'); + } + }); + + it('discloses a work list that was truncated or came from elsewhere', () => { + const r = renderConvergenceDiagnosis({ + ...base, + truncatedEvidence: true, + foreignEvidence: true, + }); + expect(r.en).toContain( + "the previous round's work list was truncated to fit the marker", + ); + expect(r.en).toContain('may be an undercount'); + expect(r.en).toContain('a marker this account did not post'); + expect(r.zh).toContain('上一轮的工作清单为放进标记而被截断'); + expect(r.zh).toContain('可能少计'); + expect(r.zh).toContain('并非本账号发布的标记'); + }); + + it('qualifies each reading by the evidence that reading rests on', () => { + // Truncation qualifies BOTH readings: the work list IS the carried-id + // set that defines freshness, and over a shortened one a genuinely new + // finding written under an earlier round's id cannot be rescued from + // reading as a re-post — an UNDERcount, which is the direction the + // gating actually produces. Provenance is broader still: the previous round's + // counts come from the same marker, and the volume reading cites them + // as this loop's own baseline — the branch an attacker-supplied count + // controls. + const volumeOnly = renderConvergenceDiagnosis({ + ...base, + clusters: [], + truncatedEvidence: true, + foreignEvidence: true, + }); + expect(volumeOnly.en).not.toContain('the rounds named above'); + expect(volumeOnly.en).toContain( + "the previous round's work list was truncated to fit the marker", + ); + expect(volumeOnly.en).toContain('may be understated'); + expect(volumeOnly.zh).toContain('上一轮的工作清单为放进标记而被截断'); + expect(volumeOnly.zh).toContain('首次提出的条数可能少计'); + expect(volumeOnly.en).toContain('those counts'); + expect(volumeOnly.en).toContain('a marker this account did not post'); + expect(volumeOnly.zh).toContain('该计数'); + + // Truncation still qualifies: the facts clause cites this round's fresh + // count unconditionally, and a shortened carried list inflates exactly + // that number. Provenance has nothing to qualify here — no rounds are + // named and no previous count is cited. + const noCitations = renderConvergenceDiagnosis({ + round: 4, + posted: 3, + fresh: 3, + clusters: [], + volumeNotShrinking: false, + truncatedEvidence: true, + foreignEvidence: true, + mergedEvidence: false, + }); + expect(noCitations.en).toContain('may be understated'); + expect(noCitations.en).not.toContain('this account did not post'); + + const nothingAtAll = renderConvergenceDiagnosis({ + round: 4, + posted: 3, + fresh: 3, + clusters: [], + volumeNotShrinking: false, + truncatedEvidence: false, + foreignEvidence: true, + mergedEvidence: false, + }); + expect(nothingAtAll.zh).not.toContain('证据说明'); + }); + + it('names an auto-resolved floor as resolved, not as a flag nobody passed', () => { + // `auto` is the DEFAULT configuration, and it fails open the moment + // context becomes unavailable — so wording it as an explicit setting + // both claims a flag that was never passed and overstates how firmly it + // holds. The floor-enforcement note in the same body says "resolved". + const r = renderConvergenceDiagnosis({ + ...base, + clusters: [], + criticalFloorKind: 'auto-resolved', + }); + expect(r.en).toContain('already resolve to a critical posting floor'); + expect(r.en).not.toContain('--severity-floor critical'); + expect(r.zh).toContain('已解析为 critical 发布下限'); + }); + + it('reports both readings when both signals fired', () => { + // Discriminating on the clusters alone made the volume sentence — and + // with it the whole floor recommendation — unreachable on the shape this + // feature exists for: recurrence and a flat trend together. + const r = renderConvergenceDiagnosis(base); + expect(r.en).toContain('Findings keep coming back to the same files'); + expect(r.en).toContain('The rate of new findings is not falling.'); + expect(r.en).toContain('shared root cause'); + expect(r.en).toContain('--severity-floor critical'); + expect(r.zh).toContain('新发现的产出速度没有下降。'); + // Both halves of the zh advice, which no assertion reached: the cluster + // reading and the batching clause the floor reading opens with. + expect(r.zh).toContain('一个不断再生兄弟发现的簇'); + expect(r.zh).toContain('把剩余修复攒成一批'); + }); + + it('says "some of" when the foreign list was merged over this account\'s own', () => { + // The union restores this account's own certified entries under their + // own ids, so an unqualified "may not be this account's own" overstates + // by exactly the part the union protected. + const r = renderConvergenceDiagnosis({ + ...base, + foreignEvidence: true, + mergedEvidence: true, + }); + expect(r.en).toContain("merged over this account's own entries"); + expect(r.en).toContain('so some of those rounds'); + expect(r.zh).toContain('并与本账号自己的条目合并'); + expect(r.zh).toContain('中的部分可能不属于本账号'); + }); + + it('states both previous-round numbers, not just the total', () => { + // The previous-round clause is the only rendering of the baseline the + // volume reading cites, and it has two numeric slots. + const r = renderConvergenceDiagnosis({ + ...base, + prevPosted: 9, + prevFresh: 4, + }); + expect(r.en).toContain('the previous round posted 9 (4 new)'); + expect(r.zh).toContain('上一轮发布了 9 条(其中 4 条首次提出)'); + // A predecessor with no fresh count renders the total alone. + const older = renderConvergenceDiagnosis({ + ...base, + prevPosted: 9, + prevFresh: undefined, + }); + expect(older.en).toContain('the previous round posted 9'); + expect(older.en).not.toContain('new)'); + expect(older.zh).toContain('上一轮发布了 9 条'); + expect(older.zh).not.toContain('首次提出)'); + }); + + it('names both citations when the reading rests on both', () => { + const r = renderConvergenceDiagnosis({ ...base, foreignEvidence: true }); + expect(r.en).toContain('those rounds and its counts'); + expect(r.zh).toContain('上述轮次与其计数'); + }); + + it('summarises the tail instead of listing every cluster', () => { + const many = Array.from({ length: MAX_RENDERED_CLUSTERS + 2 }, (_, i) => ({ + file: `f${i}.ts`, + priorRounds: [2], + thisRound: 1, + })); + const r = renderConvergenceDiagnosis({ ...base, clusters: many }); + expect(r.en).toContain('and 2 more file(s)'); + expect(r.en).not.toContain(`f${MAX_RENDERED_CLUSTERS}.ts`); + expect(r.zh).toContain('另有 2 个文件'); + }); + + it('omits the previous round when none was recovered', () => { + const r = renderConvergenceDiagnosis({ + round: 4, + posted: 3, + fresh: 3, + clusters: base.clusters, + volumeNotShrinking: false, + truncatedEvidence: false, + foreignEvidence: false, + mergedEvidence: false, + }); + expect(r.en).toContain('round 4 posted 3 inline comment(s)'); + expect(r.en).not.toContain('the previous round posted'); + expect(r.zh).not.toContain('上一轮发布了'); + }); +}); diff --git a/packages/cli/src/commands/review/lib/convergence.ts b/packages/cli/src/commands/review/lib/convergence.ts new file mode 100644 index 0000000000..f541355796 --- /dev/null +++ b/packages/cli/src/commands/review/lib/convergence.ts @@ -0,0 +1,640 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Is this review loop converging, and if not, why? +// +// A push-triggered review plus an agent addressing its findings is a feedback +// loop, and the loop's gain can exceed 1: every accepted fix widens the diff, +// the next round reviews more code, and more findings come back. Measured on +// this repository, PRs have carried hundreds of open threads and still not +// settled — one closed unmerged at ~500. +// +// The damper the pipeline already has is the round-adaptive posting floor, but +// nothing tells the humans WHY a particular loop is not settling. This module +// answers that from facts the round already holds, and it answers only that: +// it states what it measured and what the shapes usually mean, and it makes no +// decision. Whether to keep fixing, restructure, split or land is the author's +// and the operator's call — the skill holds advisory power, never decision +// power, so nothing here withholds a finding, caps a verdict, or changes what +// the round posts. +// +// Every trigger is a comparison between THIS pull request's own rounds. There +// is no threshold, no "too many comments" number: a volume bar is somebody's +// policy, and a policy the tool owns is a policy the tool would have to defend +// on repositories it knows nothing about. A PR diverging at 40 comments +// deserves the reading that a threshold of 100 would have delayed, and a large +// review whose findings are shrinking deserves no interruption at all. + +import { + LEDGER_ID_TOKEN, + LEDGER_MAX_FILE, + LEDGER_MAX_ROUND, + isStandInName, + type LedgerFinding, +} from './ledger.js'; +import { mdField } from './md-field.js'; + +/** The id grammar, anchored so a cross-reference in prose cannot match. */ +const ID_HEAD = new RegExp(`^(${LEDGER_ID_TOKEN})`); + +/** The round an id was minted in, or undefined when the id is not one. */ +function birthRound(id: unknown): number | undefined { + if (typeof id !== 'string') return undefined; + const m = ID_HEAD.exec(id.trim()); + if (!m) return undefined; + const round = Number(m[1].slice(1).split('-')[0]); + return Number.isInteger(round) && round > 0 ? round : undefined; +} + +/** A file this round and earlier rounds both produced findings in. */ +export interface RecurrenceCluster { + file: string; + /** + * Rounds that already reported a finding here, ascending — read off the + * carried ledger ids (`R-`), which is why they are the rounds the + * REPORT used rather than a count this module invents. + */ + priorRounds: number[]; + /** How many of this round's drafted comments land in this file. */ + thisRound: number; +} + +/** + * One of this round's drafted comments, as far as the diagnosis needs it. + * + * The carried id is what separates NEW activity from a still-standing finding + * the round re-posts. Step 6 re-posts every unfixed ledger Critical under its + * ORIGINAL id, so a single Critical nobody has fixed yet arrives in + * `drafts` every round: counted as activity it fires both signals forever — + * a cluster that gains "1 more now" with no new finding ever appearing, and a + * flat volume trend — which is the steady state, not divergence. + */ +export interface DraftedFinding { + /** The path this comment anchors to; empty when it has none. */ + file: string; + /** + * The ledger id the body carries when it re-posts an earlier round's + * finding, as the shared readback extracted it. Absent on a fresh finding, + * which has no id until this round's ledger is built. + */ + carriedId?: string; +} + +/** What the previous round left behind, and how far it can be trusted. */ +export interface PrevRound { + /** Inline comments the previous round posted, when it recorded the number. */ + posted?: number; + /** Its work list, as the side file recovered it. */ + findings: readonly LedgerFinding[]; + /** + * Its marker shed findings to fit the ledger's byte budget, so the list is + * known-incomplete. Measured at up to 35 shed per round on the worst PRs + * this feature targets — exactly the loops the diagnosis speaks to, so the + * undercount is disclosed rather than presented as a full count. + */ + truncated?: boolean; + /** + * The marker it came from was not posted by this account. Recovery adopts + * the highest-round marker whoever posted it, so the round numbers a + * cluster cites can name rounds this account never ran. Disclosed rather + * than dropped: the citation is still the best evidence available, and a + * reader who knows where it came from can check it. + */ + foreign?: boolean; + /** + * The work list is WHOLE — nothing was shed by the marker's byte budget, + * nothing was refused by the admission test, and it really was recovered. + * Absence of an id from an incomplete list proves nothing. + */ + complete?: boolean; + /** + * That foreign marker was MERGED over this account's own findings, which + * survive the union under their own ids. It changes what the disclosure + * can honestly claim: "may not be this account's own" over a work list + * that is predominantly this account's own certified entries overstates + * by exactly the part the union protected. + */ + merged?: boolean; + /** + * The posting floor it ran under, when its marker recorded one. A round + * that posted under a different floor is not a comparable point on this + * loop's volume trend — the posture changed, not the loop. + */ + floor?: 'c' | 'o'; + /** + * How many of its comments were findings reported for the FIRST time. + * The number the trend is about — see `fresh` on the diagnosis. + */ + fresh?: number; +} + +export interface ConvergenceDiagnosis { + /** The round being composed. */ + round: number; + /** Inline comments this round posts, and the previous round's when known. */ + posted: number; + prevPosted?: number; + /** + * How many of those were reported for the FIRST time, this round and the + * previous one. The trend runs on these, not on the totals: Step 6 + * re-posts every unfixed ledger Critical under its original id, so the + * re-post floor only ever rises and a loop whose new findings collapsed + * from five to one still posts more comments than the round before. + */ + fresh: number; + prevFresh?: number; + /** Files that carried findings before and carry more now. */ + clusters: RecurrenceCluster[]; + /** True when this round's volume did not fall below the previous round's. */ + volumeNotShrinking: boolean; + /** Carried through from `PrevRound` so the rendering can disclose them. */ + truncatedEvidence: boolean; + foreignEvidence: boolean; + mergedEvidence: boolean; + /** + * HOW this round's floor resolved to `critical`, or null if it did not. + * + * The kind, not a boolean, because the advice quotes it back: `auto` is the + * default configuration, and wording an auto-resolved floor as an explicit + * `--severity-floor critical` setting claims a flag nobody passed — beside + * a floor-enforcement note in the same body that describes it accurately as + * the RESOLVED floor. Auto also fails open the moment context becomes + * unavailable, which an unconditional-sounding claim would misstate. + */ + criticalFloorKind?: CriticalFloorKind; +} + +/** How a round's posting floor came to be `critical`. */ +export type CriticalFloorKind = 'explicit' | 'auto-resolved'; + +/** + * Is this draft a finding reported for the FIRST time? + * + * The ONE statement of freshness. Step 6 re-posts every still-standing + * ledger entry under its ORIGINAL id, so an id minted in an earlier round + * marks a re-post — the loop holding its position, not the loop generating + * work. Exported because the marker records the count for the next round's + * trend, and a second restatement there would let the number the trend reads + * disagree with the drafts the trend is about. + * + * Strict below the round cap. AT the cap the id space collides — consecutive + * rounds both stamp `R-*` — so the rule fails toward "carried", because + * the two errors do not cost the same: calling a re-post fresh narrates + * divergence at the steady state every round forever, while calling a fresh + * finding carried costs one round of silence. + */ +export function isFreshDraft( + d: DraftedFinding, + round: number, + carried: ReadonlySet = EVERY_ID, + carriedComplete = true, +): boolean { + const minted = birthRound(d?.carriedId); + if (minted === undefined) return true; + // The id must NAME an entry in the work list it claims to carry forward. + // Step 6 teaches the model to lead a re-post with `R1-2: `, and + // models emit stray ids at the head of a claim line — so a genuinely new + // finding written in that shape would otherwise vanish from both signals: + // out of its file's cluster, and out of the activity guard, leaving a + // round of real new work reading as the steady state. + // + // Only over a list known to be WHOLE, and for the same reason `buildLedger` + // keeps such an id over a shortened one: a non-member there may be an entry + // the byte budget shed, which Step 6 re-voices under its original id. Read + // as first-time work it would post "the rate of new findings is not + // falling" every round on a loop doing no new work — and one marker would + // say two things about the same comment, since the work list keeps the id + // the fresh count calls new. + if ( + carriedComplete && + d.carriedId !== undefined && + !carried.has(d.carriedId) + ) { + return true; + } + if (round >= LEDGER_MAX_ROUND && minted >= LEDGER_MAX_ROUND) return false; + return minted >= round; +} + +/** + * The default for a caller with no work list to check against — the id's own + * round is then all there is. + * + * Every production caller HAS one and must pass it: the marker's fresh count + * and the posted paragraph's are the same number about the same round, and + * two different carried-sets made one body state two volumes — with the + * marker's undercount persisting as the next round's `prev.fresh`, where the + * trend's own guard reads it. + */ +const EVERY_ID: ReadonlySet = { + has: () => true, +} as unknown as ReadonlySet; + +/** + * The diagnosis for this round, or null when the loop looks healthy. + * + * Two signals, either of which fires it, and both are self-comparisons: + * + * - **Recurrence.** A file that carried a finding in an earlier round and + * carries a NEW one now. Joined by FILE, deterministically — no model + * judgement, no similarity scoring. Title similarity was considered and + * dropped: the titles are model-written and capped at 80 characters, which + * makes them noise at exactly the length where a match would matter. A + * cluster that keeps regenerating siblings usually means the fixes are + * treating instances of a shared root cause, and that sentence is the whole + * value here. + * - **Volume not shrinking.** From round 3, this round producing at least as + * many NEW findings as the previous one. Round 3 because two rounds give + * one step and a step is not a trend; "not shrinking" rather than + * "growing" because a loop holding steady is not converging either; and + * NEW findings rather than the comment total because Step 6 re-posts every + * unfixed entry, so the total only ever rises. + * + * Both signals read FRESH drafts only. A re-posted still-standing finding is + * the loop holding its position, not the loop generating work, and counting + * it as activity fires both signals on the calmest shape there is (see + * `DraftedFinding`). + * + * Returns null — not an empty diagnosis — when neither fires, so a caller + * cannot accidentally render a section that says nothing. Absent inputs make + * a signal impossible to evaluate rather than true: a round with no recovered + * predecessor has no volume to compare against, and one with no previous work + * list has no recurrence to find. + */ +export function diagnoseConvergence(input: { + round: number; + posted: number; + prev: PrevRound; + /** This round's drafted comments. */ + drafts: readonly DraftedFinding[]; + /** + * The floor THIS round resolved to, for comparison against the previous — + * absent when the state named no floor this module recognises. An unknown + * posture is not a posture that matches, and it is not one that differs: + * it makes the comparison unavailable, which leaves the trend evaluated as + * it was before floors were recorded at all. + */ + floor?: 'c' | 'o'; + criticalFloorKind?: CriticalFloorKind; +}): ConvergenceDiagnosis | null { + const priorByFile = new Map>(); + for (const f of input.prev.findings) { + if (typeof f?.file !== 'string' || f.file.trim() === '') continue; + // A body-only Critical, or a comment that arrived without a path, names + // no file and cannot cluster. Git permits both stand-in spellings as + // real filenames, so the ledger flags the EXCEPTION — `k` marks a + // literal path that happens to be spelled like one — and the rule reads + // the same for a marker written before that flag existed, whose + // stand-ins carry no flag because they are stand-ins. + if (isStandInName(f.file) && f.k !== 1) continue; + const round = birthRound(f.id); + if (round === undefined) continue; + const set = priorByFile.get(f.file) ?? new Set(); + set.add(round); + priorByFile.set(f.file, set); + } + + // Fresh: not a re-post of a finding minted in an earlier round. An id this + // round would mint is not "earlier", so the comparison is strict — except + // AT the round cap, where the id space collides: consecutive rounds at + // `LEDGER_MAX_ROUND` both stamp `R-*`, so a re-post of an unfixed + // Critical is indistinguishable from a fresh finding by its id alone. + // There the rule fails toward "carried", because the cost of the two + // errors is not symmetric: calling a re-post fresh narrates divergence at + // the steady state every round forever, while calling a fresh finding + // carried costs one round of silence. + const carriedIds = new Set( + input.prev.findings + .map((f) => f?.id) + .filter((id): id is string => typeof id === 'string'), + ); + const fresh = input.drafts.filter((d) => + isFreshDraft(d, input.round, carriedIds, input.prev.complete === true), + ); + + // Keyed by the REAL path, never by a truncated one. The ledger caps `file` + // at `LEDGER_MAX_FILE`, so the join has to reach across that cap — but + // truncating the drafted side to meet it does not prevent prefix + // collisions, it creates them: two distinct files sharing a 200-character + // prefix collapse to one key, their counts sum as though they were one + // file, and the paragraph then posts a 200-character prefix as a path that + // exists in no repository (with a lone surrogate at the cut, for a + // non-ASCII path). Matching a truncated LEDGER entry by prefix instead + // keeps every displayed path real; two files behind one truncated entry + // become two clusters citing the same prior rounds, which over-attributes + // history rather than inventing a filename. + const thisByFile = new Map(); + for (const d of fresh) { + const p = d?.file; + if (typeof p !== 'string' || p.trim() === '') continue; + thisByFile.set(p, (thisByFile.get(p) ?? 0) + 1); + } + const priorFor = (file: string): Set | undefined => + priorByFile.get(file) ?? + (file.length > LEDGER_MAX_FILE + ? priorByFile.get(file.slice(0, LEDGER_MAX_FILE)) + : undefined); + + // Held to round 3 for the same reason the volume signal is: one step is + // not a trend. A round-1 finding fixed and one new finding landing in the + // same file is the ordinary healthy re-review — and on a single-file PR + // the "split it into its own pull request" advice has no referent at all. + const clusters: RecurrenceCluster[] = []; + if (input.round >= 3) { + for (const [file, count] of thisByFile) { + const prior = priorFor(file); + if (!prior || prior.size === 0) continue; + clusters.push({ + file, + priorRounds: [...prior].sort((a, b) => a - b), + thisRound: count, + }); + } + } + // Deterministic order: the file producing the most NEW work now first, + // then the number of earlier rounds, then the path. + // + // This round's count leads, not the prior-round depth, because the depth + // measures the wrong thing for the sentence it ranks. The previous round's + // ledger is that round's POSTING SET: a finding the author fixed is not + // re-posted and its round leaves the list, while a finding nobody fixed is + // re-posted under its original id and contributes its mint round forever. + // So depth grows exactly where nothing is being fixed — and the advice it + // ranked reads "a cluster that keeps producing siblings", about a file + // where no fix happened. Depth is also the key a stranger can set: one + // marker holding fifty legal ids on one file put a fabricated cluster in + // the top slot and evicted a genuine one from the rendered three. + // + // The path tie-break compares CODE UNITS, not `localeCompare`: collation + // follows the runtime locale, and the clustered paths belong to whatever + // repository is under review, so a locale change between the CI bot's + // round and a maintainer's round would otherwise reorder tied non-ASCII + // paths and break the invariant this sort states. + // + // The depth key is DROPPED entirely when the work list came from another + // account's marker. Leading with this round's count takes the top slot + // back from a fabricated cluster, but depth still decides every tie — and + // ties are the ordinary shape, one fresh finding per file — so fifty + // planted ids on one file still evicted a genuine cluster from the + // rendered three. Provenance is disclosed for the ROUNDS; the ordering + // cannot disclose anything, so on a foreign list it simply does not use a + // number a stranger set. + const trustDepth = input.prev.foreign !== true; + clusters.sort( + (a, b) => + b.thisRound - a.thisRound || + (trustDepth ? b.priorRounds.length - a.priorRounds.length : 0) || + (a.file < b.file ? -1 : a.file > b.file ? 1 : 0), + ); + + // A round that produced NO fresh finding is the observation a convergence + // trend most wants, not a symptom: zero new work is where a settling loop + // lands, and `0 >= 0` would otherwise narrate "the volume is not falling" + // at exactly the moment it has finished falling. The guard subsumes the + // zero-posting case — a round with no drafts has no fresh drafts either — + // and additionally covers the round whose whole output is carried re-posts. + // + // `prev.fresh > 0` for the mirror reason on the other end: a trend measured + // against a zero predecessor is `N >= 0`, true for every N, so restarting + // from a settled round would fire on the healthiest shape there is (fix + // everything, settle at zero, push again, get new findings). + // + // And the two rounds must have posted under the SAME floor. A posture + // change is not loop behaviour: an operator who takes this module's own + // advice, sets `--severity-floor critical`, and later restores it produces + // a volume jump the trend would read as a loop that will not settle — and + // the advice would then recommend re-tightening the floor just + // deliberately loosened. One transient `contextUnavailable` round under + // `auto` produces the same spike with no operator action at all. A + // previous floor that was never recorded is not a floor that differs, so a + // pre-field marker evaluates exactly as it did before. + const floorChanged = + input.prev.floor !== undefined && + input.floor !== undefined && + input.prev.floor !== input.floor; + // + // Measured on FRESH findings, not on the round's whole output. Step 6 + // re-posts every unfixed ledger Critical under its original id, so the + // re-post floor is monotonically non-decreasing: a loop whose new findings + // collapsed from five to one still posts more comments than the round + // before, and a trend on the totals would call that convergence + // "not falling" — forever. A predecessor that recorded no fresh count + // leaves the trend unevaluable rather than measured on the wrong number. + const volumeNotShrinking = + input.round >= 3 && + input.prev.fresh !== undefined && + input.prev.fresh > 0 && + !floorChanged && + fresh.length > 0 && + fresh.length >= input.prev.fresh; + + if (clusters.length === 0 && !volumeNotShrinking) return null; + return { + round: input.round, + posted: input.posted, + fresh: fresh.length, + ...(input.prev.posted === undefined + ? {} + : { prevPosted: input.prev.posted }), + ...(input.prev.fresh === undefined ? {} : { prevFresh: input.prev.fresh }), + clusters, + volumeNotShrinking, + truncatedEvidence: input.prev.truncated === true, + foreignEvidence: input.prev.foreign === true, + mergedEvidence: input.prev.merged === true, + ...(input.criticalFloorKind === undefined + ? {} + : { criticalFloorKind: input.criticalFloorKind }), + }; +} + +/** How many clusters the rendered paragraph names before summarising. */ +export const MAX_RENDERED_CLUSTERS = 3; + +/** + * The diagnosis as the two sentences a human reads: what was measured, and + * what that shape usually means. + * + * Facts first and separately, because the facts are certain and the reading + * is not. Where the evidence itself is qualified — a truncated work list, one + * recovered from another account's marker — the qualification is stated in + * the same paragraph rather than left for the reader to discover, matching + * the PARTIAL disclosure `pr-context` already renders for the same data. + * + * The recommendations are process-level on purpose — triage the cluster, + * split it out, stem the posting surface, batch the fixes — and never a + * code-architecture prescription: this module cannot verify a claim about how + * the code should be restructured, and an unverifiable claim is exactly what + * the rest of this pipeline refuses to post. + */ +export function renderConvergenceDiagnosis(d: ConvergenceDiagnosis): { + en: string; + zh: string; +} { + const shown = d.clusters.slice(0, MAX_RENDERED_CLUSTERS); + const more = d.clusters.length - shown.length; + // Every path here is PR-controlled and goes out in a body this bot posts + // under its own identity — `mdField`, never hand-spelled backticks. + const clusterEn = shown + .map( + (c) => + `${mdField(c.file)} (findings in round${c.priorRounds.length > 1 ? 's' : ''} ${c.priorRounds.join(', ')}, ${c.thisRound} more now)`, + ) + .join('; '); + const clusterZh = shown + .map( + (c) => + `${mdField(c.file)}(第 ${c.priorRounds.join('、')} 轮已出过发现,本轮又有 ${c.thisRound} 条)`, + ) + .join(';'); + + const factsEn = [ + `round ${d.round} posted ${d.posted} inline comment(s), ${d.fresh} of them reported for the first time`, + d.prevPosted === undefined + ? null + : `the previous round posted ${d.prevPosted}${d.prevFresh === undefined ? '' : ` (${d.prevFresh} new)`}`, + ] + .filter(Boolean) + .join('; '); + const factsZh = [ + `第 ${d.round} 轮发布了 ${d.posted} 条行内评论,其中 ${d.fresh} 条是首次提出`, + d.prevPosted === undefined + ? null + : `上一轮发布了 ${d.prevPosted} 条${d.prevFresh === undefined ? '' : `(其中 ${d.prevFresh} 条首次提出)`}`, + ] + .filter(Boolean) + .join(';'); + + // Both readings are reported when both fired. Discriminating on the + // clusters alone made the volume sentence — and with it the entire floor + // recommendation — unreachable on the shape this feature exists for: + // recurrence and a flat trend together. + const reasonsEn: string[] = []; + const reasonsZh: string[] = []; + if (d.clusters.length > 0) { + reasonsEn.push( + `Findings keep coming back to the same files: ${clusterEn}${more > 0 ? `, and ${more} more file(s)` : ''}.`, + ); + reasonsZh.push( + `发现反复回到同一批文件:${clusterZh}${more > 0 ? `,另有 ${more} 个文件` : ''}。`, + ); + } + if (d.volumeNotShrinking) { + reasonsEn.push(`The rate of new findings is not falling.`); + reasonsZh.push(`新发现的产出速度没有下降。`); + } + const reasonEn = reasonsEn.join(' '); + const reasonZh = reasonsZh.join(''); + + // A caveat attaches to the reading it actually bears on. Truncation + // affects only the WORK LIST, so it qualifies the recurrence reading + // alone. Provenance is broader: a foreign marker carries the previous + // round's VOLUME too, and the volume reading cites that number as this + // loop's own baseline — gated on clusters, the disclosure never reached + // exactly the branch an attacker-supplied count controls. + const citesWorkList = d.clusters.length > 0; + const citesPrevVolume = + d.prevPosted !== undefined || d.prevFresh !== undefined; + const caveatsEn: string[] = []; + const caveatsZh: string[] = []; + // Truncation qualifies BOTH readings, not only the recurrence one: the + // work list IS the carried-id set that defines freshness. The direction it + // moves the count is UNDER, not over — the stray-id rescue is gated on the + // list being whole, so over a shortened one a genuinely new finding the + // model prefixed with an earlier round's id cannot be rescued and is read + // as a re-post. (A re-post of a SHED entry is read as carried too, which + // is correct: it is one.) + if (d.truncatedEvidence) { + // The count clause is unconditional, because the facts clause cites this + // round's fresh count unconditionally. Only the rounds half depends on + // rounds being named. + const understated = { + en: `a new finding written under an earlier round's id cannot be told from a re-post over a partial list, so the new-finding count may be understated`, + zh: `在不完整的清单上,冠以早先轮次 id 的新发现无法与重发区分,首次提出的条数可能少计`, + }; + const what = citesWorkList + ? { + en: `the rounds named above may be an undercount, and ${understated.en}`, + zh: `上述轮次可能少计;${understated.zh}`, + } + : understated; + caveatsEn.push( + `the previous round's work list was truncated to fit the marker, so ${what.en}`, + ); + caveatsZh.push(`上一轮的工作清单为放进标记而被截断,${what.zh}`); + } + if (d.foreignEvidence && (citesWorkList || citesPrevVolume)) { + const what = citesWorkList + ? citesPrevVolume + ? { en: `those rounds and its counts`, zh: `上述轮次与其计数` } + : { en: `those rounds`, zh: `上述轮次` } + : { en: `those counts`, zh: `该计数` }; + caveatsEn.push( + d.mergedEvidence + ? `the previous round was recovered from a marker this account did not post and merged over this account's own entries, so some of ${what.en} may not be this account's own` + : `the previous round was recovered from a marker this account did not post, so ${what.en} may not be this account's own`, + ); + caveatsZh.push( + d.mergedEvidence + ? `上一轮的数据来自并非本账号发布的标记,并与本账号自己的条目合并,${what.zh}中的部分可能不属于本账号` + : `上一轮的数据来自并非本账号发布的标记,${what.zh}可能不属于本账号`, + ); + } + const caveatEn = + caveatsEn.length > 0 ? ` (Evidence: ${caveatsEn.join('; ')}.)` : ''; + const caveatZh = + caveatsZh.length > 0 ? `(证据说明:${caveatsZh.join(';')}。)` : ''; + + // The floor recommendation is dropped once the floor already resolves to + // `critical`: advising a posture the round is running under, inside the + // very body whose floor-enforcement note says so, reads as advice nobody + // checked. And it names the posture the way that round actually got it — + // `auto` is the DEFAULT, so wording an auto-resolved floor as an explicit + // setting claims a flag nobody passed. + const batchEn = `Batching the remaining fixes and verifying them before the next push`; + const batchZh = `把剩余修复攒成一批、验证后再推送`; + const alreadyEn: Record = { + explicit: `this PR's reviews are already at \`--severity-floor critical\``, + 'auto-resolved': `this PR's reviews already resolve to a critical posting floor`, + }; + const alreadyZh: Record = { + explicit: `本 PR 的评审已处于 \`--severity-floor critical\``, + 'auto-resolved': `本 PR 的评审已解析为 critical 发布下限`, + }; + const floorEn = + d.criticalFloorKind === undefined + ? `${batchEn}, or dropping this PR's reviews to \`--severity-floor critical\`, keeps the loop from re-deriving the same set.` + : `${batchEn} keeps the loop from re-deriving the same set; ${alreadyEn[d.criticalFloorKind]}.`; + const floorZh = + d.criticalFloorKind === undefined + ? `${batchZh},或将本 PR 的评审降到 \`--severity-floor critical\`,可以避免循环反复推导同一组发现。` + : `${batchZh},可以避免循环反复推导同一组发现;${alreadyZh[d.criticalFloorKind]}。`; + + const clusterAdviceEn = `A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time.`; + const clusterAdviceZh = `一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。`; + const adviceEn = [ + d.clusters.length > 0 ? clusterAdviceEn : null, + d.volumeNotShrinking ? floorEn : null, + ] + .filter(Boolean) + .join(' '); + const adviceZh = [ + d.clusters.length > 0 ? clusterAdviceZh : null, + d.volumeNotShrinking ? floorZh : null, + ] + .filter(Boolean) + .join(''); + + // The closing claim is scoped to THIS observation, not to the review: the + // same body can carry a floor-enforcement note, a deferral list, or a + // discarded-Suggestion count — all of them things withheld from this + // round's posting surface. An absolute "nothing was withheld" beside those + // is a sentence the body itself refutes. + return { + en: `Convergence: ${factsEn}. ${reasonEn}${caveatEn} ${adviceEn} (Observation only — nothing was withheld from this review because of this observation.)`, + zh: `收敛情况:${factsZh}。${reasonZh}${caveatZh}${adviceZh}(仅为观察——本轮评审未因此扣留任何内容。)`, + }; +} diff --git a/packages/cli/src/commands/review/lib/ledger.test.ts b/packages/cli/src/commands/review/lib/ledger.test.ts index 92091922f8..dcfb8e0f8d 100644 --- a/packages/cli/src/commands/review/lib/ledger.test.ts +++ b/packages/cli/src/commands/review/lib/ledger.test.ts @@ -20,6 +20,10 @@ import { LEDGER_MAX_BYTES, LEDGER_MAX_MODEL, LEDGER_MAX_VOLUME, + LEDGER_MAX_ID, + LEDGER_ID_SHAPE, + LEDGER_MAX_ROUND, + isLedgerFinding, type Ledger, type LedgerFinding, } from './ledger.js'; @@ -437,6 +441,251 @@ describe('ledger marker', () => { }); }); +describe('a shortened work list must never read as complete', () => { + const f = (id: string): LedgerFinding => ({ + id, + sev: 'S', + file: 'a.ts', + title: 't', + }); + + it('counts what the FILTER rejected, not only what the cap sliced', () => { + // `dropped` decides two things: the anchor is withheld while it is set, + // and it now publishes the "may be an undercount" caveat. Entries the + // filter rejected are findings the next round will never rule on, so a + // list short by them that still certifies its range retires a posted + // Critical silently AND scopes the next review past its code. + const marker = + ''; + const parsed = parseLedger(marker)!; + expect(parsed.findings.map((x) => x.id)).toEqual(['R3-1']); + expect(parsed.dropped).toBe(1); + expect(parsed.sha).toBeUndefined(); + }); + + it('never writes an id its own parser would refuse', () => { + // The id cap slices without re-validating, so an over-long id is cut + // mid-token and stops being the grammar. Emitted, the next round's + // filter drops it — the finding retires with no ruling, and the loss is + // invisible unless it is counted here, where `dropped` still counts it. + const long = `R${'1'.repeat(30)}-7`; + const marker = serializeLedger({ + v: 1, + round: 2, + findings: [f('R2-1'), { ...f(long), file: 'b.ts' }], + sha: 'deadbeef00112233', + }); + // The MARKER, not merely the parse: dropped on the write side the loss is + // declared in the bytes and the anchor is withheld by the writer; left in, + // the marker spends its budget on a token its own reader will refuse. + expect(marker).not.toContain('R1111'); + expect(marker).toContain('"dropped":1'); + const parsed = parseLedger(marker)!; + expect(parsed.findings.map((x) => x.id)).toEqual(['R2-1']); + expect(parsed.dropped).toBe(1); + expect(parsed.sha).toBeUndefined(); + }); + + it('writes no floor beside a volume that did not survive', () => { + // The floor qualifies `posted`. Written whenever the rung ADMITS the + // group rather than whenever the volume survived it, it is bytes spent + // on the shed cascade that the parser then discards — on the same ladder + // the serializer prices at a lost anchor. + const marker = serializeLedger({ + v: 1, + round: 2, + findings: [f('R2-1')], + posted: -3 as unknown as number, + floor: 'c', + }); + expect(marker).not.toContain('floor'); + }); + + it('refuses a round-0 id at both ends of the bound', () => { + // Rounds start at 1, so `R0-*` is not an id this pipeline can mint — but + // it passes the shape, and every reader that turns an id into a round + // rejects round 0 and then reads the rejection as "no carried id", i.e. + // as FRESH. Admitted, a re-posted `R0-1` counts as first-time work every + // round and the trend narrates divergence at a settled steady state. + expect( + isLedgerFinding({ id: 'R0-1', sev: 'C', file: 'x.ts', title: 't' }, 9), + ).toBe(false); + expect( + parseLedger( + '', + )?.findings, + ).toEqual([]); + // The write side applies the same test, so a stray id the model minted + // out of range never reaches a marker its own reader would refuse. + const marker = serializeLedger({ + v: 1, + round: 3, + findings: [f('R3-1'), { ...f('R0-1'), file: 'b.ts' }], + }); + expect(marker).not.toContain('R0-1'); + expect(marker).toContain('"dropped":1'); + }); + + it('round-trips the stand-in exception flag, and clamps a forged dropped', () => { + // [1] The flag has to survive serialize -> parse, not merely exist on + // the builder's output: it is the only thing separating a real file + // spelled like a stand-in from the stand-in itself, and it crosses the + // marker boundary on every round. + const marker = serializeLedger({ + v: 1, + round: 3, + findings: [ + { id: 'R3-1', sev: 'C', file: '(body)', title: 'a stand-in' }, + { id: 'R3-2', sev: 'S', file: '(body)', title: 'a real file', k: 1 }, + ], + }); + const back = parseLedger(marker)!; + expect(back.findings[0].k).toBeUndefined(); + expect(back.findings[1].k).toBe(1); + // The stand-in costs no marker bytes; only the exception is spelled. + expect(marker.match(/"k":1/g)).toHaveLength(1); + }); + + it('clamps a forged `dropped` instead of publishing it', () => { + // It renders into the model-facing PARTIAL line and publishes the + // undercount caveat, and unlike a forged finding it cannot be re-ruled. + const parsed = parseLedger( + '', + )!; + // Clamped through the same reader the other counts use, so the PARTIAL + // line cannot render `1e+308 further finding(s)`. + expect(parsed.dropped).toBe(LEDGER_MAX_VOLUME); + // A non-count is still no count at all. + expect( + parseLedger( + '', + )?.dropped, + ).toBeUndefined(); + }); + + it('refuses an over-long id rather than cutting it into a different one', () => { + // Admitted and then sliced, the entry silently changes identity between + // the round that posted it and the round that rules on it. + const long = `R2-${'9'.repeat(LEDGER_MAX_ID)}`; + expect(long.length).toBeGreaterThan(LEDGER_MAX_ID); + expect( + isLedgerFinding({ id: long, sev: 'S', file: 'a.ts', title: 't' }, 9), + ).toBe(false); + }); + + it('bounds an id round by the CAP, not only by the claimed round', () => { + // The side-file route's round is whatever was written to it, which the + // admission test's own comment says is not always clamped. + expect( + isLedgerFinding( + { + id: `R${LEDGER_MAX_ROUND + 1}-1`, + sev: 'S', + file: 'a.ts', + title: 't', + }, + Number.MAX_SAFE_INTEGER, + ), + ).toBe(false); + expect( + isLedgerFinding( + { id: `R${LEDGER_MAX_ROUND}-1`, sev: 'S', file: 'a.ts', title: 't' }, + Number.MAX_SAFE_INTEGER, + ), + ).toBe(true); + }); + + it('keeps the fresh count only beside a volume that bounds it', () => { + const ok = parseLedger( + '', + )!; + expect(ok.fresh).toBe(2); + // Larger than the total it is part of: not a count of anything. + const over = parseLedger( + '', + )!; + expect(over.fresh).toBeUndefined(); + // No total: nothing for it to be a part of. + const bare = parseLedger( + '', + )!; + expect(bare.fresh).toBeUndefined(); + }); + + it('refuses an over-long id rather than emitting a cut one under it', () => { + // The cut can still match the grammar — `R3-` plus twenty-two nines + // slices to a well-formed twenty-four — so validating after the slice + // emitted a DIFFERENT id under the same entry: the next round's readback + // of the posted claim returns the full id, matches no ledger entry, and + // the finding retires with no ruling while the list reads as complete. + const cuttable = `R3-${'9'.repeat(22)}`; + expect(cuttable.length).toBeGreaterThan(LEDGER_MAX_ID); + expect(LEDGER_ID_SHAPE.test(cuttable.slice(0, LEDGER_MAX_ID))).toBe(true); + const marker = serializeLedger({ + v: 1, + round: 3, + findings: [ + { ...f('R3-1'), file: 'a.ts' }, + { ...f(cuttable), file: 'b.ts' }, + ], + sha: 'deadbeef00112233', + }); + expect(marker).not.toContain(cuttable.slice(0, LEDGER_MAX_ID)); + const parsed = parseLedger(marker)!; + expect(parsed.findings.map((x) => x.id)).toEqual(['R3-1']); + expect(parsed.dropped).toBe(1); + expect(parsed.sha).toBeUndefined(); + }); + + it('clamps the SUMMED dropped, not only its declared term', () => { + // `raw.findings.length` is attacker-chosen — a body of tens of thousands + // of single-character invalid entries fits GitHub's limit — and the + // total is interpolated verbatim into the model-facing PARTIAL line. + const junk = Array.from({ length: 400 }, () => ({ id: 'x' })); + const parsed = parseLedger( + ``, + )!; + expect(parsed.dropped).toBe(LEDGER_MAX_VOLUME); + }); + + it('normalises an unrecognised clustering hint instead of dropping the finding', () => { + // `k` decides nothing. The marker is a cross-environment carrier by + // design, so a later version adding a third kind — or a hand edit, or a + // foreign marker — would otherwise make every older CLI drop those + // findings from the work list: they would owe no Step 6 ruling and + // retire with nobody ruling on them. + const marker = + ''; + const parsed = parseLedger(marker)!; + expect(parsed.findings).toEqual([ + { id: 'R3-1', sev: 'C', file: '(body)', title: 't' }, + ]); + expect(parsed.dropped).toBeUndefined(); + }); + + it('bounds an id round even when the marker round does not', () => { + // The round is printed verbatim in a public body, and the side-file read + // shares this admission test with no clamp of its own. + expect( + isLedgerFinding( + { id: 'R99999999999999999999-1', sev: 'S', file: 'a.ts', title: 't' }, + Number.MAX_SAFE_INTEGER, + ), + ).toBe(false); + // A leading space is the bypass the whole-shape test closes. + expect( + isLedgerFinding({ id: ' R9-1', sev: 'S', file: 'a.ts', title: 't' }, 99), + ).toBe(false); + }); +}); + // The prefix-anchored readback both ledger read sides share wholesale: // compose-review's ledger builder and presubmit's re-post extractor. describe('LEDGER_ID_READBACK', () => { diff --git a/packages/cli/src/commands/review/lib/ledger.ts b/packages/cli/src/commands/review/lib/ledger.ts index 9f89303683..f801ac6a8f 100644 --- a/packages/cli/src/commands/review/lib/ledger.ts +++ b/packages/cli/src/commands/review/lib/ledger.ts @@ -35,6 +35,23 @@ export interface LedgerFinding { /** `C` (Critical) or `S` (Suggestion). Compact on purpose — body bytes. */ sev: 'C' | 'S'; file: string; + /** + * Set only when `file` is a LITERAL path that happens to equal one of the + * stand-in names below — `(body)`, `(unknown)`. Git permits both as + * filenames, so the sentinels alone cannot separate "no path to give" from + * "a file with that name", and a reader keying on the value excluded a + * real file of that name from clustering, silently. + * + * The flag marks the EXCEPTION rather than the rule on purpose. Flagging + * the stand-ins would have cost bytes on every body Critical, which is + * routine — and this field rides through all four rungs of the shed + * cascade, where the serializer's own comment prices ~27 bytes of + * telemetry at a lost anchor or a lost ruling. Flagging the pathological + * filename instead costs nothing on any normal round, and it lets a marker + * written before this field existed read correctly: its sentinels carry no + * flag, which is exactly what they mean. + */ + k?: 1; line?: number; /** One line, capped — enough for the next round to re-locate the claim. */ title: string; @@ -114,6 +131,41 @@ export interface Ledger { * fail-open, decides-nothing contract as `posted`. */ prevPosted?: number; + /** + * The posting floor this round RESOLVED to — `c` when the critical floor + * was in effect, `o` when Suggestions were postable. + * + * It qualifies `posted`, and travels and sheds with it. Without it the + * volume trend measures a POSTURE change as loop divergence: an operator + * who takes this pipeline's own advice and sets `--severity-floor + * critical` collapses the volume, and restoring it later produces a jump + * the trend reads as a loop that will not settle — and then advises + * re-tightening the floor just deliberately loosened. The bias is + * one-directional (loosening fires it, tightening only shrinks volume), + * and one transient `contextUnavailable` round under `auto` produces the + * same spike with no operator action at all. + * + * Same fail-open, decides-nothing contract as the volumes: absent means + * "not recorded", which leaves the trend evaluated as it was before this + * field existed. + */ + floor?: 'c' | 'o'; + /** + * How many of `posted` were findings this round REPORTED FOR THE FIRST + * TIME — not re-posts of still-standing entries from earlier rounds. + * + * The number the convergence trend is actually about. `posted` is the + * round's whole output, and Step 6 re-posts every unfixed ledger Critical + * under its original id, so the re-post floor only ever rises: a loop whose + * NEW findings collapsed from five to one still posts more comments than + * the round before, and a trend measured on the totals reads that + * convergence as divergence, permanently. Absent means "not recorded", + * which leaves the trend unevaluable rather than measured on the wrong + * number. + * + * Rides and sheds with `posted`, which it qualifies. + */ + fresh?: number; } /** @@ -163,6 +215,19 @@ export const LEDGER_ID_READBACK = new RegExp( `^(${LEDGER_ID_TOKEN})[:.)\\]]?(?=\\s|$)\\s*`, ); +/** + * The id as a WHOLE string — nothing before it, nothing after. The one + * admission test, shared with presubmit's entry check. + * + * Anchored at both ends on purpose. A prefix-only test (`^R\d+-`) admitted + * ids the readers then interpreted differently from the test: every reader + * downstream trims before matching (`birthRound`, `readClaim`), so ` R9999-1` + * failed the untrimmed squat filter, was therefore never dropped, and read as + * round 9999 everywhere it mattered — pre-claiming the next round's id prefix + * and citing a round no account ever ran. + */ +export const LEDGER_ID_SHAPE = new RegExp(`^${LEDGER_ID_TOKEN}$`); + /** Caps keep the marker a footnote, never a payload: GitHub's body limit is * 65,536 chars and the marker rides inside it. Every cap binds BOTH halves — * the serializer so the write side is bounded, the parser so a hand-edited @@ -170,6 +235,30 @@ export const LEDGER_ID_READBACK = new RegExp( export const LEDGER_MAX_FINDINGS = 50; export const LEDGER_MAX_TITLE = 80; export const LEDGER_MAX_FILE = 200; +/** + * The pseudo-paths a finding carries when it has no file to name: a body-only + * Critical anchors to the review body itself, and a drafted comment that + * arrived without a path anchors to nothing at all. + * + * Named here because BOTH ends must agree. The ledger builder stamps them into + * `findings[].file`, and every reader that must not treat them as real files + * compares against them — the convergence join excludes them from clustering. + * Spelled as bare literals on each end, a rename on one end alone turns a + * pseudo-path into an ordinary file the reader clusters on and NAMES in a + * posted paragraph: the same two-ends drift the shared id constants above + * exist to prevent. + */ +export const LEDGER_BODY_FILE = '(body)'; +export const LEDGER_UNKNOWN_FILE = '(unknown)'; + +/** + * Is this path spelled like one of the stand-ins? The one place that + * question is asked, so the writer's exception flag and the reader's + * exclusion cannot disagree about which names need disambiguating. + */ +export function isStandInName(file: string): boolean { + return file === LEDGER_BODY_FILE || file === LEDGER_UNKNOWN_FILE; +} /** * The longest model id the marker can carry — and it carries one WHOLE or * not at all: a truncated id is a prefix, and a prefix can equal a DIFFERENT @@ -265,12 +354,26 @@ const CLOSE = ' -->'; * `Ledger` later cannot reintroduce the hazard by being forgotten below. */ export function serializeLedger(ledger: Ledger): string { - const capped = ledger.findings.slice(0, LEDGER_MAX_FINDINGS).map((f) => ({ - ...f, - id: f.id.slice(0, LEDGER_MAX_ID), - title: f.title.slice(0, LEDGER_MAX_TITLE), - file: f.file.slice(0, LEDGER_MAX_FILE), - })); + const roundOut = Math.min(ledger.round, LEDGER_MAX_ROUND); + const capped = ledger.findings + // Admitted BEFORE anything is sliced, the way the parse side does it. An + // over-long id cut at the cap can still match the grammar — `R3-` plus + // twenty-two nines slices to a well-formed twenty-four — so validating + // after the slice emitted a DIFFERENT id under the same entry: the next + // round's readback of the posted claim line returns the full id, matches + // no ledger entry, and the finding retires with no ruling while the list + // reads as complete and the anchor still scopes past it. A carried id the + // model minted out of range (`R0-1`) is refused here for the same reason. + .filter((f) => isLedgerFinding(f, roundOut)) + .slice(0, LEDGER_MAX_FINDINGS) + .map((f) => ({ + ...f, + // Length-safe by construction now: the admission test bounds the id, + // so this slice can only be a no-op on it. + id: f.id.slice(0, LEDGER_MAX_ID), + title: f.title.slice(0, LEDGER_MAX_TITLE), + file: f.file.slice(0, LEDGER_MAX_FILE), + })); const render = ( findings: LedgerFinding[], dropped: number, @@ -281,7 +384,7 @@ export function serializeLedger(ledger: Ledger): string { v: 1, // Mirrored on the write side like every other cap: a serializer that can // emit what its own parser refuses would round-trip to nothing. - round: Math.min(ledger.round, LEDGER_MAX_ROUND), + round: roundOut, findings, }; // The volume telemetry rides OUTSIDE the truncation rule that governs the @@ -293,6 +396,18 @@ export function serializeLedger(ledger: Ledger): string { if (volume !== 'none') { const postedOut = volumeOf(ledger.posted); if (postedOut !== undefined) payload.posted = postedOut; + // The floor and the fresh count qualify `posted`, so they ride with the + // volume that actually SURVIVED — not merely with the rung that admits + // the group. A volume that fails `volumeOf` leaves them qualifying + // nothing, which is bytes spent on this same ladder that the parser + // then discards. + if (postedOut !== undefined) { + if (ledger.floor === 'c' || ledger.floor === 'o') { + payload.floor = ledger.floor; + } + const freshOut = volumeOf(ledger.fresh); + if (freshOut !== undefined) payload.fresh = freshOut; + } if (volume === 'both') { const prevPostedOut = volumeOf(ledger.prevPosted); if (prevPostedOut !== undefined) payload.prevPosted = prevPostedOut; @@ -369,6 +484,90 @@ export function serializeLedger(ledger: Ledger): string { return marker; } +/** + * Is this a ledger finding this pipeline would admit, against the round the + * marker (or side file) claims? + * + * The ONE admission test. `parseLedger` applies it to a marker recovered from + * a posted body; `compose-review`'s side-file read applies it to the JSON + * `pr-context` wrote — the same untrusted shape arriving by a different + * route. That read restated two of these checks and skipped the rest, which + * is how a side file written before the id hardening could keep an id the + * marker path now rejects and publish a round number off it: a reader that + * trims is only as strict as the admission test in front of it. + */ +export function isLedgerFinding( + f: unknown, + markerRound: number, +): f is LedgerFinding { + const c = f as LedgerFinding | null | undefined; + if (!c || typeof c.id !== 'string') return false; + // The WHOLE shape, before anything reads a round out of it. A prefix-only + // test admitted ids the readers then interpreted differently from the test: + // every reader downstream trims before matching, so ` R9999-1` failed the + // untrimmed squat rule that exists to stop it and took full effect + // everywhere else. + if (!LEDGER_ID_SHAPE.test(c.id)) return false; + // The length cap belongs to the admission test, not only to the two + // slices: an over-long id admitted here is one the normalizer then CUTS, + // and a cut id is a different id — the entry silently changes identity + // between the round that posted it and the round that rules on it. + if (c.id.length > LEDGER_MAX_ID) return false; + // An id claiming a FUTURE round is a squat, not a finding. The pipeline's + // own ids obey `id round <= marker round` by construction — a round stamps + // its new findings `R-` and carries older ids forward — so a + // legitimate marker can never violate this. A foreign one can, and recovery + // reads foreign markers: a marker at round N carrying `R-*` ids would + // pre-claim exactly the prefix the next compose stamps, splitting one claim + // across two ids and renumbering every genuinely new finding past the + // squatted block. + // + // Bounded by the CAP as well as by the claimed round, because the claimed + // round is not always one the caller clamped: the side file's is whatever + // was written to it, and an unbounded id round is printed verbatim in a + // public body ("findings in round 100000000000000000000"). + const idRound = Number(c.id.slice(1).split('-')[0]); + if (!Number.isSafeInteger(idRound)) return false; + // Both ends. Rounds start at 1, so `R0-*` is not an id this pipeline can + // mint — but it passes the shape, and every reader that turns an id into a + // round rejects round 0 and then treats the rejection as "no carried id", + // i.e. as FRESH. Admitted, a re-posted `R0-1` counts as first-time work + // every round, and the trend narrates divergence at a fully settled steady + // state forever. + if (idRound < 1) return false; + if (idRound > Math.min(markerRound, LEDGER_MAX_ROUND)) return false; + return ( + (c.sev === 'C' || c.sev === 'S') && + typeof c.file === 'string' && + typeof c.title === 'string' && + (c.line === undefined || Number.isInteger(c.line)) + ); +} + +/** + * A finding normalised to the caps the serializer writes under. Applied on + * READ too: the caps are the serializer's contract, and neither a + * hand-edited marker nor a side file is bound by it. + */ +export function normalizeLedgerFinding(f: LedgerFinding): LedgerFinding { + const { k: _k, ...rest } = f; + return { + ...rest, + id: f.id.slice(0, LEDGER_MAX_ID), + title: f.title.slice(0, LEDGER_MAX_TITLE), + file: f.file.slice(0, LEDGER_MAX_FILE), + // Normalised to absent, never used to REJECT the entry. `k` is a + // clustering hint that decides nothing, and the marker is a + // cross-environment carrier by design: a later version adding a third + // kind, a hand edit, or a foreign marker would otherwise make every + // older CLI drop those findings from the work list — they would owe no + // Step 6 ruling and retire with nobody ruling on them. Its + // decides-nothing siblings (`posted`, `prevPosted`, `floor`) are all + // normalised the same way. + ...(f.k === 1 ? { k: 1 as const } : {}), + }; +} + /** * Parse the ledger out of a posted review body. Null on absence or ANY * malformation — the body is another account's writable surface, and a marker @@ -393,50 +592,40 @@ export function parseLedger(body: string | undefined): Ledger | null { return null; } if (!Array.isArray(raw.findings)) return null; - const valid = raw.findings.filter( - (f): f is LedgerFinding => - !!f && - typeof f.id === 'string' && - // An id claiming a FUTURE round is a squat, not a finding. The - // pipeline's own ids obey `id round <= marker round` by construction — - // a round stamps its new findings `R-` and carries older ids - // forward — so a legitimate marker can never violate this. A foreign - // one can, and recovery now reads foreign markers: a marker at round N - // carrying `R-*` ids would pre-claim exactly the prefix the next - // compose stamps, splitting one claim across two ids and renumbering - // every genuinely new finding past the squatted block. Read-side only, - // deliberately: the pipeline's one writer stamps - // `min(prevRound + 1, LEDGER_MAX_ROUND)` (compose-review), so its ids - // never exceed the round it writes — the coexisting round clamp below - // cannot reintroduce the mismatch this filter would then hide. - !(() => { - const m = /^R(\d+)-/.exec(f.id); - return m !== null && Number(m[1]) > raw.round; - })() && - (f.sev === 'C' || f.sev === 'S') && - typeof f.file === 'string' && - typeof f.title === 'string' && - (f.line === undefined || Number.isInteger(f.line)), + const valid = raw.findings.filter((f): f is LedgerFinding => + isLedgerFinding(f, raw.round), ); const findings = valid .slice(0, LEDGER_MAX_FINDINGS) - // Normalise on READ too: the caps are the serializer's contract, and a - // hand-edited marker is not bound by it. - .map((f) => ({ - ...f, - id: f.id.slice(0, LEDGER_MAX_ID), - title: f.title.slice(0, LEDGER_MAX_TITLE), - file: f.file.slice(0, LEDGER_MAX_FILE), - })); - const declared = - Number.isInteger(raw.dropped) && (raw.dropped as number) > 0 - ? (raw.dropped as number) - : 0; + .map(normalizeLedgerFinding); + // Clamped through the shared reader like every other number here. It + // was the one field admitted unbounded, and it is no longer internal: + // it renders into the model-facing PARTIAL line and publishes the + // "may be an undercount" caveat, so a forged `1e308` instructs the model + // to hedge about findings that never existed — and unlike a forged + // finding, a forged count cannot be re-ruled. + const declared = volumeOf(raw.dropped) ?? 0; // The count cap binds on READ as it does on write: valid entries this // parser sliced off ARE dropped findings, and a hand-edited marker whose // list was truncated here must not read as complete — nor keep an anchor // the serializer's own truncation path would have refused to certify. - const dropped = declared + (valid.length - findings.length) || undefined; + // Both losses count, not only the cap's. Entries this parser's own + // filter rejected are findings the next round will never rule on, and a + // list short by them must not read as complete — nor keep an anchor the + // serializer's truncation path would have refused to certify. The + // filter's share was uncounted while the reasons to reject were few and + // pipeline-impossible; it is now the larger share, and `dropped` is no + // longer internal — it publishes the "may be an undercount" caveat. + // The SUM is clamped, not merely the declared term: `raw.findings.length` + // is attacker-chosen (a body of ~32,700 single-character invalid entries + // fits GitHub's limit), and the total is interpolated verbatim into the + // model-facing PARTIAL line — a forged count instructing the model to + // hedge about findings that never existed, which unlike a forged finding + // cannot be re-ruled. + const rejected = raw.findings.length - valid.length; + const dropped = + volumeOf(declared + rejected + (valid.length - findings.length)) || + undefined; const sha = // Normalised on READ as the serializer holds on WRITE: a hand-edited // marker carrying both `dropped` and `sha` would certify a range its @@ -462,6 +651,21 @@ export function parseLedger(body: string | undefined): Ledger | null { // no gate reads. const posted = volumeOf(raw.posted); const prevPosted = volumeOf(raw.prevPosted); + // The floor qualifies `posted`, so it survives only beside it: a floor + // alone would let a later round compare postures across rounds whose + // volumes it does not have, which is not a comparison anyone can act on. + const floor = + posted !== undefined && (raw.floor === 'c' || raw.floor === 'o') + ? raw.floor + : undefined; + // Bounded by the total it is a part of: a "fresh" count exceeding the + // round's whole output is not a count of anything, and a forged one + // would let a marker's trend beat a real one's. + const freshRaw = posted === undefined ? undefined : volumeOf(raw.fresh); + const fresh = + freshRaw !== undefined && posted !== undefined && freshRaw <= posted + ? freshRaw + : undefined; return { v: 1, round: raw.round, @@ -471,6 +675,8 @@ export function parseLedger(body: string | undefined): Ledger | null { ...(model ? { model } : {}), ...(posted === undefined ? {} : { posted }), ...(prevPosted === undefined ? {} : { prevPosted }), + ...(floor === undefined ? {} : { floor }), + ...(fresh === undefined ? {} : { fresh }), }; } catch { return null; diff --git a/packages/cli/src/commands/review/lib/md-field.test.ts b/packages/cli/src/commands/review/lib/md-field.test.ts new file mode 100644 index 0000000000..7aa68eac7d --- /dev/null +++ b/packages/cli/src/commands/review/lib/md-field.test.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { mdField } from './md-field.js'; +import { parseLedger, stripLedgerMarker } from './ledger.js'; + +describe('mdField — a PR-controlled value, made inert', () => { + it('holds a value inside one code span', () => { + expect(mdField('src/a.ts')).toBe('`src/a.ts`'); + }); + + it('strips what would break the span or forge a line', () => { + expect(mdField('x`\n@acme/security approve')).toBe( + '`x @acme/security approve`', + ); + expect(mdField('a\r\nb')).toBe('`a b`'); + }); + + it('never emits a bare backtick run for a value that strips to nothing', () => { + // Git permits a filename that is nothing but backticks. Stripped, it + // leaves the empty string — and `` `` + `` `` `` is not two empty spans + // but ONE span whose content is everything the renderer wrote between + // them, so a planted filename re-renders the bot's own prose as code. + const emptied = mdField('`'); + expect(emptied).toBe('`(unnamed)`'); + expect(emptied).not.toBe('``'); + + // The shape the pairing needs: two such values in one paragraph. + const paragraph = `same files: ${mdField('`')} (round 2); ${mdField('``')} (round 3)`; + expect(paragraph.match(/`/g)).toHaveLength(4); + expect(paragraph).not.toContain('`` '); + }); + + it('breaks the ledger marker grammar, which the span does not neutralise', () => { + // The code span makes Markdown and HTML inert to a RENDERER. This + // pipeline's own readers scan the raw body: `stripLedgerMarker` takes + // the FIRST ` .sh'; + const out = mdField(hostile); + expect(out).not.toContain(''); + expect(out).toBe('`a qwen-review-ledger {"v":1,"round":9} .sh`'); + // The whole body still holds exactly one marker: the real one. + const body = `Some review prose.\n\n${out}\n\n`; + expect(body.split('`, deleting that round's prose AND + // its marker. A complete forged pair additionally parses as the + // recovered ledger on any round the real marker is missing. No + // legitimate value needs raw comment grammar inside a code span. + .replace(/[`\r\n]+|/g, ' ') + .trim(); + // A value that strips to nothing would emit a bare pair of backticks, which + // is not a code span at all: two such runs in one paragraph pair up as + // opener and closer, and the bot's own prose between them renders as code. + // Git permits a filename that is nothing but backticks, so the empty case + // is PR-controlled like every other input here. + return '`' + (inner === '' ? '(unnamed)' : inner) + '`'; +} diff --git a/packages/cli/src/commands/review/pr-context-persist.test.ts b/packages/cli/src/commands/review/pr-context-persist.test.ts index 39213c6619..07d752a41a 100644 --- a/packages/cli/src/commands/review/pr-context-persist.test.ts +++ b/packages/cli/src/commands/review/pr-context-persist.test.ts @@ -44,7 +44,13 @@ describe('persistRecoveredLedger', () => { try { persistRecoveredLedger( side, - { ledger, commitId: 'a'.repeat(40), reviewId: 42 }, + { + ledger, + commitId: 'a'.repeat(40), + reviewId: 42, + foreign: false, + merged: false, + }, { noOwnReview: true, identityKnown: true }, ); const written = JSON.parse(readFileSync(side, 'utf8')); @@ -52,6 +58,8 @@ describe('persistRecoveredLedger', () => { ...ledger, commitId: 'a'.repeat(40), reviewId: 42, + foreign: false, + merged: false, }); expect(written.sha).toBe('deadbeef00112233'); } finally { @@ -59,6 +67,258 @@ describe('persistRecoveredLedger', () => { } }); + it('records that the winning marker came from another account', () => { + // The convergence diagnosis CITES the round numbers carried in this work + // list, in a body this account posts. Recovery adopts the highest-round + // marker whoever posted it, so those rounds can be ones this account + // never ran — and the provenance is knowable only here, at the moment of + // recovery. Dropped on the way to disk, the citation goes out bare. + const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-')); + const side = join(dir, 'side.json'); + try { + persistRecoveredLedger( + side, + { ledger, commitId: null, reviewId: 9, foreign: true, merged: false }, + { noOwnReview: false, identityKnown: true }, + ); + expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('keeps disclosing foreign provenance while the work list carries it', () => { + // Step 6 re-posts still-standing entries under their ORIGINAL ids, so a + // foreign round's entries — and the round numbers a cluster cites off + // them — survive into this account's own next marker. Recomputed from + // the winning review's author alone, the flag flips false after exactly + // one round and the caveat vanishes while the citations remain. + const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-')); + const side = join(dir, 'side.json'); + try { + persistRecoveredLedger( + side, + { + ledger: { ...ledger, round: 5 }, + commitId: null, + reviewId: 9, + foreign: true, + merged: false, + }, + { noOwnReview: false, identityKnown: true }, + ); + expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(true); + // Next round recovers this account's OWN marker, still carrying the + // foreign-minted ids. + persistRecoveredLedger( + side, + { + ledger: { ...ledger, round: 6 }, + commitId: null, + reviewId: 10, + foreign: false, + merged: false, + }, + { noOwnReview: false, identityKnown: true }, + ); + expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(true); + // It clears when the list empties — the point at which no carried id + // can still name a round this account never ran. + persistRecoveredLedger( + side, + { + ledger: { v: 1, round: 7, findings: [] }, + commitId: null, + reviewId: 11, + foreign: false, + merged: false, + }, + { noOwnReview: false, identityKnown: true }, + ); + expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('records whether the foreign winner was merged over own entries', () => { + // The union restores this account's own certified entries under their own + // ids. Without this flag the side file cannot tell a pure-foreign list + // from an own+foreign one, and the next body says a predominantly own + // work list "may not be this account's own". + const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-')); + const side = join(dir, 'side.json'); + try { + persistRecoveredLedger( + side, + { + ledger, + commitId: null, + reviewId: 9, + foreign: true, + merged: true, + }, + { noOwnReview: false, identityKnown: true }, + ); + expect(JSON.parse(readFileSync(side, 'utf8')).merged).toBe(true); + // Sticky across the next OWN recovery, for the same reason `foreign` + // is: Step 6 re-posts the merged entries under their original ids. + persistRecoveredLedger( + side, + { + ledger: { ...ledger, round: 4 }, + commitId: null, + reviewId: 10, + foreign: false, + merged: false, + }, + { noOwnReview: false, identityKnown: true }, + ); + expect(JSON.parse(readFileSync(side, 'utf8')).merged).toBe(true); + // And it clears when the list empties, the same conjunct `foreign` + // carries — nothing merged can still be in a list holding nothing. + persistRecoveredLedger( + side, + { + ledger: { v: 1, round: 5, findings: [] }, + commitId: null, + reviewId: 11, + foreign: false, + merged: false, + }, + { noOwnReview: false, identityKnown: true }, + ); + expect(JSON.parse(readFileSync(side, 'utf8')).merged).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('an ANONYMOUS advance keeps the provenance of the list it keeps', () => { + // This branch advances only the COUNTER; the work list is kept verbatim, + // so the flags describing that list are not stale — they were vouched + // under a known identity and the ids they qualify are still in the file. + // Zeroing `foreign` here broke the sticky clause: no later + // identity-known round could re-fire it, and the caveat vanished while + // the citations remained. + const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-')); + const side = join(dir, 'side.json'); + try { + writeFileSync( + side, + JSON.stringify({ + ...ledger, + round: 5, + reviewId: 50, + model: 'qwen3.7-max@1a2b3c4d', + foreign: true, + merged: true, + }), + ); + persistRecoveredLedger( + side, + { + ledger: { ...ledger, round: 6 }, + commitId: null, + reviewId: 60, + // FALSE in the input, true in the file: the assertion below then + // proves the flag came from the kept list rather than being + // echoed back. (Production feeds `true` here — without a `me` + // every marker walks as foreign — which is exactly the value that + // must not be stamped over this account's own certified list.) + foreign: false, + merged: false, + }, + { noOwnReview: false, identityKnown: false }, + ); + const written = JSON.parse(readFileSync(side, 'utf8')); + expect(written.round).toBe(6); + expect(written.foreign).toBe(true); + expect(written.merged).toBe(true); + // The anchor PAIR goes together here as at every other seam: a model + // left behind names a certifier for a range that is gone. + expect(written.sha).toBeUndefined(); + expect(written.model).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a pure-foreign recovery cannot inherit a merged claim', () => { + // `mergedOverOwn` is false when there was nothing to merge — an own + // marker deleted, unparseable, or absent from the walk. Inheriting the + // flag there makes the rendered caveat claim own-certified entries exist + // when every entry is a stranger's. + const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-')); + const side = join(dir, 'side.json'); + try { + writeFileSync( + side, + JSON.stringify({ ...ledger, round: 5, foreign: true, merged: true }), + ); + persistRecoveredLedger( + side, + { + ledger: { + v: 1, + round: 6, + findings: [{ id: 'R6-1', sev: 'S', file: 'theirs.ts', title: 't' }], + }, + commitId: null, + reviewId: 60, + foreign: true, + merged: false, + }, + { noOwnReview: false, identityKnown: true }, + ); + const written = JSON.parse(readFileSync(side, 'utf8')); + expect(written.foreign).toBe(true); + expect(written.merged).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('does not make an EMPTY prior list sticky — nothing could be carried', () => { + // A stranger's empty LGTM marker adopted before this account's first + // finding recorded `foreign: true` over a list holding nothing. Keyed on + // the NEW list's length, the flag then re-fired forever over a provably + // all-own work list — and the cost is mechanical as well as prose: the + // cluster sort drops its depth key over a list with zero fabrication + // risk. + const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-')); + const side = join(dir, 'side.json'); + try { + persistRecoveredLedger( + side, + { + ledger: { v: 1, round: 1, findings: [] }, + commitId: null, + reviewId: 10, + foreign: true, + merged: false, + }, + { noOwnReview: false, identityKnown: true }, + ); + expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(true); + // This account's own round 2, with findings of its own. + persistRecoveredLedger( + side, + { + ledger: { ...ledger, round: 2 }, + commitId: null, + reviewId: 20, + foreign: false, + merged: false, + }, + { noOwnReview: false, identityKnown: true }, + ); + expect(JSON.parse(readFileSync(side, 'utf8')).foreign).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('a recovery that THREW strips the age reference but keeps round and sha', () => { // A transient failure must not reset the id space or lose the anchor; // it must also not keep an age reference this run could not re-vouch — @@ -82,6 +342,8 @@ describe('persistRecoveredLedger', () => { // exactly the rounds this path exists to protect. posted: 4, prevPosted: 2, + fresh: 3, + floor: 'c', }), ); persistRecoveredLedger(side, null, { @@ -89,7 +351,13 @@ describe('persistRecoveredLedger', () => { identityKnown: true, }); const written = JSON.parse(readFileSync(side, 'utf8')); - expect(written).toEqual({ ...ledger, posted: 4, prevPosted: 2 }); + expect(written).toEqual({ + ...ledger, + posted: 4, + prevPosted: 2, + fresh: 3, + floor: 'c', + }); expect(written.round).toBe(3); expect(written.sha).toBe('deadbeef00112233'); } finally { @@ -97,6 +365,35 @@ describe('persistRecoveredLedger', () => { } }); + it('carries the volume group through the ordinary recovered write', () => { + // The common path own volumes reach disk. The DROP is pinned at the + // anonymous seam and the KEEP at the threw-strip seam, but survival on + // a successful recovery held only by construction — and "harmonize the + // seams" is a plausible follow-up now that the group is one list. + const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-')); + const side = join(dir, 'side.json'); + try { + persistRecoveredLedger( + side, + { + ledger: { ...ledger, posted: 4, prevPosted: 2, fresh: 3, floor: 'c' }, + commitId: null, + reviewId: 42, + foreign: false, + merged: false, + }, + { noOwnReview: false, identityKnown: true }, + ); + const written = JSON.parse(readFileSync(side, 'utf8')); + expect(written.posted).toBe(4); + expect(written.prevPosted).toBe(2); + expect(written.fresh).toBe(3); + expect(written.floor).toBe('c'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('proven absence REMOVES the stale file whole', () => { // The PR demonstrably holds no prior round for this account (a walked // list with no own submitted review) — another account's round counter @@ -135,6 +432,8 @@ describe('persistRecoveredLedger', () => { ledger: { ...ledger, round: 2 }, commitId: 'a'.repeat(40), reviewId: 20, + foreign: false, + merged: false, }, { noOwnReview: false, identityKnown: true }, ); @@ -142,14 +441,26 @@ describe('persistRecoveredLedger', () => { // Same round, older reviewId: also kept. persistRecoveredLedger( side, - { ledger: { ...ledger, round: 7 }, commitId: null, reviewId: 60 }, + { + ledger: { ...ledger, round: 7 }, + commitId: null, + reviewId: 60, + foreign: false, + merged: false, + }, { noOwnReview: false, identityKnown: true }, ); expect(JSON.parse(readFileSync(side, 'utf8'))).toEqual(newer); // A genuinely newer recovery still writes. persistRecoveredLedger( side, - { ledger: { ...ledger, round: 8 }, commitId: null, reviewId: 80 }, + { + ledger: { ...ledger, round: 8 }, + commitId: null, + reviewId: 80, + foreign: false, + merged: false, + }, { noOwnReview: false, identityKnown: true }, ); expect(JSON.parse(readFileSync(side, 'utf8')).round).toBe(8); @@ -198,6 +509,8 @@ describe('persistRecoveredLedger', () => { }, commitId: 'c'.repeat(40), reviewId: 101, + foreign: false, + merged: false, }, { noOwnReview: false, identityKnown: false }, ); @@ -226,13 +539,18 @@ describe('persistRecoveredLedger', () => { round: 7, reviewId: 100, commitId: 'b'.repeat(40), - // The volumes belong to round 7. This branch advances the counter - // past it, so they must go the way the anchor and the age - // reference go — kept, they would attribute this account's round-7 + // The volume group belongs to round 7. This branch advances the + // counter past it, so it must go the way the anchor and the age + // reference go — kept, it would attribute this account's round-7 // posting count to the foreign round that won recovery, and the - // next compose would stamp it as `prevPosted`. + // next compose would stamp it as `prevPosted`. The floor and the + // fresh count qualify that volume, so they go with it: a posture + // recorded for a round whose volume was deliberately discarded + // qualifies nothing. posted: 4, prevPosted: 2, + fresh: 3, + floor: 'c', }), ); persistRecoveredLedger( @@ -246,6 +564,8 @@ describe('persistRecoveredLedger', () => { }, commitId: 'c'.repeat(40), reviewId: 200, + foreign: false, + merged: false, }, { noOwnReview: true, identityKnown: false }, ); @@ -264,6 +584,9 @@ describe('persistRecoveredLedger', () => { }); it('an ANONYMOUS recovery with no existing file still writes whole', () => { + // Production shape: without a `me` every marker walks as foreign, this + // account's own included, so the recorded provenance must be "unknown" + // rather than "another account's". // Nothing to protect: a machine with no side file gains round context // from the write, and the list it gains is exactly what a healthy // foreign-only recovery would have handed it — THEIR claims, no anchor. @@ -272,12 +595,39 @@ describe('persistRecoveredLedger', () => { try { persistRecoveredLedger( side, - { ledger: { ...ledger, round: 4 }, commitId: null, reviewId: 40 }, + { + ledger: { + ...ledger, + round: 4, + posted: 7, + prevPosted: 3, + fresh: 4, + floor: 'c', + }, + commitId: null, + reviewId: 40, + // What recovery actually hands this branch anonymously. + foreign: true, + merged: true, + }, { noOwnReview: false, identityKnown: false }, ); const written = JSON.parse(readFileSync(side, 'utf8')); expect(written.round).toBe(4); expect(written.findings).toEqual(ledger.findings); + // An unknown identity is not a foreign author: recorded `true`, the + // next round publishes the foreign caveat about a marker this account + // may well have posted. + expect(written.foreign).toBe(false); + expect(written.merged).toBe(false); + // ...but it cannot VOUCH for the volume either. Without a `me` every + // marker walks as foreign, so the upstream strip never fires and any + // marker inside the headroom wins — kept, a stranger's counts become + // this loop's baseline and are stamped into the next own marker. + expect(written.posted).toBeUndefined(); + expect(written.prevPosted).toBeUndefined(); + expect(written.fresh).toBeUndefined(); + expect(written.floor).toBeUndefined(); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/packages/cli/src/commands/review/pr-context.test.ts b/packages/cli/src/commands/review/pr-context.test.ts index a685e6e6be..5aca57a313 100644 --- a/packages/cli/src/commands/review/pr-context.test.ts +++ b/packages/cli/src/commands/review/pr-context.test.ts @@ -1500,17 +1500,157 @@ describe('latestLedger — the split trust surface', () => { '{"id":"R6-1","sev":"S","file":"e.ts","title":"deep squat"},' + '{"id":"R3-1","sev":"C","file":"b.ts","title":"own"},' + '{"id":"R1-2","sev":"S","file":"c.ts","title":"carried"},' + - '{"id":"f7","sev":"S","file":"d.ts","title":"non-pipeline id"}' + + // Admission is the WHOLE grammar, so an id the pipeline's own writer + // could never emit does not ride either: `idFor` reuses only ids read + // back through `LEDGER_ID_READBACK` and otherwise stamps + // `R-`, so a non-conforming id is a hand-edited or foreign + // entry by construction. + '{"id":"f7","sev":"S","file":"d.ts","title":"non-pipeline id"},' + + // The bypass the whole-shape test closes: every reader downstream + // TRIMS before matching, so a leading space made this id invisible to + // the untrimmed squat rule above and fully effective everywhere else — + // pre-claiming a future round's prefix, and citing round 9999 in a + // convergence paragraph this account posts. + '{"id":" R9999-1","sev":"S","file":"f.ts","title":"whitespace squat"}' + ']} -->'; const found = latestLedger( [review('stranger', '2026-01-09T00:00:00Z', squatting)], 'bot', ); - expect(found?.ledger.findings.map((f) => f.id)).toEqual([ - 'R3-1', - 'R1-2', - 'f7', - ]); + expect(found?.ledger.findings.map((f) => f.id)).toEqual(['R3-1', 'R1-2']); + }); + + it("drops the volume telemetry from another account's marker", () => { + // `posted` is the baseline the next round's trend is measured against, + // and a foreign one is a number a stranger chose — with leverage both + // ways: `posted: 1` makes every following round read as "not falling", + // `posted: 100000` suppresses the signal for as long as the marker + // stands. It goes the way the anchor goes, and the floor goes with it + // because it qualifies nothing else. + const foreign = + 'x '; + const found = latestLedger( + [review('stranger', '2026-01-09T00:00:00Z', foreign)], + 'bot', + ); + expect(found?.foreign).toBe(true); + expect(found?.ledger.posted).toBeUndefined(); + expect(found?.ledger.prevPosted).toBeUndefined(); + expect(found?.ledger.floor).toBeUndefined(); + // The work list still rides — it is re-ruled entry by entry, which a + // bare number cannot be. + expect(found?.ledger.findings.map((f) => f.id)).toEqual(['R9-1']); + }); + + it('keeps the volume when the identity lookup is what failed', () => { + // Without a `me` EVERY marker walks as foreign, this account's own + // included. Stripping the volume on that reading let one blip in + // `gh api user` break this account's own trend chain for two rounds. The + // anchor still goes — a drive-by anchor must not decide which lines this + // pipeline stops looking at — but a number nobody can attribute is not + // the same as a number somebody else chose. + const own = + 'x '; + const anonymous = latestLedger( + [review('maintainer', '2026-01-09T00:00:00Z', own)], + null, + ); + expect(anonymous?.foreign).toBe(true); + expect(anonymous?.ledger.posted).toBe(4); + expect(anonymous?.ledger.fresh).toBe(3); + expect(anonymous?.ledger.floor).toBe('c'); + expect(anonymous?.ledger.sha).toBeUndefined(); + }); + + it("restores this account's own volume when it restores its own findings", () => { + // The union exists so a foreign marker cannot erase own data, and the + // volume is own data: this account's own marker is walked in the same + // pass. Restoring only `findings` let any second bot posting one + // parseable marker at a round at-or-above this account's blind the + // trend for that round with a good count in hand. + const own = + 'x '; + // The foreign counts are a SUPERSET of the own ones in every field, so + // a strip that silently failed would be indistinguishable from one that + // worked if the own values happened to win a comparison — they are + // restored wholesale, and these numbers make the difference visible. + const foreign = + 'y '; + const found = latestLedger( + [ + review('bot', '2026-01-01T00:00:00Z', own), + review('stranger', '2026-01-02T00:00:00Z', foreign), + ], + 'bot', + ); + expect(found?.merged).toBe(true); + expect(found?.ledger.posted).toBe(6); + expect(found?.ledger.fresh).toBe(4); + expect(found?.ledger.floor).toBe('c'); + // The foreign numbers are gone, not merely outranked. + expect(found?.ledger.prevPosted).toBeUndefined(); + }); + + it('restores an own TRUE-ZERO volume even with nothing to merge', () => { + // A clean own round — LGTM, findings empty, `posted: 0` — has a real + // baseline, and zero survives the persistence chain precisely so it can + // be one. Gated on the list, any stranger's parseable marker blinded the + // trend for that round with a good count in hand. + const own = + 'LGTM '; + const foreign = + 'y '; + const found = latestLedger( + [ + review('bot', '2026-01-01T00:00:00Z', own), + review('stranger', '2026-01-02T00:00:00Z', foreign), + ], + 'bot', + ); + // Nothing merged — there was no own list — but the own counts came back. + expect(found?.merged).toBe(false); + expect(found?.ledger.posted).toBe(0); + expect(found?.ledger.fresh).toBe(0); + expect(found?.ledger.floor).toBe('o'); + }); + + it('will not pair own counts with a round the own marker does not describe', () => { + // The side file pairs ONE round number with ONE set of counts. Spread + // onto a higher-round winner, own round-7 numbers are attributed to a + // round this account never ran — and the next body says "the previous + // round posted 0 (0 new)" in the same paragraph as a cluster citing + // round 8, which plainly did post. + const own = + 'LGTM '; + const foreign = + 'y '; + const found = latestLedger( + [ + review('bot', '2026-01-01T00:00:00Z', own), + review('stranger', '2026-01-02T00:00:00Z', foreign), + ], + 'bot', + ); + expect(found?.ledger.round).toBe(8); + // The stranger's counts are stripped and the own ones are not adopted: + // absence already reads as "not recorded", which beats a wrong pairing. + expect(found?.ledger.posted).toBeUndefined(); + expect(found?.ledger.fresh).toBeUndefined(); + expect(found?.ledger.floor).toBeUndefined(); }); it('merges a foreign winner OVER the own findings — displacement is dead', () => { diff --git a/packages/cli/src/commands/review/pr-context.ts b/packages/cli/src/commands/review/pr-context.ts index 16cf3b7cff..a83c1acc74 100644 --- a/packages/cli/src/commands/review/pr-context.ts +++ b/packages/cli/src/commands/review/pr-context.ts @@ -751,6 +751,17 @@ const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/; export interface RecoveredLedger { ledger: Ledger; commitId: string | null; + /** + * The winning marker was posted by another account. Recovery adopts the + * highest-round marker whoever posted it (bounded by + * `FOREIGN_ROUND_HEADROOM`), so a work list can carry rounds this account + * never ran — and the convergence diagnosis CITES those round numbers in a + * body this account posts. Persisted beside the list so the citation can + * disclose where it came from instead of publishing it bare. + */ + foreign: boolean; + /** That foreign winner was merged over this account's own findings. */ + merged: boolean; /** * The winning review's own id — persisted so Step 6 can find WHICH body's * not-reviewed disclosures bind the code-age rule: with several summaries @@ -934,7 +945,16 @@ export function recoverLedger( if (!best) return { recovered: null, sawOwnReview }; // The anchor never crosses accounts. Dropped here, at the recovery seam, so // no consumer downstream has to remember the rule. + // The anchor is stripped whenever the winner is foreign, INCLUDING the + // anonymous case: without a `me` every marker walks as foreign, and a + // drive-by anchor must not decide which lines this pipeline stops looking + // at. The volume is different. `foreign` there means "another account + // chose this number", and on an anonymous walk it means only "this run + // could not ask who". Stripping on that reading let one blip in + // `gh api user` break this account's own trend chain for two rounds — and + // record its own marker as a stranger's. let ledger = best.foreign ? stripAnchor(best.ledger) : best.ledger; + if (me && best.foreign) ledger = stripForeignVolume(ledger); // A FOREIGN winner never DISPLACES this account's own findings — it is // merged over them. Round-first selection alone handed a drive-by poster a // one-comment suppression: a marker at `ownMax + 1` (deep inside the @@ -955,6 +975,30 @@ export function recoverLedger( // shape `merged` made the provenance wording claim own-certified entries // exist when none do (and misattributed the PARTIAL note's sum). The // foreign winner recovers as pure-foreign, which is exactly what it is. + if (best.foreign && bestOwn) { + // The volume comes back whether or not there is a LIST to merge. The + // union exists so a foreign marker cannot erase own data, and the + // volume is own data too: this account's own marker was walked in the + // same pass and its counts are trustworthy. Gated on the list, an own + // round that posted nothing — a clean LGTM, findings empty, `posted: 0` + // — lost its true-zero baseline to any stranger's parseable marker, and + // zero survives the whole persistence chain precisely so it can be one. + // Derived from the shared list, not re-enumerated — see `pickVolume`. + // ...but ONLY when the own marker describes the SAME round the winner + // does. The side file pairs one round number with one set of counts, so + // spreading round M's counts onto a round-N winner attributes own + // numbers to a round this account never ran — and the next body says + // "the previous round posted 0 (0 new)" in the same paragraph as a + // cluster citing round N, which plainly did post. A round the counts do + // not describe is worse than no counts: absence already reads as "not + // recorded". + if (bestOwn.ledger.round === ledger.round) { + ledger = { + ...ledger, + ...pickVolume(bestOwn.ledger as unknown as Record), + }; + } + } if (best.foreign && bestOwn && bestOwn.ledger.findings.length > 0) { mergedOverOwn = true; const ownIds = new Set(bestOwn.ledger.findings.map((f) => f.id)); @@ -1158,11 +1202,16 @@ export function persistRecoveredLedger( // exactly as a pre-telemetry predecessor does. const { sha: _droppedSha, + // The PAIR, as everywhere else: a `model` left behind says a round + // was certified by someone while the range it certified is gone. + // This was the one seam where they did not fall together. + model: _droppedModel, commitId: _droppedCommitId, - posted: _droppedPosted, - prevPosted: _droppedPrevPosted, - ...kept + ...rest } = existing; + // Through the shared projection, not a second hand-kept list: the + // volume group grew twice and this branch was updated neither time. + const kept = withoutVolume(rest); mkdirSync(dirname(sideFilePath), { recursive: true }); writeAtomic( JSON.stringify( @@ -1170,6 +1219,18 @@ export function persistRecoveredLedger( ...kept, round: recovered.ledger.round, reviewId: recovered.reviewId, + // Both provenance flags ride with `...kept`, deliberately + // unwritten here. This branch advances only the COUNTER; the + // work list it describes is kept verbatim, so the flags that + // describe that list are not stale — they were vouched under a + // known identity, and the ids they qualify are still in the + // file. Zeroing `foreign` here broke the sticky clause the + // recovered branch establishes: no later identity-known round + // could re-fire it, and the caveat vanished while the + // citations remained. (What this run genuinely cannot vouch — + // the anchor, the age reference, the volume group — is + // stripped above, because each is a fact about a specific + // round and this write advances past it.) }, null, 2, @@ -1178,12 +1239,81 @@ export function persistRecoveredLedger( return; } mkdirSync(dirname(sideFilePath), { recursive: true }); + // An ANONYMOUS recovery cannot vouch for the volume it adopts. Without + // a `me` every marker walks as foreign, so the upstream strip + // (`if (me && best.foreign)`) never fires and `ownMax` is 0 — any + // marker inside the headroom wins. Kept, a stranger's counts become + // this loop's baseline: the trend evaluates against them, the + // paragraph cites them as own history, and they are stamped into this + // account's own next marker as `prevPosted`, which later recovery + // trusts. The counter-advance branch already sheds the group for this + // exact reason; this seam takes the same "not recorded" degradation. + const recoveredOut = identityKnown + ? recovered.ledger + : (withoutVolume( + recovered.ledger as unknown as Record, + ) as unknown as Ledger); writeAtomic( JSON.stringify( { - ...recovered.ledger, + ...recoveredOut, ...(recovered.commitId ? { commitId: recovered.commitId } : {}), reviewId: recovered.reviewId, + // Provenance travels WITH the list it describes. Written even + // when false, so the field's absence means only "a version + // before this wrote the file" — which degrades to no disclosure, + // the same reading a pre-telemetry predecessor already gets. + // + // Two qualifications on the value: + // + // - An UNKNOWN identity is not a foreign author. Without a `me` + // every marker walks as foreign, so recording `true` there + // publishes a caveat about a marker this account may well have + // posted. + // - It is STICKY while the work list is non-empty. Step 6 + // re-posts still-standing entries under their ORIGINAL ids, so + // a foreign round's entries — and the round numbers a cluster + // cites off them — survive into this account's own next + // marker, where recovery would compute `foreign: false` and + // the caveat would vanish while the citations remained. It + // clears when the list empties, which is the point at which no + // carried id can still name a round this account never ran. + // That over-discloses on a list whose foreign entries are gone + // but whose own entries are not; over-disclosing a caveat is + // the safe direction. + // Keyed on the PREVIOUS list — the one that could carry an id + // forward — not on the new one. An empty prior list can carry + // nothing, so re-firing the flag off the new list's length + // stamped a provably all-own work list foreign forever: one + // stranger's empty LGTM marker adopted before this account's + // first finding was enough, and the cost is mechanical as well + // as prose — `trustDepth` drops the depth key over a list with + // zero fabrication risk. + foreign: + (identityKnown && recovered.foreign) || + (existing?.['foreign'] === true && + Array.isArray(existing['findings']) && + (existing['findings'] as unknown[]).length > 0 && + recovered.ledger.findings.length > 0), + // Whether that foreign winner was MERGED over this account's own + // findings. `renderLedgerSection` already draws this line for the + // model ("entries this account certified are its own claims"); + // dropped on the way to disk, the posted caveat could not, and + // said a predominantly own work list "may not be this account's + // own". + // The sticky term is conditioned on the winner NOT being foreign. + // A foreign marker winning while this account's own list is + // absent, deleted, or unparseable writes a PURE-foreign list — + // `mergedOverOwn` is false precisely because there was nothing to + // merge — and inheriting `merged` there makes the rendered + // caveat claim own-certified entries exist when every entry is a + // stranger's. The union guard one function up refuses to flag + // that same shape for the same reason. + merged: + (identityKnown && recovered.merged) || + (!recovered.foreign && + existing?.['merged'] === true && + recovered.ledger.findings.length > 0), }, null, 2, @@ -1236,6 +1366,69 @@ function stripAnchor(ledger: Ledger): Ledger { return rest; } +/** + * Drop the volume telemetry from a marker another account posted. + * + * The same reasoning as the anchor, applied to the other cross-account field: + * `posted` is the baseline the next round's volume trend is measured against, + * so a foreign value is not this loop's history — it is a number a stranger + * chose. And it is a number with leverage in BOTH directions: `posted: 1` + * makes every following round with any volume read as "not falling", while + * `posted: 100000` suppresses the signal for as long as the marker stands. + * Dropped rather than carried-and-disclosed, because unlike the work list + * there is nothing here for a reader to re-rule on: a volume is a single + * number with no evidence attached. Absence already reads as "not recorded", + * which degrades the trend exactly as a pre-telemetry predecessor does. The + * floor goes with it — it qualifies the volume and nothing else. + */ +export const VOLUME_FIELDS = [ + 'posted', + 'prevPosted', + 'fresh', + 'floor', +] as const; + +/** + * Drop the whole volume group from a record, whatever shape it is in. + * + * ONE list, because there are two seams that must shed it — this one and the + * anonymous-recovery branch that rewrites the side file by hand — and a + * hand-kept field list on each is how `floor` came to be shed at one seam + * and kept at the other, recorded for a round whose volume had been + * deliberately discarded. + */ +export function withoutVolume>(record: T): T { + const out = { ...record }; + for (const field of VOLUME_FIELDS) delete out[field]; + return out; +} + +/** + * The volume group PRESENT in a record — the restore half of the same list. + * + * The union that protects own findings from a foreign winner has to put the + * own volume back, and hand-enumerating it there was a third copy of the + * list `withoutVolume` exists to be the only one of. A field added to the + * group would otherwise be stripped from the foreign winner and never + * restored, losing the own data point on exactly the merged rounds the + * branch protects. + */ +export function pickVolume( + record: Record, +): Record { + const out: Record = {}; + for (const field of VOLUME_FIELDS) { + if (record[field] !== undefined) out[field] = record[field]; + } + return out; +} + +function stripForeignVolume(ledger: Ledger): Ledger { + return withoutVolume( + ledger as unknown as Record, + ) as unknown as Ledger; +} + /** * Whether the recovered anchor may scope this round, and the routing that * follows from it — computed here, for the reason `renderLedgerSection` diff --git a/packages/cli/src/commands/review/presubmit.ts b/packages/cli/src/commands/review/presubmit.ts index 1249a0e1df..a77ecca527 100644 --- a/packages/cli/src/commands/review/presubmit.ts +++ b/packages/cli/src/commands/review/presubmit.ts @@ -27,7 +27,11 @@ import { severityOf, } from './lib/inline-counts.js'; import { carriesCommentMarker } from './lib/review-footer.js'; -import { LEDGER_ID_READBACK, LEDGER_ID_TOKEN } from './lib/ledger.js'; +import { + LEDGER_ID_READBACK, + LEDGER_ID_SHAPE, + LEDGER_ID_TOKEN, +} from './lib/ledger.js'; interface FindingAnchor { path: string; @@ -70,8 +74,6 @@ interface CommentSummary { matchedIds?: string[]; } -/** Exact-shape check for ids read from the --new-findings file. */ -const LEDGER_ID_SHAPE = new RegExp(`^${LEDGER_ID_TOKEN}$`); /** The carried id this comment's claim line leads with, if any. */ function extractCarriedIds(body: string): string[] { let line = carriedClaimLine(body); diff --git a/packages/cli/src/commands/review/save-artifact.test.ts b/packages/cli/src/commands/review/save-artifact.test.ts index 4abf7781ae..5dc0198029 100644 --- a/packages/cli/src/commands/review/save-artifact.test.ts +++ b/packages/cli/src/commands/review/save-artifact.test.ts @@ -449,6 +449,40 @@ describe('saveReviewArtifact', () => { ).toBe(0); }); + it('carries the fresh count and the convergence paragraph into the artifact', () => { + // Both are new surfaces on the composed result, and the allow-list is + // where a new field silently stops existing. The paragraph matters most: + // it is the FIRST clause the overflow ladder sheds, so on the rounds it + // fires the artifact may be the only durable copy. + const paths = fixture(); + writeJson(paths.composed, { + ...verdict, + postedFresh: 2, + convergence: { en: 'Convergence: …', zh: '收敛情况:…' }, + }); + saveReviewArtifact({ ...paths, target: 'local', effort: 'medium' }); + const saved = JSON.parse(readFileSync(paths.out, 'utf8')); + expect(saved.verdict.postedFresh).toBe(2); + expect(saved.verdict.convergence.en).toBe('Convergence: …'); + expect(saved.verdict.convergence.zh).toBe('收敛情况:…'); + }); + + it('PRESERVES an absent postedFresh and refuses a present one of the wrong shape', () => { + // Same distinction as its sibling: a round that recorded no fresh count + // is not a round that produced none. + const paths = fixture(); + saveReviewArtifact({ ...paths, target: 'local', effort: 'medium' }); + expect( + 'postedFresh' in JSON.parse(readFileSync(paths.out, 'utf8')).verdict, + ).toBe(false); + rmSync(paths.out, { force: true }); + + writeJson(paths.composed, { ...verdict, postedFresh: -1 }); + expect(() => + saveReviewArtifact({ ...paths, target: 'local', effort: 'medium' }), + ).toThrow(/postedFresh/); + }); + it('reads an absent or null floorEnforced as empty — a pre-enforcement composed file must still save', () => { // Null rides the same absence semantics as the sibling deferredCount // pair — an undefined-only check would refuse a composed file that diff --git a/packages/cli/src/commands/review/save-artifact.ts b/packages/cli/src/commands/review/save-artifact.ts index 703f6ca3b0..ebf6322a83 100644 --- a/packages/cli/src/commands/review/save-artifact.ts +++ b/packages/cli/src/commands/review/save-artifact.ts @@ -36,7 +36,10 @@ import { volumeOf } from './lib/ledger.js'; import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; interface PersistedVerdict - extends Omit { + extends Omit< + ComposeReviewResult, + 'postedInline' | 'postedFresh' | 'prevPostedInline' + > { verdictLine: string; /** * Optional HERE, required on the composed result it is otherwise a copy @@ -51,6 +54,13 @@ interface PersistedVerdict * recoverable from the marker chain inside `body`. */ postedInline?: number; + /** + * Optional for the same reason as its sibling, and for one more: an + * artifact written before the convergence trend measured NEW findings + * carries only the total. Absence is preserved rather than defaulted — + * a round that recorded no fresh count is not a round that produced none. + */ + postedFresh?: number; } export interface ReviewArtifactV1 { @@ -282,6 +292,29 @@ function validateVerdict(value: unknown): PersistedVerdict { 'Composed verdict.postedInline must be a non-negative integer.', ); } + // The fresh count reads by the same rules as the total it is part of. + // The convergence paragraph is the ONE clause the overflow ladder sheds + // first, and the artifact is where a trimmed round's record lives. Dropped + // by this allow-list, the durable record of a round whose body shed it + // held neither copy. + const rawConvergence = verdict['convergence']; + let convergence: { en: string; zh: string } | undefined; + if (rawConvergence !== undefined && rawConvergence !== null) { + const c = object(rawConvergence, 'Composed verdict.convergence'); + convergence = { + en: string(c['en'], 'Composed verdict.convergence.en'), + zh: string(c['zh'], 'Composed verdict.convergence.zh'), + }; + } + // The fresh count reads by the same rules as the total it is part of. + const rawFresh = verdict['postedFresh']; + const freshAbsent = rawFresh === undefined || rawFresh === null; + const postedFresh = freshAbsent ? undefined : volumeOf(rawFresh); + if (!freshAbsent && postedFresh === undefined) { + throw new Error( + 'Composed verdict.postedFresh must be a non-negative integer.', + ); + } // Absent reads as "no trim", the same absence semantics the sibling count // gets: a composed file written before the body budget shipped carries no // `bodyTrim`, and a mid-upgrade save must not fail over a record of @@ -332,6 +365,8 @@ function validateVerdict(value: unknown): PersistedVerdict { deferredCount, floorEnforced: floorEnforced as number[], ...(postedInline === undefined ? {} : { postedInline }), + ...(postedFresh === undefined ? {} : { postedFresh }), + ...(convergence === undefined ? {} : { convergence }), lowSignal: lowSignal === null ? null From 9f2342d32377d5983cb11fbc42514fa4d82baed7 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 20 Aug 2026 23:51:05 +0000 Subject: [PATCH 2/4] fix(ci): stop the fallback comment from denying a review it already posted (#9462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): stop the fallback comment from denying a review it already posted The review job can fail AFTER posting its review — the CLI exiting silently, a cleanup step dying — and both fallback sites then announce that review as one that could not be posted, retry instruction attached. Measured on PR #9342: the review posted at 11:56:34Z, review-pr failed at 12:00:53Z ("Qwen review completed but produced no output"), and the comment landed at 12:01:00Z saying the pipeline "failed before a review could be posted. … retry with @qwen-code /review" — a fresh ~3-hour review, asked for beside the review that had just landed. The autofix takeover loop reads the same feed a human does. Both sites now check, before composing a body, whether a review this run posted is already on the PR. The check is scoped three ways so a stale review can never buy silence on a genuinely dead pipeline: the bot's own account, the head this run reviewed, and a submission at or after this run started. Where the proof is unavailable — no start time, no head, a failed listing — the guard declines to fire and the comment posts, the same call the head-moved guard already makes. The job-level step now reads state and headRefOid in one `gh pr view` (the in-job step already did), which is where its head value comes from. Tests run the steps' real bash over review fixtures, because the guard IS a filter: silence when this run posted the review, and posting for each near-miss on its own — an earlier run's review at the same head, another account's, one of a different head, a PENDING one, none at all, an unavailable start time, and a failed reviews listing. One existing assertion tightened: "no `gh run view`" was the proxy for "no head comparison on comment runs", and the new guard asks that same command for startedAt on every event, so it now pins the head lookups themselves. The stub's state,headRefOid branch learned the pr_closed scenario its state-only sibling already knew. * fix(ci): anchor the already-posted guard on the run's creation, and say when it cannot run Round 1's two blockers, both re-verified against this repo's own run data. The time anchor reset on job re-runs. `gh run view --json startedAt` returns the LATEST attempt's start while the run id stays the same — the dedup above relies on that stability — so a re-run pushed attempt 1's review outside "this run": runs 32219268680 (created 05:23:57Z, startedAt 05:51:26Z) and 32218596441 (05:13:04Z → 05:22:05Z) both show the ~9-28 minute shift. Attempt 1 posts its review, the job fails after the post, someone re-runs it, attempt 2 fails before posting — and the guard, anchored on attempt 2's start, lets the contradictory comment through. Exactly the shape this PR exists to stop, on the path most likely to reach it. Both sites now anchor on `createdAt`, which is attempt-stable; a review submitted after the run was created still cannot belong to an earlier run, so the stale-review protection is unchanged. The guard also swallowed its own lookup failures. A transient failure in either call emptied the value, the guard declined, and the false comment posted with nothing in the log separating "the guard ran, nothing matched" from "the lookup died" — while every sibling lookup in these steps announces its failures. Both unavailable paths now emit a `::warning::` and a step-summary line before posting. No behavior change: posting was, and remains, the fail-open direction. Tests: a re-run fixture per site, where the stub answers `createdAt` and `startedAt` with DIFFERENT values and attempt 1's review sits between them — reverting either site to `startedAt` fails exactly these two; and a per-site assertion that both unavailable paths announce themselves. Also from round 1, both verified before taking: the stub's standalone `*state*)` branch is dead (no `--json state` call remains in either extracted step) and is removed, so its scenarios cannot be edited into a no-op; and the harness now substitutes `${{ vars.* }}` before running the in-job script, which bash rejected as a bad substitution — the assignment was skipped, `MAX_TIMEOUT_MINUTES` stayed unset, and eight error lines rode every suite run, so "the step's real bash" was not quite true for that line. * fix(ci): read the head this run reviewed, and claim only what the guard proved Round 2's six, all taken. The fallback JOB compared review commit ids against the PR's head at fallback time, not the head the run reviewed. On every trigger but pull_request_target the head-moved guard above deliberately does not run, so a push landing between the post and this step leaves that value pointing at bytes no review ever covered: the match fails and the contradictory comment posts anyway — the #9342 shape, re-opened for the trigger + post + push + fail-after-post interleaving. `review-pr` now publishes the head its review step recorded as a job output, and the guard reads it, falling back to the fresh head only when the job died before that step (a run that posted nothing either). The in-job twin needs none of this — its unconditional head-moved check exits first — and that asymmetry is now pinned per site rather than left to be rediscovered. Both skip messages claimed "this run already posted a review". Reviews carry no run id, so the window (bot account + head + submitted at or after this run was created) also matches an overlapping sibling run's review, which this workflow's own concurrency note says can happen. The suppression is right either way — a review IS sitting above the comment — but the oncall reading the summary was told something the guard never proved; both now say what it did. The guard's opening paragraphs still described the round-1 `startedAt` anchor while the code (and the paragraph below it, and the runtime warning) said creation. A maintainer reading top-down got the anchor that re-runs break — the defect round 1 removed. Test stub: `gh run view` now answers by running the caller's own --jq over an object carrying both timestamps, instead of a `case` on "$*" that matched substrings in order. A combined `--json createdAt,startedAt --jq '.startedAt'` was answered from the createdAt branch, leaving the re-run pin green for a guard reading the attempt-scoped field — the exact regression it exists to catch. * fix(ci): attribute the guard by time alone — the head is not a stable run attribute Round 3's blocker, and the second time the head clause re-opened the contradiction this PR exists to close. Two entrances this round, both after a "Re-run failed jobs": attempt 2 dies before the review step writes its head, so the guard falls back to a head attempt 1 never reviewed; or a push lands and attempt 2 records the NEW head — in both, attempt 1's own review no longer matches `.commit_id`, and the fallback posts "failed before a review could be posted … retry" beneath the review the same run had posted. Rather than patch the head lookup a third time, the head clause is gone. What the guard proves is now narrower and stable: a bot review of this PR was submitted while this run was alive — bot account plus the attempt-stable `createdAt` window. That closes both entrances at once and takes the round-2 cross-job wiring with it (review-pr's `expected_head_sha` output and the env line that read it), so there is no untested chain left whose silent breakage would restore the fresh-head comparison. The job-level step no longer needs the PR head either and reverts to its state-only query; the test stub's state-only branch, removed in round 1 as dead, has a caller again. The comment blocks now state the guarantee the concurrency model actually supports. They claimed a review inside the window "cannot belong to an earlier run", but per-run concurrency groups deliberately allow overlapping runs on the same head, so an earlier-created run's review can match and this run's failure then goes unannounced. That is accepted, and said plainly: the silence coincides with a bot review a reader can see — the very state that makes the comment's claim false — while the bot-author and creation-time clauses still rule out silence with no review at all. Tests: the moved-head case flips from "posts" to "silences" and is pinned per site (a review on ANY head inside the window silences); re-introducing a head clause fails exactly that test; and a structural pin asserts the wiring is absent rather than merely unused. * test(ci): skip the guard's jq-driven cases where jq is absent, instead of failing them The stub answers the guard's reviews and run-view lookups by running the caller's own `--jq` filter — that filter IS the thing under test — so those cases need jq on PATH. A reviewer running the suite on Windows without jq saw them as failures of the guard rather than as untested, which is the wrong signal in the wrong direction. Probed once per run and skipped honestly. Measured with a jq that exits 127: the file goes from 31 failures to 26 failures plus 13 skips — the 26 are the retry-loop cases, which have parsed the review log with jq since long before this change and are equally untestable without it. GitHub's windows-latest image ships jq, so CI coverage is unchanged either way; what changes is what a jq-less machine reports. * docs(ci): remove the head-keyed leftovers the guard no longer has Round 5's four, all leftovers of the round-3 design change rather than new behavior. The job-level block still explained why it compared against the head this run reviewed — naming `pr_head`, "the reviewed head's review" and a `review-pr` job output, none of which survive: the shipped filter is author scope plus the creation-time window, and the wiring was deleted with the head clause. A maintainer reading it would look for a comparison that is not there. The in-job block stated the createdAt-not-startedAt rationale twice, once with the measured run ids and once without; the measured one stays. Same in the tests: the stub's comment listed a head clause the filter deliberately does not have (`attributes by TIME, not by head` is the test that pins its absence), and the harness still declared and injected `reviewedHead`/`REVIEWED_HEAD_SHA`, which nothing reads since the wiring went — a knob that looks live and cannot be. * docs(ci): drop the duplicated anchor rationale and the last stale-head leftovers Co-authored-by: Qwen-Coder * fix(ci): exclude foreign same-account approvals from the already-posted guard * fix(ci): attribute the already-posted guard by composed-review markers The foreign-approval exclusion list shipped incomplete: the triage skill's commit-pinned APPROVE body also posts under the same account, matches the guard's author and window clauses, and silenced the fallback for a genuinely dead run — the failure shape this guard exists to stop. The producer set is open, so no exclusion list can be finished; every miss fails in the dangerous direction. Match positively instead: a review silences the fallback only if its body carries what only this pipeline's composed reviews carry — the "via Qwen Code /review" attribution footer or the invisible qwen-review-ledger marker. Every composed body carries at least one (a zero-findings APPROVE included); no foreign approval carries either. A marker that ever changes shape stops the guard firing and the comment posts — the pre-guard status quo, not a masked dead run. --------- Co-authored-by: qwen-code-ci-bot Co-authored-by: Qwen-Coder --- .github/workflows/qwen-code-pr-review.yml | 120 ++++++ scripts/tests/qwen-pr-review-workflow.test.js | 353 +++++++++++++++++- 2 files changed, 467 insertions(+), 6 deletions(-) diff --git a/.github/workflows/qwen-code-pr-review.yml b/.github/workflows/qwen-code-pr-review.yml index fd6f66c8d4..6b683dd379 100644 --- a/.github/workflows/qwen-code-pr-review.yml +++ b/.github/workflows/qwen-code-pr-review.yml @@ -1756,6 +1756,65 @@ jobs: echo "Fallback comment dedup lookup failed; deferring to the fallback-comment job." >> "$GITHUB_STEP_SUMMARY" exit 0 fi + # Same guard as the fallback-comment job's, for the same reason: a + # review posted moments before this step runs makes every body + # below — each one ending in a retry instruction — contradict the + # review already on the PR. Scoped to the bot's own account and to a + # submission at or after this run was CREATED, so a stale review from + # an earlier run cannot silence a genuinely dead one; an unavailable + # creation time declines to fire and posts. + # What a match proves, exactly: a bot review of this PR was + # submitted while this run was alive. It is deliberately NOT keyed on + # the reviewed head. Two revisions of this guard were, and the head + # is not a stable attribute of a run: a push moves the PR's head + # between the post and this step, and a re-run recomputes the + # reviewed head from a later attempt — in both, THIS run's own review + # stops matching and the contradictory comment ships. The window is + # anchored on `createdAt`, not `startedAt`, against the same class of + # drift: re-running a failed job keeps the run id (the dedup above + # relies on that) while run-level `startedAt` moves to the + # re-executed attempt — measured on runs 32219268680 (created + # 05:23:57Z, startedAt 05:51:26Z) and 32218596441 (05:13:04Z → + # 05:22:05Z). + # + # Under this workflow's per-run concurrency an overlapping run's + # review can also fall inside the window, and then this run's failure + # goes unannounced. Accepted: that silence coincides with a bot + # review of this PR a reader can see, which is exactly the state that + # makes this comment's claim false. What the bot-author and + # creation-time clauses rule out is silence with NO review at all. + # + # The account is not this pipeline's alone: finalize-release.yml, + # qwen-triage-finalize.yml, and the triage skill all post approvals + # under it. Excluding those bodies by name cannot be finished — it + # shipped missing one ("LGTM, looks ready to ship. ✅"), and any + # producer rewording fails in the dangerous direction: a foreign + # LGTM buys silence for a genuinely dead run. So the filter matches + # positively on what only this pipeline's composed reviews carry: + # every composed body ends in the "via Qwen Code /review" + # attribution footer or carries the invisible qwen-review-ledger + # marker — at least one rides every body, a zero-findings APPROVE + # included — and no foreign approval carries either. A marker that + # ever changes shape stops the guard firing and the comment posts: + # the pre-guard status quo, not a masked dead run. + run_created="$(gh run view "${GITHUB_RUN_ID:?}" --repo "$GITHUB_REPOSITORY" --json createdAt --jq '.createdAt' 2>/dev/null)" || run_created="" + posted_reviews="" + # Three outcomes, and the guard must not be silent about the third: + # a lookup that DIED degrades to the false comment this whole change + # removes, and an oncall reading the log could not tell it from "no + # review matched". Every sibling lookup in this step announces its + # failures; this one says so too, then posts. + if [ -z "$run_created" ]; then + echo "::warning::already-posted guard unavailable (no run creation time); posting the fallback comment" + echo "Already-posted guard unavailable (run creation time missing); proceeding to post." >> "$GITHUB_STEP_SUMMARY" + elif ! posted_reviews="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate \ + --jq ".[] | select(.user.login == \"$bot_login\") | select(.submitted_at >= \"$run_created\") | select((.body // \"\") | contains(\"via Qwen Code /review\") or contains(\"qwen-review-ledger\")) | .id" 2>/dev/null)"; then + echo "::warning::already-posted guard unavailable (reviews listing failed); posting the fallback comment" + echo "Already-posted guard unavailable (reviews listing failed); proceeding to post." >> "$GITHUB_STEP_SUMMARY" + elif [ -n "$posted_reviews" ]; then + echo "Skipping fallback comment: a bot review of this PR was submitted after this run was created." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi MAX_TIMEOUT_MINUTES="${{ vars.QWEN_REVIEW_MAX_TIMEOUT_MINUTES }}" if [ "$FAILURE_KIND" = "timeout" ]; then if [ "$TIMEOUT_MINUTES" -lt "$MAX_TIMEOUT_MINUTES" ]; then @@ -1944,6 +2003,67 @@ jobs: echo "Skipping fallback comment: PR #${PR_NUMBER} is ${pr_state}." >> "$GITHUB_STEP_SUMMARY" exit 0 fi + # A run that DID post its review must not be announced as one that + # could not. The review job can fail AFTER the post — the CLI exiting + # silently, a cleanup step dying — and this body's claim ("failed + # before a review could be posted"), with its retry instruction, then + # contradicts the review sitting right above it. Measured on PR + # #9342: the review posted at 11:56:34Z, the job failed at 12:00:53Z, + # and this comment landed at 12:01:00Z asking for a fresh ~3-hour + # review; the autofix takeover loop reads the same feed a human does. + # + # What a match proves, exactly: a bot review of this PR was + # submitted while this run was alive. It is deliberately NOT keyed on + # the reviewed head. Two revisions of this guard were, and the head + # is not a stable attribute of a run: a push moves the PR's head + # between the post and this step, and a re-run recomputes the + # reviewed head from a later attempt — in both, THIS run's own review + # stops matching and the contradictory comment ships. The window is + # anchored on `createdAt`, not `startedAt`, against the same class of + # drift: re-running a failed job keeps the run id (the dedup above + # relies on that) while run-level `startedAt` moves to the + # re-executed attempt — measured on runs 32219268680 (created + # 05:23:57Z, startedAt 05:51:26Z) and 32218596441 (05:13:04Z → + # 05:22:05Z). + # + # Under this workflow's per-run concurrency an overlapping run's + # review can also fall inside the window, and then this run's failure + # goes unannounced. Accepted: that silence coincides with a bot + # review of this PR a reader can see, which is exactly the state that + # makes this comment's claim false. What the bot-author and + # creation-time clauses rule out is silence with NO review at all. + # + # The account is not this pipeline's alone: finalize-release.yml, + # qwen-triage-finalize.yml, and the triage skill all post approvals + # under it. Excluding those bodies by name cannot be finished — it + # shipped missing one ("LGTM, looks ready to ship. ✅"), and any + # producer rewording fails in the dangerous direction: a foreign + # LGTM buys silence for a genuinely dead run. So the filter matches + # positively on what only this pipeline's composed reviews carry: + # every composed body ends in the "via Qwen Code /review" + # attribution footer or carries the invisible qwen-review-ledger + # marker — at least one rides every body, a zero-findings APPROVE + # included — and no foreign approval carries either. A marker that + # ever changes shape stops the guard firing and the comment posts: + # the pre-guard status quo, not a masked dead run. + run_created="$(gh run view "${GITHUB_RUN_ID:?}" --repo "$GITHUB_REPOSITORY" --json createdAt --jq '.createdAt' 2>/dev/null)" || run_created="" + posted_reviews="" + # Three outcomes, and the guard must not be silent about the third: + # a lookup that DIED degrades to the false comment this whole change + # removes, and an oncall reading the log could not tell it from "no + # review matched". Every sibling lookup in this step announces its + # failures; this one says so too, then posts. + if [ -z "$run_created" ]; then + echo "::warning::already-posted guard unavailable (no run creation time); posting the fallback comment" + echo "Already-posted guard unavailable (run creation time missing); proceeding to post." >> "$GITHUB_STEP_SUMMARY" + elif ! posted_reviews="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate \ + --jq ".[] | select(.user.login == \"$bot_login\") | select(.submitted_at >= \"$run_created\") | select((.body // \"\") | contains(\"via Qwen Code /review\") or contains(\"qwen-review-ledger\")) | .id" 2>/dev/null)"; then + echo "::warning::already-posted guard unavailable (reviews listing failed); posting the fallback comment" + echo "Already-posted guard unavailable (reviews listing failed); proceeding to post." >> "$GITHUB_STEP_SUMMARY" + elif [ -n "$posted_reviews" ]; then + echo "Skipping fallback comment: a bot review of this PR was submitted after this run was created." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi body="**Qwen Code review did not complete successfully.** The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with \`@qwen-code /review\`. See [workflow logs](${RUN_URL})." body="$(printf '%s\n\n%s' "$FALLBACK_MARKER" "$body")" gh pr comment "$PR_NUMBER" \ diff --git a/scripts/tests/qwen-pr-review-workflow.test.js b/scripts/tests/qwen-pr-review-workflow.test.js index 3fd5f6cbd8..55f2a27c76 100644 --- a/scripts/tests/qwen-pr-review-workflow.test.js +++ b/scripts/tests/qwen-pr-review-workflow.test.js @@ -3216,6 +3216,20 @@ describe('fallback comment resilience (PR #8894 incident class)', () => { expect(r.status).toBe(0); }); + // The stub answers the guard's reviews and run-view lookups by running the + // caller's own `--jq` filter — that filter IS the thing under test — so + // these cases need jq on PATH. Windows runners have none, and a stub that + // silently produced nothing there would report the guard as broken rather + // than untested. Probed once, skipped honestly. + const hasJq = (() => { + try { + execFileSync('jq', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } + })(); + // Executed shape: run the step's REAL bash with a stub gh that logs every // call. The stub pre-applies the dedup filter's semantics to the fixture // (the filter's author scope is pinned by the text test above). @@ -3227,6 +3241,9 @@ describe('fallback comment resilience (PR #8894 incident class)', () => { runHead = '', prHead = '', useInJobStep = false, + reviews = '[]', + runCreated = '', + runStartedAttempt = '', } = {}, ) { const dir = mkdtempSync(join(tmpdir(), 'fallback-comment-')); @@ -3253,14 +3270,36 @@ describe('fallback comment resilience (PR #8894 incident class)', () => { '#!/bin/bash', 'echo "gh $*" >> "$CALLS"', 'cmd="$1"; sub="${2:-}"', + // Hoisted: both the run-view and the reviews branches run the + // caller's own --jq, so the extraction cannot live inside one of them. + 'filter=""; prev=""', + 'for a in "$@"; do if [ "$prev" = "--jq" ]; then filter="$a"; fi; prev="$a"; done', 'if [ "$cmd" = "api" ] && [ "$sub" = "user" ]; then', ' [ "${SCENARIO:-}" = "lookup_fail" ] && exit 1', ' echo "qwen-code-ci-bot"; exit 0', 'fi', 'if [ "$cmd" = "run" ] && [ "$sub" = "view" ]; then', + ' case "$*" in', + ' *createdAt*|*startedAt*)', + ' [ "${SCENARIO:-}" = "runstart_fail" ] && exit 1', + // Real --jq over an object carrying BOTH fields, exactly as the + // reviews stub does: a `case` on "$*" answers a combined + // `--json createdAt,startedAt --jq .startedAt` from whichever + // substring branch comes first, so the discriminator between the + // two anchors would silently stop discriminating. + ' printf \'{"createdAt":"%s","startedAt":"%s"}\' "${RUN_CREATED:-}" "${RUN_STARTED_ATTEMPT:-}" | jq -r "$filter"; exit 0 ;;', + ' esac', ' [ "${SCENARIO:-}" = "runview_fail" ] && exit 1', ' echo "${RUN_HEAD:-}"; exit 0', 'fi', + // The reviews lookup runs the step's REAL --jq filter over the + // fixture: the guard under test IS that filter (author scope and + // submission time — no head clause, which `attributes by TIME, not + // by head` pins), so a stub that pre-applied it would pin nothing. + 'if [ "$cmd" = "api" ] && [ "${sub#repos/}" != "$sub" ]; then', + ' [ "${SCENARIO:-}" = "reviews_fail" ] && exit 1', + ' printf "%s" "$REVIEWS_JSON" | jq -r "$filter"; exit 0', + 'fi', 'if [ "$cmd" = "pr" ] && [ "$sub" = "view" ]; then', ' case "$*" in', ' *comments*)', @@ -3268,13 +3307,17 @@ describe('fallback comment resilience (PR #8894 incident class)', () => { ' cat "$COMMENTS_FILE"; exit 0 ;;', ' *state,headRefOid*)', ' [ "${SCENARIO:-}" = "state_fail" ] && exit 1', - ' printf "OPEN\\t%s\\n" "${PR_HEAD:-}"; exit 0 ;;', - ' *headRefOid*)', - ' [ "${SCENARIO:-}" = "prview_fail" ] && exit 1', - ' echo "${PR_HEAD:-}"; exit 0 ;;', + ' state=OPEN; [ "${SCENARIO:-}" = "pr_closed" ] && state=MERGED', + ' printf "%s\\t%s\\n" "$state" "${PR_HEAD:-}"; exit 0 ;;', + // Live again: the fallback job reverted to a state-only query when + // the guard stopped keying on the head, so this branch has a caller + // once more (the in-job step keeps the combined shape above). ' *state*)', ' [ "${SCENARIO:-}" = "state_fail" ] && exit 1', ' [ "${SCENARIO:-}" = "pr_closed" ] && echo "MERGED" || echo "OPEN"; exit 0 ;;', + ' *headRefOid*)', + ' [ "${SCENARIO:-}" = "prview_fail" ] && exit 1', + ' echo "${PR_HEAD:-}"; exit 0 ;;', ' esac', 'fi', 'if [ "$cmd" = "pr" ] && [ "$sub" = "comment" ]; then', @@ -3291,7 +3334,18 @@ describe('fallback comment resilience (PR #8894 incident class)', () => { try { stdout = execFileSync( 'bash', - ['-c', useInJobStep ? inJobStep.run : step.run], + [ + '-c', + // The runner substitutes `${{ vars.* }}` before bash ever sees the + // script; feeding the raw expression to bash is a `bad substitution` + // that skips the assignment and leaves the variable unset — the + // timeout body then compares against an empty string. Substituting + // here is what makes "the step's real bash" true. + (useInJobStep ? inJobStep.run : step.run).replace( + /\$\{\{ vars\.QWEN_REVIEW_MAX_TIMEOUT_MINUTES \}\}/g, + '180', + ), + ], { encoding: 'utf8', env: { @@ -3314,6 +3368,9 @@ describe('fallback comment resilience (PR #8894 incident class)', () => { CALLS: calls, COMMENTS_FILE: commentsFile, POSTED: posted, + REVIEWS_JSON: reviews, + RUN_CREATED: runCreated, + RUN_STARTED_ATTEMPT: runStartedAttempt, }, }, ); @@ -3419,7 +3476,12 @@ describe('fallback comment resilience (PR #8894 incident class)', () => { }); expect(r.status).toBe(0); expect(r.posted).not.toBe(''); - expect(r.calls).not.toContain('run view'); + // Pinned on the head lookup itself, not on `gh run view` as a whole: + // the already-posted guard below asks the same command for this run's + // createdAt on every event, and a blanket "no run view" assertion would + // read that as a head comparison it never makes. + expect(r.calls).not.toContain('headSha'); + expect(r.calls).not.toContain('--json headRefOid'); }); it('degrades to POSTING when the head comparison lookups fail', () => { @@ -3450,6 +3512,285 @@ describe('fallback comment resilience (PR #8894 incident class)', () => { expect(r.posted).not.toBe(''); }); + // A run can fail AFTER posting its review — the CLI exiting silently, a + // cleanup step dying — and both fallback bodies then announce a review + // sitting right above them as one that could not be posted, retry + // instruction attached. Measured on PR #9342: review posted 11:56:34Z, + // review-pr failed 12:00:53Z, the comment landed 12:01:00Z asking for a + // fresh ~3-hour review; the autofix takeover loop reads the same feed a + // human does. The guard is a FILTER (author scope and submission + // time), so these run the step's real bash over review fixtures. + // The run was CREATED at 09:08:38Z; a re-run of its failed job later moved + // run-level startedAt to 11:30:00Z. Attempt 1's review sits between them — + // the shape that separates the two anchors. + const RUN_CREATED = '2026-08-18T09:08:38Z'; + const RUN_RESTARTED = '2026-08-18T11:30:00Z'; + const AFTER = '2026-08-18T11:56:34Z'; + const MID_RERUN = '2026-08-18T10:00:00Z'; + const BEFORE = '2026-08-17T10:00:00Z'; + const reviewFixture = (login, commit, submitted, body = null) => + JSON.stringify([ + { + id: 1, + user: { login }, + commit_id: commit, + submitted_at: submitted, + body, + }, + ]); + + // The bot account posts more than this pipeline's reviews: + // finalize-release.yml approves release PRs under the same CI_BOT_PAT, + // qwen-triage-finalize.yml posts a deferred APPROVE under + // QWEN_CODE_BOT_TOKEN || CI_BOT_PAT, and the triage skill posts its own + // commit-pinned APPROVE through the reviews API. In-window approvals like + // these must not buy the silence that only THIS pipeline's own review + // earns. + const FOREIGN_APPROVAL_BODIES = [ + 'Automated second approval for the release version bump.', + 'LGTM, looks ready to ship — CI landed green after the review. ✅', + 'LGTM, looks ready to ship. ✅', + ]; + + // What the guard recognizes a review THIS pipeline composed by: every + // composed body carries the "via Qwen Code /review" attribution footer or + // the invisible qwen-review-ledger marker — at least one, never neither — + // and no foreign approval carries either. Matching on that evidence is how + // the guard stays closed to a producer set no exclusion list can finish. + const REVIEW_FOOTER = '_— qwen3.8-max via Qwen Code /review (v0.21.14)_'; + const REVIEW_LEDGER = ''; + const COMPOSED_REVIEW_BODIES = [ + // Attribution on: the footer and the ledger marker both ride the body. + `No issues found. LGTM! ✅\n\n${REVIEW_FOOTER}\n\n${REVIEW_LEDGER}`, + // Attribution off: no footer, but the ledger marker still rides. + `No issues found. LGTM! ✅\n\n${REVIEW_LEDGER}`, + // Pre-ledger bundles posted the footer alone. + `No issues found. LGTM! ✅\n\n${REVIEW_FOOTER}`, + ]; + + for (const useInJobStep of [false, true]) { + const site = useInJobStep ? 'in-job step' : 'fallback job'; + + it.skipIf(!hasJq)( + `${site} stays silent when THIS run already posted its review`, + () => { + // Every shape compose-review can post must buy the silence: the + // guard attributes by the markers a composed body carries, so each + // marker alone — and both together — has to match. + for (const body of COMPOSED_REVIEW_BODIES) { + const r = runFallbackStep('default', { + useInJobStep, + prHead: 'HEADSHA1', + runCreated: RUN_CREATED, + runStartedAttempt: RUN_RESTARTED, + reviews: reviewFixture('qwen-code-ci-bot', 'HEADSHA1', AFTER, body), + }); + expect(r.status, body).toBe(0); + expect(r.posted, body).toBe(''); + expect(r.summary, body).toContain( + 'a bot review of this PR was submitted', + ); + } + }, + ); + + it.skipIf(!hasJq)( + `${site} still posts when no review can be attributed to this run`, + () => { + // Each clause alone must keep the fallback speaking, or a stale or + // foreign review buys silence on a genuinely dead pipeline: an earlier + // run's review (outside the window), another account's, an unsubmitted + // (PENDING) one, and none at all. The head is deliberately not a clause + // — see the attribute-by-TIME test below. + const cases = { + stale: reviewFixture( + 'qwen-code-ci-bot', + 'HEADSHA1', + BEFORE, + COMPOSED_REVIEW_BODIES[0], + ), + foreign: reviewFixture( + 'someone-else', + 'HEADSHA1', + AFTER, + COMPOSED_REVIEW_BODIES[0], + ), + pending: reviewFixture( + 'qwen-code-ci-bot', + 'HEADSHA1', + null, + COMPOSED_REVIEW_BODIES[0], + ), + none: '[]', + }; + for (const [name, reviews] of Object.entries(cases)) { + const r = runFallbackStep('default', { + useInJobStep, + prHead: 'HEADSHA1', + runCreated: RUN_CREATED, + runStartedAttempt: RUN_RESTARTED, + reviews, + }); + expect(r.posted, name).not.toBe(''); + } + }, + ); + + it.skipIf(!hasJq)( + `${site} still posts when the only in-window reviews are foreign approvals`, + () => { + // The guard's author + window clauses match ANY review the account + // posts, and the account also approves release PRs (finalize-release + // .yml), posts deferred triage approvals (qwen-triage-finalize.yml), + // and approves through the triage skill's reviews-API call. None of + // these bodies carries a composed-review marker, so none may silence + // the fallback while THIS pipeline's review is absent — the LGTM + // would mask a dead run. + for (const body of FOREIGN_APPROVAL_BODIES) { + const r = runFallbackStep('default', { + useInJobStep, + prHead: 'HEADSHA1', + runCreated: RUN_CREATED, + runStartedAttempt: RUN_RESTARTED, + reviews: reviewFixture('qwen-code-ci-bot', 'HEADSHA1', AFTER, body), + }); + expect(r.posted, body).not.toBe(''); + } + }, + ); + + it.skipIf(!hasJq)( + `${site} survives a job re-run: attempt 1's review still silences it`, + () => { + // Re-running a failed job keeps the run id but moves run-level + // startedAt to the re-executed attempt (measured: runs 32219268680 and + // 32218596441 report startedAt ~28 and ~9 minutes after createdAt). + // Anchored there, attempt 1's review reads as older than "this run", + // and a re-run that fails before posting contradicts it — the very + // shape this guard exists to stop. The stub answers createdAt and + // startedAt with DIFFERENT values, so this fails if the guard reads + // the wrong field. + const r = runFallbackStep('default', { + useInJobStep, + prHead: 'HEADSHA1', + runCreated: RUN_CREATED, + runStartedAttempt: RUN_RESTARTED, + reviews: reviewFixture( + 'qwen-code-ci-bot', + 'HEADSHA1', + MID_RERUN, + COMPOSED_REVIEW_BODIES[0], + ), + }); + expect(r.status).toBe(0); + expect(r.posted).toBe(''); + expect(r.summary).toContain('a bot review of this PR was submitted'); + }, + ); + + it.skipIf(!hasJq)( + `${site} says so in the log when the guard cannot run`, + () => { + // A lookup that DIED degrades to the false comment this change + // removes, and silence there leaves an oncall unable to tell it from + // "no review matched". Both unavailable paths announce themselves. + for (const scenario of ['runstart_fail', 'reviews_fail']) { + const r = runFallbackStep(scenario, { + useInJobStep, + prHead: 'HEADSHA1', + runCreated: RUN_CREATED, + runStartedAttempt: RUN_RESTARTED, + reviews: reviewFixture('qwen-code-ci-bot', 'HEADSHA1', AFTER), + }); + expect(r.posted, scenario).not.toBe(''); + expect(r.stdout, scenario).toContain( + '::warning::already-posted guard', + ); + expect(r.summary, scenario).toContain( + 'Already-posted guard unavailable', + ); + } + }, + ); + + it.skipIf(!hasJq)( + `${site} posts when this run's creation time is unavailable`, + () => { + // Without a start time there is no proof the review landed during THIS + // run, and posting wins over silence — the same call the head-moved + // guard makes when its comparison is unavailable. + const r = runFallbackStep('runstart_fail', { + useInJobStep, + prHead: 'HEADSHA1', + runCreated: RUN_CREATED, + runStartedAttempt: RUN_RESTARTED, + reviews: reviewFixture('qwen-code-ci-bot', 'HEADSHA1', AFTER), + }); + expect(r.posted).not.toBe(''); + }, + ); + + it.skipIf(!hasJq)( + `${site} posts when the reviews lookup itself fails`, + () => { + // Same direction as every other lookup this step makes for a SKIP + // decision: a failed listing is never read as "a review exists". + const r = runFallbackStep('reviews_fail', { + useInJobStep, + prHead: 'HEADSHA1', + runCreated: RUN_CREATED, + runStartedAttempt: RUN_RESTARTED, + reviews: reviewFixture('qwen-code-ci-bot', 'HEADSHA1', AFTER), + }); + expect(r.posted).not.toBe(''); + }, + ); + } + + it.skipIf(!hasJq)( + "attributes by TIME, not by head — a moved head cannot hide this run's review", + () => { + // The head is not a stable attribute of a run: a push moves the PR's head + // between the post and this step, and a re-run recomputes the reviewed + // head from a later attempt. Two revisions of this guard keyed on it and + // both re-opened the #9342 contradiction through one of those doors. What + // the guard proves now is narrower and stable — a bot review of this PR + // submitted while this run was alive — so a review on ANY head inside the + // window silences the comment. + for (const useInJobStep of [false, true]) { + const r = runFallbackStep('default', { + useInJobStep, + prHead: 'NEWSHA', + runCreated: RUN_CREATED, + runStartedAttempt: RUN_RESTARTED, + reviews: reviewFixture( + 'qwen-code-ci-bot', + 'OLDSHA', + AFTER, + COMPOSED_REVIEW_BODIES[0], + ), + }); + expect(r.status, String(useInJobStep)).toBe(0); + expect(r.posted, String(useInJobStep)).toBe(''); + expect(r.summary, String(useInJobStep)).toContain( + 'a bot review of this PR was submitted after this run was created', + ); + } + }, + ); + + it('carries no cross-job head wiring to drift', () => { + // An earlier revision published review-pr's reviewed head as a job output + // and read it here. The guard no longer keys on the head at all, so the + // wiring is gone rather than left as an untested chain whose silent + // breakage would restore the fresh-head comparison. + expect(doc.jobs['review-pr'].outputs).toBeUndefined(); + expect(step.env.REVIEWED_HEAD_SHA).toBeUndefined(); + expect(step.run).not.toContain('REVIEWED_HEAD_SHA'); + expect(inJobStep.run).not.toContain('commit_id =='); + expect(step.run).not.toContain('commit_id =='); + }); + it('in-job step dedupes on a fallback comment this run already has', () => { // Re-runs of failed jobs keep the run id: when a prior attempt died // before its in-job step, the fallback-comment job already posted for From 54a3a7f6693b0ac50c6459e91cc2cac32983d393 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 21 Aug 2026 02:57:30 +0000 Subject: [PATCH 3/4] fix(web-shell): bound daemon transcript retention to stop renderer OOM crashes (#9303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web-shell): bound daemon transcript retention to stop renderer OOM crashes Watching a busy daemon session (long turn, many subagents) could exhaust the browser renderer: the replay snapshot stayed pinned on the session client for the whole attachment, replay rebuilds ran uncapped and could ratchet the transcript block cap above its configured limit, and a few retention windows were implicitly far larger than intended. Observed as a Chrome renderer abort after multi-GB growth, reproducible on every reload of the affected session. - Release the replay snapshot once it is injected into the transcript store (SSE resumes from lastEventId; older history via pagination) - Rebuild replays under the configured maxBlocks cap and never raise the committed cap above it; trimming keeps the most recent blocks - Cap the debug text embedded for unrecognized session_update kinds, which appended one block per frame with the full payload - Pass the web-shell block cap to the subagent detail provider and lower the provider default window from 200k to 50k blocks * fix(review): address round-1 findings for transcript retention (#9303) Co-authored-by: Qwen-Coder * fix(review): cap debug payloads at the producer for transcript retention (#9303) * fix(web-shell): slim consumeReplaySnapshot to fit the SDK browser bundle budget The consume-and-drop API landed as a getter plus a private backing field, which pushed the minified browser daemon bundle 51 bytes past the assertBrowserSafeBundle budget. Collapse it to a single mutable replaySnapshot field swapped by consumeReplaySnapshot(), preserving the external behavior while fitting the budget without raising it. * fix(web-shell): bound transcript retention in bytes, not just blocks (#9303) Round-3 review fixes for the renderer OOM: - The capped diagnostic strings no longer retain their oversized parents: capped slices are detached via a UTF-8 round-trip before being kept on transcript blocks (capDetails and the 100KB text-block truncation), so a 4KB visible cap cannot pin a 100KB payload backing store. - Retention is now byte-aware: the transcript state keeps a running estimate of what blocks retain and trimming evicts oldest blocks until the estimate is back under a byte budget (128 MiB default), so the block-count window is no longer mistaken for a memory ceiling while tool blocks carry uncapped raw payloads. - The replay snapshot is released only after the injection dispatch succeeds, keeping the window consistent if normalization throws mid-injection. - The provider DEFAULT_MAX_BLOCKS is exported and guarded by a web-shell drift test instead of a duplicated literal. * fix(review): close round-4 byte-budget accounting gaps in transcript retention (#9303) * fix(webui): reject history pages that overflow the transcript byte budget (#9303) Pagination admission only checked the block cap, so a page could merge over the retention byte budget: idle sessions stayed over budget, and on live sessions the next trim evicted the freshly prepended oldest records, which the exclusive pagination anchor can never re-fetch — a permanent silent gap. Admit pages only when the merged estimate fits the budget, mirroring the atomic block-cap rejection. * fix(review): reconcile transcript eviction with the pagination anchor (#9303) * fix(webui): gate pagination re-open and drop stale in-flight pages (#9303) Round-7 review fixes for the eviction/pagination reconciliation: - The eviction re-open of the load-older affordance now applies the same gates as the sibling paths: it requires the session_transcript_pagination capability and an existing positional anchor (beforeRecordId or cursor). On non-pagination daemons or anchor-less sessions the capacity latch stays closed instead of exposing an affordance that would call a route the daemon does not serve and latch a pagination error. - A retention trim firing while a load-older page is in flight re-anchors the exclusive beforeRecordId; the stale page fetched against the old anchor is now dropped instead of merged, so it can never advance the anchor below the evicted band. Load-older fetches capture a monotonic pagination generation bumped on every block trim and drop the page when it moved mid-fetch (lossless: every record in the dropped page is older than the new anchor and is re-served by the next fetch). Adds regression tests for both: latch stays closed on live trims without pagination support, and an in-flight page is dropped across a mid-fetch re-anchor with the retry fetching against the re-anchored anchor. * fix(webui): gate pagination re-open on admission headroom (#9303) Round-8 review fix for the eviction-triggered capacity-latch re-open: - The re-open now checks whether the rejected page would actually be admitted against the post-trim window occupancy (carried on the truncation detail). A rejected page's footprint is remembered at rejection time; the latch re-opens only once enough count AND byte headroom has been freed for that page, stopping the streaming fetch/reject/re-render churn when count trims restore the window to exactly maxBlocks (zero headroom). - A footprint-less latch (replay saturation) re-opens only on real count headroom: while the count window is saturated, count admission rejects every page regardless of freed bytes. - A page that alone exceeds the whole retention window (block count or byte budget) can never be admitted in any occupancy state; it is now a terminal pagination failure (hasMore=false + paginationError) instead of a re-openable capacity state, so everything older than it is no longer silently unreachable. - Adds a maxRetainedBytes provider prop (mirrors maxBlocks) so the byte budget is configurable and testable; the replay rebuild store uses the same budget as the live store. Adds regression tests: churn suppression under count saturation, latch suppression while the freed hole is smaller than the rejected page, footprint-based re-open once eviction frees room (with re-anchored fetch), and terminal failure for a single oversized page. * fix(webui): reconcile pagination with eviction across rebuild and rewind (#9303) Round-9/10 review fixes for the retention/pagination reconciliation: - impossible is now evaluated across both admission dimensions with >= at the equality boundary: a page that alone fills the whole block window, or whose estimate alone reaches the byte budget, can never be admitted (an anchored window always retains at least one block / some bytes), so it routes to the terminal pagination failure regardless of which branch rejects first. - applyTranscriptHistory merges sentinel-aware: a pagination-resurrected real tool block mapping wins over the current window's TRIMMED sentinel for the same callId (the resurrected block is no longer orphaned), and the resurrected tool's trimmed-notification flag is cleared. - rewind (truncateTranscriptBeforeBlock) now fires the same 'blocks' truncation signal when it drops blocks, so freed capacity can re-open the pagination latch. The detail carries evictedOldest so consumers only re-anchor on oldest-first eviction (a rewind drops the newest blocks and leaves the oldest anchor intact). - The live-trim re-anchor clears a stale cursor atomically, and fails closed (clears the anchor, closes the affordance) when no retained block carries a recordId and the pre-trim replay had several distinct recordIds (a real evicted band) — with zero/one recordId the pre-trim anchor stays valid. - Replay-rebuild reconciliation runs for live-journal repair rebuilds too (both the ordinary and the marker-visible checkpoint-seeded branches observe trimming), and fails closed when the re-anchor is uncomputable with a real evicted band. - The rebuild affordance only opens with real admission headroom: a positional anchor and byte-budget room. Without byte-budget headroom (e.g. a single oversized block whose estimate alone exceeds the budget) no page can ever be admitted, so the affordance closes instead of burning on the first click. - projectChatRecordsToDaemonTranscript (offline/export projection) passes maxRetainedBytes: Infinity — its only documented truncation knob is maxBlocks; the live-window byte budget must not silently evict rendered or exported transcripts. Updates the affected pagination tests to non-boundary footprints and adds coverage for the eviction-direction signal. * fix(webui): harden eviction/pagination reconciliation corners (#9303) Round-11 review fixes: - Mirror the sentinel-aware merge for permissionBlockByRequestId: a page-resurrected real mapping wins over the current window's TRIMMED_PERMISSION sentinel, so a resurrected pending permission can flip to resolved (export isTrimmedPermissionBlockId next to the tool helper). - Snap retention eviction to record boundaries: one persisted record fans out into several blocks sharing a sourceRecordIds entry, and block-granular trimming could land mid-record, orphaning the evicted sibling blocks from exclusive-before pagination (the anchor re-sets to the shared record while the dedup filter drops any page still advertising it). Advance the cut until it no longer lands inside a record, keeping the at-least-one-block floor. - Content-aware dedup for recordId-less blocks: the locally echoed user prompt carries no recordId, so recordId dedup is blind to it; once a trim leaves it as the oldest retained block, a load-older page returning that prompt's persisted record would render it twice and double-count bytes. Drop page user blocks matching a displayed recordId-less prompt by text. - Fail closed when the rebuild trimmed and no retained block carries a recordId: with any pre-trim recordId, that record's blocks were all evicted and exclusive-before pagination never returns the anchor record itself, so anchoring there leaves it silently unreachable. Drop the anchor (affordance closes) instead of paging from a stale anchor. Updates the bounded full-snapshot fallback test to the fail-closed outcome. * fix(webui): close round-12 retention/pagination reconciliation criticals (#9303) Round-12 review fixes: - Scope the local-echo text dedup to the boundary pair (R12-1): the recordId-less echo can only collide with the page's newest user block, the one adjacent to the window's oldest block. Keying on text window-wide instead dropped distinct older prompts sent twice ("yes", a retry), permanently orphaning their assistant replies. Compare only the window's oldest recordId-less echo against the page's newest user block. - Gate observeReplayTrim on evictedOldest !== false (R12-2): a rewind also fires the 'blocks' truncation signal but drops the newest blocks and leaves the oldest pagination anchor valid, so it must not latch the capacity/re-anchor path during a capped rebuild (same gate the live store's onTruncation handler already uses). - Add the pagination generation guard to the loadMore catch block (R12-10): the success path already drops a page when a trim re-anchored mid-fetch, but the catch restored hasMore unconditionally, reviving the load-older affordance in the anchor-less state the fail-closed branch just closed. Leave the fail-closed state intact when the generation changed. - Back the record-boundary snap off the floor (R12-21): when byte pressure evicts to the last block, the forward snap's at-least-one-block floor stopped it from escaping a mid-record cut between the final two sibling blocks. Re-retain the penultimate block when the floor-pinned pair shares a record, trading at most one extra retained block against the budget to keep the record whole. Adds a reducer regression test. * fix(webui): close round-13 retention/pagination reconciliation criticals (#9303) Round-13 review fixes: - Count binary payloads in the retention estimate: Blob/File, ArrayBuffer, and typed-array/DataView values carry their content in non-enumerable slots, so the record walk only charged the fixed object overhead — an 8 MiB attachment counted as ~64 bytes and the byte budget never fired for media-heavy transcripts (the OOM class this PR targets). Charge Blob.size / byteLength for these shapes. Adds a budget-eviction regression test. - Loop the record-boundary floor back-off (R12-21 follow-up): the single back-off only re-retained one block, still cutting a 3+-sibling record mid-record. Loop while the boundary pair shares a record, keeping the record whole; the removeCount===0 guard retains the whole window when nothing is evictable. Grows the snap test to a 3-block record. - Offer the load-older affordance on a live trim for sessions that loaded unlatched: eviction re-anchored beforeRecordId but the re-open path only ran under capacityReached, so a session whose replay fit under the caps could never surface older persisted history after the window trimmed. Mirror the replay path's olderHistoryReachable gates in the re-anchor branch. - Key the boundary-echo dedup on echo presence and fold media into the comparison (R12-1 follow-up): an empty-text gate skipped image/file-only echoes entirely, and a naive empty-text match would collapse two distinct media-only prompts. Compare text + image/file counts for the boundary pair. * fix(webui): close round-14 retention/pagination reconciliation criticals (#9303) Round-14 review fixes: - Clamp the trim floor to keep at least one block (R14-1): a non-positive or non-finite maxBlocks made the count floor evict the whole window, and the record snap then read one past the end of the block array, throwing on every dispatch. Treat maxBlocks < 1 / non-finite as 1 so the window always keeps a block. Adds a regression test. - Drop the rebuild-trim fail-closed anchor unconditionally (R14-2): scanning only the fresh replayEvents missed recordIds trimmed from the repair checkpoint in a marker-visible live-journal repair, leaving a stale anchor with the affordance still on. Mirror the live store's fail-closed branch — when no retained block carries a recordId any pre-trim anchor is stale, and dropping an already-undefined anchor is a no-op. - Grow the latched rejectedPage footprint by a re-anchoring trim's evicted band (R14-3): the daemon re-serves the evicted band on the next exclusive-before fetch, so the page is larger than latched; a stale footprint would churn fetch/reject or misclassify a now-larger page as terminal. The re-open gate now measures the grown page. Reworks the re-open test to the faithful daemon behavior (the latch stays closed once the evicted band re-joins the page). - Refuse anchor-less forced retries (R14-4): after a fail-closed trim drops both cursor and beforeRecordId, a forced retry fetched with neither anchor, and the daemon defaults that to the journal's oldest page — prepended below the window and re-stamping a bogus anchor. Return early until a later trim re-establishes an anchor. * fix(sdk): integral-ize a fractional maxBlocks in the trim floor (#9303) Round-15 review fix (R15-1): the R14-1 degenerate-maxBlocks clamp floored non-positive and non-finite values to 1 but passed positive fractional values through unchanged, leaving removeCount fractional so the record-boundary snap indexed blocks[removeCount] one past the end and threw on later dispatches — the same crash class R14-1 was added to prevent, re-entering through a sibling input. Floor the clamped value so the window always trims by a whole block count. Math.floor is a no-op for the integer maxBlocks every in-repo caller passes. Extends the degenerate-maxBlocks regression test with a fractional case. --------- Co-authored-by: qwen-code-dev-bot Co-authored-by: Qwen-Coder Co-authored-by: qwen-code-dev-bot --- packages/sdk-typescript/scripts/build.js | 18 +- .../src/daemon/DaemonSessionClient.ts | 21 +- packages/sdk-typescript/src/daemon/index.ts | 4 + .../src/daemon/ui/chat-record-transcript.ts | 7 + .../sdk-typescript/src/daemon/ui/index.ts | 4 + .../src/daemon/ui/normalizer.ts | 29 +- .../sdk-typescript/src/daemon/ui/store.ts | 26 +- .../src/daemon/ui/toolPreview.ts | 5 +- .../src/daemon/ui/transcript.ts | 256 ++- .../sdk-typescript/src/daemon/ui/types.ts | 32 + .../sdk-typescript/src/daemon/ui/utils.ts | 22 + .../test/unit/DaemonSessionClient.test.ts | 31 + .../sdk-typescript/test/unit/daemonUi.test.ts | 577 +++++++ .../components/artifacts/SubagentDetail.tsx | 2 + .../client/constants/sessions.test.ts | 20 + .../web-shell/client/constants/sessions.ts | 15 +- packages/webui/src/daemon-react-sdk.ts | 7 + .../session/DaemonSessionProvider.test.tsx | 1429 ++++++++++++++++- .../daemon/session/DaemonSessionProvider.tsx | 579 ++++++- packages/webui/src/daemon/session/index.ts | 1 + packages/webui/src/daemon/session/types.ts | 8 + 21 files changed, 2990 insertions(+), 103 deletions(-) create mode 100644 packages/web-shell/client/constants/sessions.test.ts diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 535c493205..c41d95ca80 100644 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -93,13 +93,25 @@ const rootDir = join(__dirname, '..'); // Bumped from 195KB to 196KB for transient-vs-gone media hydration errors and // the reference-only replay placeholder. // Bumped from 196KB to 197KB for the workspace session live-state daemon -// surface (catalog version + live snapshot accessors) and immutable, -// identity-stable transcript block indexes used by browser renderers. +// surface (catalog version + live snapshot accessors), immutable, +// identity-stable transcript block indexes used by browser renderers, and the +// daemon transcript-retention work (replay-snapshot release + capped debug +// payloads, #9303) landing on top of the session media references bundle. // Bumped from 197KB to 198KB for the unrecognized-diagnostic sidechannel // (`unrecognizedDiagnostics` routing + selector, #8823). // Bumped from 198KB to 199KB for persistent session attachment read/remove and // binary resource hydration. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 199 * 1024; +// Bumped from 199KB to 200KB for the retention byte budget (block byte +// estimation + budget-aware trimming) and backing-store-detached string caps +// (#9303 review round 3). +// Bumped from 200KB to 206KB for the pagination/eviction reconciliation and the +// #8823 × #9303 merge (#9303 review rounds 9-12): eviction-direction signal, +// rewind truncation callback, trimmed tool/permission sentinel helpers, +// record-boundary eviction snap, and the floor back-off — each bump budgeted +// its own delta in isolation, and the combined feature sets land here. The +// attachment read/remove + binary hydration feature that separately bumped +// main to 199KB merges within this headroom, so no further bump is needed. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 206 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 86443702df..3f6997704d 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -173,7 +173,13 @@ export class DaemonSessionClient { readonly client: DaemonClient; readonly session: DaemonSession; readonly state: DaemonSessionState; - readonly replaySnapshot: DaemonReplaySnapshot; + /** + * Not `readonly`: {@link consumeReplaySnapshot} swaps it for an empty + * snapshot once the provider has injected it into the transcript store, + * releasing the raw wire events (tens of MiB on busy sessions) instead of + * retaining them for the session client's lifetime. + */ + replaySnapshot: DaemonReplaySnapshot; readonly replaySnapshotComplete: boolean; readonly replayPartial: boolean; readonly replayError: string | undefined; @@ -407,6 +413,19 @@ export class DaemonSessionClient { return this.lastSeenEpoch; } + /** + * Returns the retained replay snapshot and drops the client's reference + * to it. Call once the snapshot has been injected into a transcript + * store; the raw wire events are no longer needed (SSE continues from + * `lastEventId`, and older history is served by pagination) and can + * otherwise pin tens of MiB per session client. + */ + consumeReplaySnapshot(): DaemonReplaySnapshot { + const snapshot = this.replaySnapshot; + this.replaySnapshot = { compactedReplay: [], liveJournal: [] }; + return snapshot; + } + setLastEventId(lastEventId: number | undefined): void { this.lastSeenEventId = validateLastEventId(lastEventId); } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index b32b7c0c91..df05ba8da7 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -100,6 +100,7 @@ export { daemonBlockToPlainText, daemonToolPreviewToMarkdown, daemonUiEventToTerminalText, + estimateDaemonTranscriptBlockBytes, extractContentPart, extractServerTimestamp, formatBlockTimestamp, @@ -107,6 +108,8 @@ export { getSessionUpdatePayload, isDaemonUiSensitiveKey, isSubagentChildBlock, + isTrimmedPermissionBlockId, + isTrimmedToolBlockId, isUnrecognizedDiagnosticReason, normalizeDaemonEvent, redactDaemonUiSensitiveFields, @@ -159,6 +162,7 @@ export type { DaemonTranscriptSidechannelState, DaemonTranscriptState, DaemonTranscriptStore, + DaemonTranscriptTruncationDetail, DaemonUiAssistantDoneEvent, DaemonUiAuthDeviceFlowAuthorizedEvent, DaemonUiAuthDeviceFlowCancelledEvent, diff --git a/packages/sdk-typescript/src/daemon/ui/chat-record-transcript.ts b/packages/sdk-typescript/src/daemon/ui/chat-record-transcript.ts index 2e21a23bec..1a549fc9ec 100644 --- a/packages/sdk-typescript/src/daemon/ui/chat-record-transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/chat-record-transcript.ts @@ -105,6 +105,13 @@ export function projectChatRecordsToDaemonTranscript( let truncated = false; let state = createDaemonTranscriptState({ maxBlocks, + // Trim-free by bytes: this offline/export projection's only documented + // truncation knob is `maxBlocks`. The retention byte budget exists for + // the LIVE session window; applying it here would silently evict older + // blocks from rendered/exported transcripts whose `maxBlocks` never + // limited them, and the truncation diagnostic would misname maxBlocks as + // the cause. + maxRetainedBytes: Number.POSITIVE_INFINITY, now: 0, onTruncation: (detail) => { truncated = true; diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index f02c81b81c..312a1d3c99 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -13,8 +13,11 @@ export { createDaemonToolPreview } from './toolPreview.js'; export { appendLocalUserTranscriptMessage, createDaemonTranscriptState, + estimateDaemonTranscriptBlockBytes, formatBlockTimestamp, isSubagentChildBlock, + isTrimmedPermissionBlockId, + isTrimmedToolBlockId, rebuildDaemonTranscriptBlockIndex, reduceDaemonTranscriptEvents, selectApprovalMode, @@ -88,6 +91,7 @@ export type { DaemonTranscriptSidechannelState, DaemonTranscriptState, DaemonTranscriptStore, + DaemonTranscriptTruncationDetail, DaemonUnrecognizedDiagnostic, DaemonUnrecognizedDiagnosticReason, // Chat-stream events diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index f417dc9588..287b5fdee8 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -22,6 +22,7 @@ import type { } from './types.js'; import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; import { + capDetails, getFirstString, getOutputText, getString, @@ -59,7 +60,6 @@ const MCP_RESTART_REFUSED_REASONS = new Set([ ]); const MALFORMED_MEMORY_CHANGED = 'malformed memory_changed payload'; -const MAX_DETAILS_LENGTH = 4096; const SESSION_RECORDING_DEGRADED_MESSAGE = 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then start a new session to resume recording.'; @@ -407,7 +407,10 @@ function normalizeUnrecognizedEvent( ...base, type: 'debug', debugReason: 'unrecognized_event', - text: `${event.type} (unrecognized daemon event): ${stringifyRedactedJson(event.data)}`, + text: debugBlockText( + `${event.type} (unrecognized daemon event)`, + event.data, + ), }, ]; } @@ -768,7 +771,7 @@ function normalizeSessionUpdate( ...base, type: 'debug', debugReason: 'malformed_payload', - text: `session_update: ${stringifyRedactedJson(event.data)}`, + text: debugBlockText('session_update', event.data), }, ]; } @@ -981,7 +984,7 @@ function normalizeSessionUpdate( debugReason: kind?.trim() ? 'unrecognized_session_update' : 'malformed_payload', - text: `${kind ?? 'session_update'}: ${stringifyRedactedJson(update)}`, + text: debugBlockText(kind ?? 'session_update', update), }, ]; } @@ -1256,9 +1259,15 @@ function asDaemonErrorKind( : undefined; } -function capDetails(details: string): string { - if (details.length <= MAX_DETAILS_LENGTH) return details; - return `${details.slice(0, MAX_DETAILS_LENGTH)}... [truncated]`; +/** + * Builds the `text` of a `debug` block that embeds an unrecognized or + * malformed payload, capped at the producer. One such block is appended per + * frame, so a high-frequency frame could otherwise accumulate 100KB blocks up + * to the transcript block cap; capping here means a future debug branch + * cannot drop the cap. + */ +function debugBlockText(prefix: string, data: unknown): string { + return capDetails(`${prefix}: ${stringifyRedactedJson(data)}`); } function normalizePermissionRequest( @@ -1271,7 +1280,7 @@ function normalizePermissionRequest( ...base, type: 'debug', debugReason: 'malformed_payload', - text: `permission_request: ${stringifyRedactedJson(event.data)}`, + text: debugBlockText('permission_request', event.data), }, ]; } @@ -1283,7 +1292,7 @@ function normalizePermissionRequest( ...base, type: 'debug', debugReason: 'malformed_payload', - text: `permission_request: ${stringifyRedactedJson(event.data)}`, + text: debugBlockText('permission_request', event.data), }, ]; } @@ -1317,7 +1326,7 @@ function normalizePermissionResolved( ...base, type: 'debug', debugReason: 'malformed_payload', - text: `${event.type}: ${stringifyRedactedJson(event.data)}`, + text: debugBlockText(event.type, event.data), }, ]; } diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index 8c66e4c4d8..4a7602e5e9 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -6,6 +6,7 @@ import type { DaemonTextDeltaMeta, + DaemonTranscriptReducerOptions, DaemonTranscriptState, DaemonTranscriptStore, DaemonUiEvent, @@ -13,14 +14,22 @@ import type { import { appendLocalUserTranscriptMessage, createDaemonTranscriptState, + estimateDaemonTranscriptBlockBytes, rebuildDaemonTranscriptBlockIndex, reduceDaemonTranscriptEvents, } from './transcript.js'; export function createDaemonTranscriptStore( - seed: Partial = {}, + seed: Partial & + Pick = {}, ): DaemonTranscriptStore { - let state = createState(seed); + // Held in the closure (not on the state object) so `reset()` keeps the + // listener registered across wholesale state replacements. + const { onTruncation, ...stateSeed } = seed; + const reducerOptions: DaemonTranscriptReducerOptions = onTruncation + ? { onTruncation } + : {}; + let state = createState(stateSeed); const listeners = new Set<() => void>(); let notifyScheduled = false; @@ -55,7 +64,7 @@ export function createDaemonTranscriptStore( dispatch(event: DaemonUiEvent | DaemonUiEvent[]) { const events = Array.isArray(event) ? event : [event]; if (events.length === 0) return; - state = reduceDaemonTranscriptEvents(state, events); + state = reduceDaemonTranscriptEvents(state, events, reducerOptions); scheduleNotify(); }, appendLocalUserMessage( @@ -74,12 +83,14 @@ export function createDaemonTranscriptStore( images, meta, files, + ...reducerOptions, }); scheduleNotify(); }, reset(nextSeed: Partial = {}) { state = createState({ maxBlocks: nextSeed.maxBlocks ?? state.maxBlocks, + maxRetainedBytes: nextSeed.maxRetainedBytes ?? state.maxRetainedBytes, retainSubagentBlocks: nextSeed.retainSubagentBlocks ?? state.retainSubagentBlocks, ...nextSeed, @@ -147,10 +158,19 @@ function createState( return { ...createDaemonTranscriptState({ maxBlocks: seed.maxBlocks, + maxRetainedBytes: seed.maxRetainedBytes, now: seed.now, }), ...seed, blocks, + // Seeded blocks (e.g. a replay snapshot handed to `reset`) must count + // toward the retention byte budget from the start. + retainedBytes: + seed.retainedBytes ?? + blocks.reduce( + (total, block) => total + estimateDaemonTranscriptBlockBytes(block), + 0, + ), blockIndexById: rebuildDaemonTranscriptBlockIndex(blocks), toolBlockByCallId: createNullIndex(seed.toolBlockByCallId), trimmedToolNotificationByCallId: createNullIndex( diff --git a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts index 64004f8956..617c6e5f4a 100644 --- a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts +++ b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts @@ -10,6 +10,7 @@ import type { DaemonTranscriptQuestionOption, } from './types.js'; import { + capDetails, getFirstString, isRecord, isSensitiveKey, @@ -338,7 +339,9 @@ function collectPreviewRows( if (isRecord(value)) continue; rows.push({ label: key, - value: isSensitiveKey(key) ? '[redacted]' : stringifyRedactedJson(value), + value: isSensitiveKey(key) + ? '[redacted]' + : capDetails(stringifyRedactedJson(value)), }); } return rows; diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 66b5e62611..1662368d8e 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -26,9 +26,17 @@ import { isUnrecognizedDiagnosticReason, } from './types.js'; import { createDaemonToolPreview } from './toolPreview.js'; -import { isRecord } from './utils.js'; +import { detachString, isRecord } from './utils.js'; const DEFAULT_MAX_BLOCKS = 1_000; +/** + * Byte budget for retained transcript blocks. Blocks carry raw tool payloads + * (up to the daemon's per-frame cap each), so the block-count window alone + * does not bound memory; trimming evicts the oldest blocks until the running + * estimate is back under this budget. The ceiling is therefore budget + one + * worst-case block. + */ +const DEFAULT_MAX_RETAINED_BYTES = 128 * 1024 * 1024; /** * Cap for the `unrecognizedDiagnostics` sidechannel. Forward-compat noise * must stay inspectable without growing unboundedly in long sessions. @@ -36,6 +44,27 @@ const DEFAULT_MAX_BLOCKS = 1_000; export const UNRECOGNIZED_DIAGNOSTICS_LIMIT = 50; const TRIMMED_TOOL_BLOCK_ID = '__trimmed_tool_block__'; const TRIMMED_PERMISSION_BLOCK_ID = '__trimmed_permission_block__'; + +/** + * True when a `toolBlockByCallId` entry is the trimmed-block sentinel (the + * original block was evicted by retention trimming). Lets consumers merge a + * pagination-resurrected real mapping without letting the stale sentinel win. + */ +export function isTrimmedToolBlockId(blockId: string | undefined): boolean { + return blockId === TRIMMED_TOOL_BLOCK_ID; +} + +/** + * True when a `permissionBlockByRequestId` entry is the trimmed-block + * sentinel (the original block was evicted by retention trimming). Lets + * consumers merge a pagination-resurrected real mapping without letting the + * stale sentinel win. + */ +export function isTrimmedPermissionBlockId( + blockId: string | undefined, +): boolean { + return blockId === TRIMMED_PERMISSION_BLOCK_ID; +} const MAX_TEXT_BLOCK_LENGTH = 100_000; const TEXT_TRUNCATED_SUFFIX = '\n[truncated]\n'; const MAX_CLONE_DEPTH = 16; @@ -70,6 +99,8 @@ export function createDaemonTranscriptState( nextOrdinal: 1, now: opts.now ?? Date.now(), maxBlocks: opts.maxBlocks ?? DEFAULT_MAX_BLOCKS, + retainedBytes: 0, + maxRetainedBytes: opts.maxRetainedBytes ?? DEFAULT_MAX_RETAINED_BYTES, retainSubagentBlocks: opts.retainSubagentBlocks ?? true, }; if (opts.onTruncation) truncationCallbacks.set(state, opts.onTruncation); @@ -281,24 +312,31 @@ function applyDaemonTranscriptEvent( break; case 'user.image.delta': { const block = userBlockForAttachment(next, event); + // Measure the retained block before the write path clones it (same + // pattern as upsertToolBlock) so merged attachments stay counted + // against the byte budget. + const bytesBefore = estimateBlockBytes(block); if (event.meta) block.meta = { ...block.meta, ...event.meta }; block.images = [ ...(block.images ?? []), { data: event.data, mimeType: event.mimeType }, ]; + next.retainedBytes += estimateBlockBytes(block) - bytesBefore; break; } case 'user.file.delta': { - const block = userBlockForAttachment(next, event); - if (event.meta) block.meta = { ...block.meta, ...event.meta }; - block.files = [ - ...(block.files ?? []), + const fileBlock = userBlockForAttachment(next, event); + const fileBytesBefore = estimateBlockBytes(fileBlock); + if (event.meta) fileBlock.meta = { ...fileBlock.meta, ...event.meta }; + fileBlock.files = [ + ...(fileBlock.files ?? []), { name: event.name, mimeType: event.mimeType, attachmentId: event.attachmentId, }, ]; + next.retainedBytes += estimateBlockBytes(fileBlock) - fileBytesBefore; break; } case 'assistant.text.delta': @@ -856,6 +894,15 @@ function upsertToolBlock( } return; } + // Measure the block as currently retained BEFORE the write path clones it + // (`getWritableBlockById` swaps in a COW clone, whose structure can estimate + // slightly differently); the delta stays exact against what is actually + // retained before and after. + const retainedIndex = + existingId !== undefined ? state.blockIndexById[existingId] : undefined; + const retainedBefore = + retainedIndex !== undefined ? state.blocks[retainedIndex] : undefined; + const bytesBefore = retainedBefore ? estimateBlockBytes(retainedBefore) : 0; const existing = getWritableBlockById(state, existingId); if (existing?.kind === 'tool') { if (event.title !== undefined) existing.title = event.title; @@ -948,6 +995,7 @@ function upsertToolBlock( if (event.subagentType && !existing.subagentType) { existing.subagentType = event.subagentType; } + state.retainedBytes += estimateBlockBytes(existing) - bytesBefore; updateCurrentToolPointer(state, event.toolCallId, event.status); return; } @@ -1032,8 +1080,17 @@ function discardToolBlock( ): void { const blockId = state.toolBlockByCallId[toolCallId]; if (!blockId || blockId === TRIMMED_TOOL_BLOCK_ID) return; + const droppedIndex = state.blockIndexById[blockId]; + const dropped = + droppedIndex !== undefined ? state.blocks[droppedIndex] : undefined; takeBlocksOwnership(state); state.blocks = state.blocks.filter((block) => block.id !== blockId); + if (dropped) { + state.retainedBytes = Math.max( + 0, + state.retainedBytes - estimateBlockBytes(dropped), + ); + } state.blockIndexById = rebuildDaemonTranscriptBlockIndex(state.blocks); ownedBlocks.set(state, state.blocks); ownedBlockIndexes.set(state, state.blockIndexById); @@ -1488,6 +1545,7 @@ function cloneTranscriptState( ...state, now: opts.now ?? Date.now(), maxBlocks: opts.maxBlocks ?? state.maxBlocks, + maxRetainedBytes: opts.maxRetainedBytes ?? state.maxRetainedBytes, retainSubagentBlocks: opts.retainSubagentBlocks ?? state.retainSubagentBlocks, // Lazy copy-on-write for @@ -1541,12 +1599,92 @@ function cloneTranscriptState( return next; } +function sharesSourceRecordId( + a: DaemonTranscriptBlock, + b: DaemonTranscriptBlock, +): boolean { + const aIds = a.sourceRecordIds; + const bIds = b.sourceRecordIds; + if (!aIds?.length || !bIds?.length) return false; + const set = new Set(aIds); + return bIds.some((recordId) => set.has(recordId)); +} + function trimTranscriptState( state: DaemonTranscriptState, ): DaemonTranscriptState { - if (state.blocks.length <= state.maxBlocks) return state; - truncationCallbacks.get(state)?.({ kind: 'blocks' }); - const blocks = state.blocks.slice(-state.maxBlocks); + const overByteBudget = state.retainedBytes > state.maxRetainedBytes; + if (state.blocks.length <= state.maxBlocks && !overByteBudget) return state; + // Count-based floor: keep at most the last `maxBlocks` blocks. Keep at least + // one block: a non-positive, non-finite, or fractional maxBlocks is a + // degenerate input that must not evict the whole window nor leave removeCount + // fractional (the record snap below indexes blocks[removeCount] and would + // read one past the end of the block array). + const effectiveMaxBlocks = Math.max( + 1, + Math.floor(Number.isFinite(state.maxBlocks) ? state.maxBlocks : 1), + ); + let removeCount = Math.max(0, state.blocks.length - effectiveMaxBlocks); + let bytes = state.retainedBytes; + for (let i = 0; i < removeCount; i += 1) { + bytes -= estimateBlockBytes(state.blocks[i]!); + } + // Byte budget: keep evicting oldest blocks until the retained estimate is + // back under the budget. The last block always survives, so the ceiling is + // budget + one worst-case block rather than strictly the budget. + while ( + removeCount < state.blocks.length - 1 && + bytes > state.maxRetainedBytes + ) { + bytes -= estimateBlockBytes(state.blocks[removeCount]!); + removeCount += 1; + } + // Snap the cut to record boundaries: one persisted record fans out into + // several blocks sharing a sourceRecordIds entry, and trimming is + // block-granular, so the boundary can land mid-record. A partially evicted + // record is unrecoverable from both directions — exclusive-before + // pagination anchored at the shared record never returns the evicted + // sibling blocks, and the recordId dedup filter drops any later page that + // still advertises the recordId. Advance the cut until it no longer lands + // inside a record, keeping the at-least-one-block floor. + while ( + removeCount > 0 && + removeCount < state.blocks.length - 1 && + sharesSourceRecordId( + state.blocks[removeCount - 1]!, + state.blocks[removeCount]!, + ) + ) { + bytes -= estimateBlockBytes(state.blocks[removeCount]!); + removeCount += 1; + } + // The forward snap can be pinned by the floor: the byte loop evicts down to + // the last block and the snap's `removeCount < len - 1` bound stops there even + // when the evicted tail shares a record with the surviving block — a + // mid-record cut the snap detects but cannot fix by advancing. Back the cut + // off the floor instead, re-retaining siblings while the boundary pair still + // shares a record so the record stays whole. A single record can fan out into + // several contiguous blocks, so this loops; when nothing is left to evict the + // `removeCount === 0` guard below keeps the whole window rather than cutting + // mid-record. This trades at most one extra retained record against the + // budget, extending the "budget + one worst-case block" ceiling the byte loop + // above already documents. + while ( + removeCount > 0 && + sharesSourceRecordId( + state.blocks[removeCount - 1]!, + state.blocks[removeCount]!, + ) + ) { + removeCount -= 1; + bytes += estimateBlockBytes(state.blocks[removeCount]!); + } + // Nothing evictable (e.g. one oversized block): skip the callback and + // rebuild. Firing `kind: 'blocks'` with zero removals records a false + // truncation and churns snapshot identity on every dispatch. + if (removeCount === 0) return state; + state.retainedBytes = Math.max(0, bytes); + const blocks = state.blocks.slice(removeCount); const keptIds = new Set(blocks.map((block) => block.id)); state.blocks = blocks; state.blockIndexById = rebuildDaemonTranscriptBlockIndex(blocks); @@ -1614,6 +1752,21 @@ function trimTranscriptState( delete state.activeThoughtBlockByParent[parentId]; } } + // Fired after the mutation completes so listeners observe the post-trim + // window (e.g. re-anchoring an exclusive pagination anchor to the oldest + // retained record — the evicted anchor can never be re-fetched). + const oldestRetainedBlock = state.blocks.find( + (block) => (block.sourceRecordIds?.length ?? 0) > 0, + ); + truncationCallbacks.get(state)?.({ + kind: 'blocks', + oldestRetainedRecordId: oldestRetainedBlock?.sourceRecordIds?.[0], + evictedOldest: true, + blockCount: state.blocks.length, + retainedBytes: state.retainedBytes, + maxBlocks: state.maxBlocks, + maxRetainedBytes: state.maxRetainedBytes, + }); return state; } @@ -1692,10 +1845,36 @@ function truncateTranscriptBeforeBlock( blockIndex: number, ): void { takeBlocksOwnership(state); + const originalLength = state.blocks.length; + let droppedBytes = 0; + for (let index = blockIndex; index < state.blocks.length; index += 1) { + const block = state.blocks[index]; + if (block) droppedBytes += estimateBlockBytes(block); + } state.blocks = state.blocks.slice(0, blockIndex); + state.retainedBytes = Math.max(0, state.retainedBytes - droppedBytes); ownedBlocks.set(state, state.blocks); rebuildTranscriptIndexes(state); ownedBlockIndexes.set(state, state.blockIndexById); + if (state.blocks.length < originalLength) { + // A rewind frees retention capacity just like an eviction does. Fire the + // same 'blocks' truncation signal so consumers reconciling a pagination + // capacity latch on freed capacity observe rewinds too. `evictedOldest` + // is false: a rewind drops the NEWEST blocks, so the oldest pagination + // anchor stays valid and must not be re-anchored. + const oldestRetainedBlock = state.blocks.find( + (block) => (block.sourceRecordIds?.length ?? 0) > 0, + ); + truncationCallbacks.get(state)?.({ + kind: 'blocks', + oldestRetainedRecordId: oldestRetainedBlock?.sourceRecordIds?.[0], + evictedOldest: false, + blockCount: state.blocks.length, + retainedBytes: state.retainedBytes, + maxBlocks: state.maxBlocks, + maxRetainedBytes: state.maxRetainedBytes, + }); + } } function rebuildTranscriptIndexes(state: DaemonTranscriptState): void { @@ -1737,6 +1916,7 @@ function appendBlock( (state.blockIndexById as Record)[block.id] = state.blocks.length; (state.blocks as DaemonTranscriptBlock[]).push(block); + state.retainedBytes += estimateBlockBytes(block); } function getWritableBlockById( @@ -1805,11 +1985,20 @@ function appendBoundedText( text: string, ): string { const existing = 'text' in block ? block.text : ''; + let next: string; if (existing.length >= MAX_TEXT_BLOCK_LENGTH) { if (text) reportTextTruncation(state, block.id, block.sourceRecordIds); - return existing; + next = existing; + } else { + next = truncateText( + state, + block.id, + block.sourceRecordIds, + existing + text, + ); } - return truncateText(state, block.id, block.sourceRecordIds, existing + text); + state.retainedBytes += (next.length - existing.length) * 2; + return next; } function truncateTextAtLimit(text: string): string { @@ -1818,7 +2007,9 @@ function truncateTextAtLimit(text: string): string { 0, MAX_TEXT_BLOCK_LENGTH - TEXT_TRUNCATED_SUFFIX.length, ); - return `${text.slice(0, keepLength)}${TEXT_TRUNCATED_SUFFIX}`; + // detach: a bare slice would keep the oversized parent string's backing + // store alive for as long as the block is retained. + return `${detachString(text.slice(0, keepLength))}${TEXT_TRUNCATED_SUFFIX}`; } function truncateText( @@ -1844,6 +2035,49 @@ function reportTextTruncation( }); } +/** + * Cheap structural size estimate of a retained value (bytes). Strings count + * as 2 bytes per UTF-16 code unit; records/arrays add a small per-entry + * overhead. Deliberately approximate: it drives the retention byte budget, + * not billing. The walk is bounded by the same depth cap as cloning. + */ +function estimateRetainedBytes(value: unknown, depth = 0): number { + if (depth > MAX_CLONE_DEPTH) return 0; + if (typeof value === 'string') return value.length * 2; + if (typeof value === 'number' || typeof value === 'boolean') return 16; + // Binary payloads (Blob/File, ArrayBuffer, typed-array/DataView views) carry + // their content in non-enumerable internal slots, so the record walk below + // would only charge the fixed object overhead for them. Charge by real binary + // size instead, or media-heavy transcripts never trip the budget — the OOM + // class this budget exists to stop. + if (typeof Blob !== 'undefined' && value instanceof Blob) return value.size; + if (value instanceof ArrayBuffer) return value.byteLength; + if (ArrayBuffer.isView(value)) return value.byteLength; + if (Array.isArray(value)) { + let total = 32; + for (const entry of value) { + total += estimateRetainedBytes(entry, depth + 1); + } + return total; + } + if (isRecord(value)) { + let total = 64; + for (const [key, entry] of Object.entries(value)) { + total += key.length * 2 + estimateRetainedBytes(entry, depth + 1); + } + return total; + } + return 0; +} + +export function estimateDaemonTranscriptBlockBytes( + block: DaemonTranscriptBlock, +): number { + return estimateRetainedBytes(block); +} + +const estimateBlockBytes = estimateDaemonTranscriptBlockBytes; + function createIndex( source?: Readonly>, ): Record { diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index f6e66e48d3..f97c024e08 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -1136,10 +1136,18 @@ export interface DaemonTranscriptState now: number; maxBlocks: number; retainSubagentBlocks: boolean; + /** + * Running estimate (bytes) of what `blocks` retains. Blocks carry raw tool + * payloads, so a block-count cap alone is not a memory ceiling; trimming + * also evicts until the estimate is back under `maxRetainedBytes`. + */ + retainedBytes: number; + maxRetainedBytes: number; } export interface DaemonTranscriptReducerOptions { maxBlocks?: number; + maxRetainedBytes?: number; now?: number; retainSubagentBlocks?: boolean; onTruncation?: (detail: DaemonTranscriptTruncationDetail) => void; @@ -1149,6 +1157,30 @@ export interface DaemonTranscriptTruncationDetail { kind: 'blocks' | 'text'; blockId?: string; sourceRecordIds?: readonly string[]; + /** + * Set for `kind: 'blocks'`: the oldest recordId still retained after the + * eviction, from the oldest retained block that carries one. Undefined when + * no retained block carries a recordId. Lets consumers reconcile exclusive + * pagination anchors with retention trimming. + */ + oldestRetainedRecordId?: string; + /** + * Set for `kind: 'blocks'`: whether the eviction removed blocks from the + * OLDEST end. True for retention trimming (oldest-first), which can evict + * the record an exclusive pagination anchor points at; false for a rewind + * (which drops the newest blocks and leaves the oldest anchor intact). + * Consumers should only re-anchor pagination when this is true. + */ + evictedOldest?: boolean; + /** + * Set for `kind: 'blocks'`: the post-trim window occupancy. Lets consumers + * decide whether a previously rejected history page would now be admitted, + * without reading a snapshot that may lag the in-flight dispatch. + */ + blockCount?: number; + retainedBytes?: number; + maxBlocks?: number; + maxRetainedBytes?: number; } export interface DaemonTranscriptStore { diff --git a/packages/sdk-typescript/src/daemon/ui/utils.ts b/packages/sdk-typescript/src/daemon/ui/utils.ts index c70f205235..6992765ab0 100644 --- a/packages/sdk-typescript/src/daemon/ui/utils.ts +++ b/packages/sdk-typescript/src/daemon/ui/utils.ts @@ -42,6 +42,28 @@ export function stringifyRedactedJson(value: unknown): string { return stringifyJson(redactSensitiveFields(value)); } +const MAX_DETAILS_LENGTH = 4096; + +/** + * Returns a copy of `value` that does not reference the input's backing + * storage. Engines such as V8 represent slices of large strings as views + * (SlicedString) that keep the parent alive, so a capped string retained on a + * transcript block would otherwise pin the entire uncapped payload and defeat + * the cap. The UTF-8 round-trip forces an independent string. + */ +export function detachString(value: string): string { + return new TextDecoder('utf-8').decode(new TextEncoder().encode(value)); +} + +/** + * Caps a rendered details string so a single unbounded payload cannot grow a + * transcript block without limit. + */ +export function capDetails(details: string): string { + if (details.length <= MAX_DETAILS_LENGTH) return details; + return `${detachString(details.slice(0, MAX_DETAILS_LENGTH))}... [truncated]`; +} + export function redactSensitiveFields(value: unknown, depth = 0): unknown { if (depth > 16) return '[truncated]'; if (Array.isArray(value)) { diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 34d7d7f7a4..41c3f1acd2 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -390,6 +390,37 @@ describe('DaemonSessionClient', () => { expect(calls[1]?.headers['last-event-id']).toBe('42'); }); + it('releases the replay snapshot once consumed', async () => { + const { fetch } = recordingFetch((req) => { + if (req.url.endsWith('/session/s-1/load')) { + return jsonResponse(200, { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + clientId: 'client-1', + state: {}, + lastEventId: 7, + compactedReplay: [{ id: 1, v: 1, type: 'session_update', data: {} }], + liveJournal: [{ id: 7, v: 1, type: 'session_update', data: {} }], + }); + } + return jsonResponse(500, { error: `unexpected ${req.url}` }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const session = await DaemonSessionClient.load(client, 's-1'); + expect(session.replaySnapshot.compactedReplay).toHaveLength(1); + expect(session.replaySnapshot.liveJournal).toHaveLength(1); + + const consumed = session.consumeReplaySnapshot(); + expect(consumed.compactedReplay).toHaveLength(1); + expect(consumed.liveJournal).toHaveLength(1); + expect(session.replaySnapshot.compactedReplay).toHaveLength(0); + expect(session.replaySnapshot.liveJournal).toHaveLength(0); + // Idempotent: a second consume returns the empty snapshot. + expect(session.consumeReplaySnapshot().compactedReplay).toHaveLength(0); + }); + it('hydrates replay images but leaves file attachments lazy', async () => { const { fetch, calls } = recordingFetch((req) => { if (req.url.endsWith('/session/s-1/load')) { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 32732714ea..6b14a4d2ae 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -11,6 +11,7 @@ import { createDaemonTranscriptState, createDaemonTranscriptStore, daemonUiEventToTerminalText, + estimateDaemonTranscriptBlockBytes, getOutputText, isDaemonUiSensitiveKey, normalizeDaemonEvent, @@ -1208,6 +1209,511 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(Object.keys(state.toolBlockByCallId)).toHaveLength(4); }); + it('evicts oldest blocks to stay under the retention byte budget', () => { + // The block-count window is not a memory ceiling: blocks carry raw tool + // payloads. With a generous block window but a tight byte budget, heavy + // blocks must still be evicted oldest-first. + const large = 'x'.repeat(20_000); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 90_000, + now: 1, + }); + for (let index = 0; index < 4; index += 1) { + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: `tool-${index}`, + title: `Tool ${index}`, + status: 'completed', + rawOutput: large, + }, + ], + { now: index + 2 }, + ); + } + + expect(state.blocks.length).toBeGreaterThan(0); + expect(state.blocks.length).toBeLessThan(4); + expect(state.retainedBytes).toBeLessThanOrEqual(90_000); + // Most recent blocks survive eviction. + const keptToolCallIds = state.blocks.map( + (block) => (block as { toolCallId?: string }).toolCallId, + ); + expect(keptToolCallIds).toContain('tool-3'); + expect(keptToolCallIds).not.toContain('tool-0'); + // The running estimate matches the blocks actually retained. + const expected = state.blocks.reduce( + (total, block) => total + estimateDaemonTranscriptBlockBytes(block), + 0, + ); + expect(state.retainedBytes).toBe(expected); + }); + + it('backs the record-boundary snap off the floor instead of cutting mid-record (R12-21)', () => { + // One persisted record can fan out into several blocks. When byte pressure + // evicts down to the last block, the forward snap's at-least-one-block + // floor stops it from advancing — even when the evicted penultimate block + // shares the record with the survivor, which would ship a mid-record cut. + // The snap must back off the floor and keep the record whole instead. + const large = 'x'.repeat(100_000); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + // Fits one ~100 KB block but not both, so the byte loop evicts to the + // floor (one block left) and the snap is pinned there. + maxRetainedBytes: 150_000, + now: 1, + }); + for (const toolCallId of ['tool-a', 'tool-b']) { + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId, + title: `Tool ${toolCallId}`, + status: 'completed', + rawOutput: large, + sourceRecordIds: ['record-x'], + }, + ], + { now: 2 }, + ); + } + + // Both siblings of record-x stay: evicting only tool-a would leave a + // mid-record cut that exclusive-before pagination can never re-fetch. + const keptToolCallIds = state.blocks.map( + (block) => (block as { toolCallId?: string }).toolCallId, + ); + expect(keptToolCallIds).toEqual(['tool-a', 'tool-b']); + expect(state.blocks).toHaveLength(2); + for (const block of state.blocks) { + expect(block.sourceRecordIds).toContain('record-x'); + } + }); + + it('backs the record-boundary snap off the floor across a 3-block record (R12-21)', () => { + // A single record fans out into 3+ contiguous blocks; the floor back-off + // must loop (not stop after one block) or the tail is still cut mid-record. + const large = 'x'.repeat(100_000); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 150_000, + now: 1, + }); + for (const toolCallId of ['tool-a', 'tool-b', 'tool-c']) { + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId, + title: `Tool ${toolCallId}`, + status: 'completed', + rawOutput: large, + sourceRecordIds: ['record-x'], + }, + ], + { now: 2 }, + ); + } + // All three record-x siblings stay — the loop re-retains tool-a and tool-b + // after the byte loop pins the cut to the floor. + expect( + state.blocks.map( + (block) => (block as { toolCallId?: string }).toolCallId, + ), + ).toEqual(['tool-a', 'tool-b', 'tool-c']); + }); + + it('counts Blob-backed file payloads against the retention budget', () => { + // Blob/File payloads live in non-enumerable internal slots, so a plain + // record walk would only charge the fixed object overhead — an 8 MiB file + // would count as ~64 bytes and the byte budget would never fire. Charge + // their real size instead. + const eightMiB = new Blob([new Uint8Array(8 * 1024 * 1024)]); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 100_000_000, + now: 1, + }); + state = appendLocalUserTranscriptMessage(state, '', { + files: [ + { + name: 'big.bin', + mimeType: 'application/octet-stream', + data: eightMiB, + }, + ], + }); + expect(state.retainedBytes).toBeGreaterThanOrEqual(8 * 1024 * 1024); + }); + + it('evicts Blob-backed file blocks that cross the retention budget', () => { + const fourMiB = new Blob([new Uint8Array(4 * 1024 * 1024)]); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 9 * 1024 * 1024, + now: 1, + }); + for (let index = 0; index < 4; index += 1) { + state = appendLocalUserTranscriptMessage(state, `attach ${index}`, { + files: [ + { + name: `f${index}.bin`, + mimeType: 'application/octet-stream', + data: fourMiB, + }, + ], + }); + } + // 4 × 4 MiB = 16 MiB > 9 MiB budget: the byte trim must fire only because + // the Blob payloads are counted. Without it, retainedBytes would stay at + // the fixed record overhead and no block would be evicted. + expect(state.blocks.length).toBeLessThan(4); + expect(state.retainedBytes).toBeGreaterThan(4 * 1024 * 1024); + }); + + it('tolerates a degenerate maxBlocks without crashing the trim (R14-1)', () => { + // maxBlocks is a public option with no validated lower bound. A + // non-positive value must not evict down to zero blocks — the record snap + // would then read one past the end of the block array and throw on every + // dispatch. Keep at least one block instead. + const store = createDaemonTranscriptStore({ maxBlocks: 0 }); + expect(() => + store.dispatch({ type: 'user.text.delta', text: 'survives' }), + ).not.toThrow(); + expect(store.getSnapshot().blocks).toHaveLength(1); + // A second dispatch must not throw either (the crash repeated on every + // dispatch before the floor clamp). + expect(() => + store.dispatch({ type: 'user.text.delta', text: 'still alive' }), + ).not.toThrow(); + expect(store.getSnapshot().blocks.length).toBeGreaterThanOrEqual(1); + + // A positive fractional maxBlocks must also be integral-ized (R15-1): + // without the floor, removeCount went fractional and the record snap read a + // fractional index one past the end, throwing on later dispatches. + const fractionalStore = createDaemonTranscriptStore({ maxBlocks: 2.5 }); + for (let index = 0; index < 5; index += 1) { + expect(() => + fractionalStore.dispatch({ + type: 'user.text.delta', + text: `delta ${index}`, + }), + ).not.toThrow(); + } + expect(fractionalStore.getSnapshot().blocks.length).toBeGreaterThanOrEqual( + 1, + ); + }); + + it('keeps the retention estimate current when a tool payload is replaced', () => { + let state = createDaemonTranscriptState({ + maxBlocks: 10, + maxRetainedBytes: 10_000_000, + now: 1, + }); + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: 'tool-a', + title: 'Tool', + status: 'running', + rawOutput: 'small', + }, + ], + { now: 2 }, + ); + const before = state.retainedBytes; + expect(before).toBeGreaterThan(0); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: 'tool-a', + status: 'completed', + rawOutput: 'y'.repeat(50_000), + }, + ], + { now: 3 }, + ); + + expect(state.blocks).toHaveLength(1); + expect(state.retainedBytes).toBeGreaterThan(before); + expect(state.retainedBytes).toBe( + estimateDaemonTranscriptBlockBytes(state.blocks[0]!), + ); + }); + + it('applies the default retention budget when none is configured', () => { + // The eviction comparison degrades to "never fires" if the creation-time + // default is dropped, so pin the exact value for callers that rely on it. + expect(createDaemonTranscriptState().maxRetainedBytes).toBe( + 128 * 1024 * 1024, + ); + expect(createDaemonTranscriptStore().getSnapshot().maxRetainedBytes).toBe( + 128 * 1024 * 1024, + ); + }); + + it('keeps a configured retention budget across store reset', () => { + // reset() carries maxBlocks and retainSubagentBlocks forward from the + // current state; maxRetainedBytes must behave the same or a custom + // budget silently reverts to the default on every replay/session switch. + const store = createDaemonTranscriptStore({ maxRetainedBytes: 5_000_000 }); + expect(store.getSnapshot().maxRetainedBytes).toBe(5_000_000); + store.reset(); + expect(store.getSnapshot().maxRetainedBytes).toBe(5_000_000); + }); + + it('keeps a seeded truncation listener across dispatches and resets', () => { + // Store consumers (e.g. the session provider reconciling its pagination + // anchor with eviction) register onTruncation through the seed; the + // listener must fire on evictions and survive reset(), which replaces + // the state wholesale. + const onTruncation = vi.fn(); + const store = createDaemonTranscriptStore({ maxBlocks: 2, onTruncation }); + const toolEvent = (index: number) => ({ + type: 'tool.update' as const, + toolCallId: `tool-${index}`, + title: `Tool ${index}`, + status: 'completed' as const, + sourceRecordIds: [`record-${index}`], + }); + store.dispatch([toolEvent(0), toolEvent(1)]); + expect(onTruncation).not.toHaveBeenCalled(); + store.dispatch(toolEvent(2)); + expect(onTruncation).toHaveBeenCalledTimes(1); + expect(onTruncation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'blocks', + oldestRetainedRecordId: 'record-1', + }), + ); + + onTruncation.mockClear(); + store.reset({ maxBlocks: 2 }); + store.dispatch([toolEvent(3), toolEvent(4)]); + expect(onTruncation).not.toHaveBeenCalled(); + store.dispatch(toolEvent(5)); + expect(onTruncation).toHaveBeenCalledTimes(1); + expect(onTruncation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'blocks', + oldestRetainedRecordId: 'record-4', + }), + ); + }); + + it('counts every image merged into a user block against the retention budget', () => { + const data = 'I'.repeat(100_000); + let state = createDaemonTranscriptState({ + maxBlocks: 10, + maxRetainedBytes: 10_000_000, + now: 1, + }); + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'user.image.delta', + data, + mimeType: 'image/png', + sourceRecordIds: ['record-1'], + }, + { + type: 'user.image.delta', + data, + mimeType: 'image/png', + sourceRecordIds: ['record-1'], + }, + ], + { now: 2 }, + ); + + expect(state.blocks).toHaveLength(1); + const block = state.blocks[0]!; + expect((block as { images?: unknown[] }).images).toHaveLength(2); + expect(state.retainedBytes).toBe(estimateDaemonTranscriptBlockBytes(block)); + }); + + it('releases the retention budget when a rewind drops blocks', () => { + const large = 'x'.repeat(100_000); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 1_000_000, + now: 1, + }); + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'user.text.delta', text: 'turn zero' }], + { now: 2 }, + ); + for (let index = 0; index < 3; index += 1) { + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: `tool-${index}`, + title: `Tool ${index}`, + status: 'completed', + rawOutput: large, + }, + ], + { now: index + 3 }, + ); + } + expect(state.blocks).toHaveLength(4); + expect(state.retainedBytes).toBeGreaterThan(0); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'session.rewound', promptId: 'prompt-1', targetTurnIndex: 0 }], + { now: 9 }, + ); + expect(state.blocks).toHaveLength(0); + expect(state.retainedBytes).toBe(0); + + // Ghost bytes from the dropped blocks must not collapse later growth: + // with an inflated counter every dispatch looks over budget and evicts + // the freshly appended blocks down to the last survivor. + for (let index = 0; index < 3; index += 1) { + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: `post-${index}`, + title: `Post ${index}`, + status: 'completed', + rawOutput: large, + }, + ], + { now: index + 10 }, + ); + } + expect( + state.blocks.map( + (block) => (block as { toolCallId?: string }).toolCallId, + ), + ).toEqual(['post-0', 'post-1', 'post-2']); + const expected = state.blocks.reduce( + (total, block) => total + estimateDaemonTranscriptBlockBytes(block), + 0, + ); + expect(state.retainedBytes).toBe(expected); + }); + + it('releases the retention budget when a subagent tool block is discarded', () => { + let state = createDaemonTranscriptState({ + maxBlocks: 10, + maxRetainedBytes: 10_000_000, + retainSubagentBlocks: false, + now: 1, + }); + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: 'sub-1', + title: 'Subagent', + status: 'running', + rawOutput: 'y'.repeat(50_000), + }, + ], + { now: 2 }, + ); + expect(state.blocks).toHaveLength(1); + expect(state.retainedBytes).toBeGreaterThan(0); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: 'sub-1', + parentToolCallId: 'parent-1', + status: 'completed', + }, + ], + { now: 3 }, + ); + expect(state.blocks).toHaveLength(0); + expect(state.retainedBytes).toBe(0); + }); + + it('accounts streamed assistant text against the retention byte budget', () => { + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 250_000, + now: 1, + }); + const chunk = 'A'.repeat(25_000); + for (let blockIndex = 0; blockIndex < 2; blockIndex += 1) { + for (let chunkIndex = 0; chunkIndex < 4; chunkIndex += 1) { + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.text.delta', text: chunk }], + { now: blockIndex * 10 + chunkIndex + 2 }, + ); + } + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'end_turn' }], + { now: blockIndex * 10 + 8 }, + ); + } + + // Each text block caps at 100k chars (~200KB estimated), so the 250KB + // budget cannot hold both streamed blocks and the older one is evicted. + expect(state.blocks).toHaveLength(1); + expect(state.retainedBytes).toBeLessThanOrEqual(250_000); + const expected = state.blocks.reduce( + (total, block) => total + estimateDaemonTranscriptBlockBytes(block), + 0, + ); + expect(state.retainedBytes).toBe(expected); + }); + + it('does not signal truncation when the byte budget cannot evict anything', () => { + const onTruncation = vi.fn(); + let state = createDaemonTranscriptState({ + maxBlocks: 10, + maxRetainedBytes: 100, + now: 1, + onTruncation, + }); + // A single block whose estimate exceeds the budget can never be evicted + // (the last block always survives). + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'status', text: 'x'.repeat(100) }], + { now: 2 }, + ); + expect(state.blocks).toHaveLength(1); + onTruncation.mockClear(); + const blocksBefore = state.blocks; + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'session.metadata.changed', sessionId: 'session-1' }], + { now: 3 }, + ); + + expect(onTruncation).not.toHaveBeenCalled(); + expect(state.blocks).toBe(blocksBefore); + }); + it('keeps active assistant text open when reporting trimmed tool updates', () => { let state = createDaemonTranscriptState({ maxBlocks: 2, now: 1 }); @@ -2328,6 +2834,24 @@ describe('daemon UI normalizer and transcript reducer', () => { }); }); + it('caps oversized generic key_value preview values', () => { + // A generic tool input whose only usable field is one large primitive + // string must not embed the full value in the preview row — the row value + // is capped like other rendered detail strings. + const preview = createDaemonToolPreview({ + someBigTextField: 'x'.repeat(100_000), + }); + expect(preview.kind).toBe('key_value'); + const row = ( + preview as { rows?: Array<{ label: string; value: string }> } + ).rows?.find((entry) => entry.label === 'someBigTextField'); + expect(row).toBeDefined(); + expect(row?.value.length).toBeLessThanOrEqual( + 4096 + '... [truncated]'.length, + ); + expect(row?.value.endsWith('... [truncated]')).toBe(true); + }); + it('recognizes common secret-key aliases before rendering previews', () => { expect( [ @@ -2662,6 +3186,59 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { ]); }); + it('caps the embedded payload of unrecognized session_update debug text', () => { + // One transcript block per such frame is appended, so an unrecognized + // kind streaming at high frequency must not embed its full payload + // (up to 100KB wire frames) — the text is capped like tool details. + const events = normalizeDaemonEvent( + envelopeOf('session_update', { + update: { + sessionUpdate: 'some_future_kind', + payload: 'x'.repeat(100_000), + }, + }), + ); + + expect(events).toHaveLength(1); + const text = (events[0] as { text?: string }).text ?? ''; + expect(text.length).toBeLessThanOrEqual(4096 + '... [truncated]'.length); + expect(text.endsWith('... [truncated]')).toBe(true); + }); + + it('caps the embedded payload of the sibling debug-block paths too', () => { + // The one-block-per-frame accumulation hazard is not specific to the + // unrecognized session_update branch: every debug path that embeds the + // raw payload must stay capped, or a high-frequency frame taking any + // sibling path reproduces the same unbounded-growth OOM. + const large = 'x'.repeat(100_000); + const expectCapped = (events: ReturnType) => { + expect(events).toHaveLength(1); + const text = (events[0] as { text?: string }).text ?? ''; + expect(text.length).toBeLessThanOrEqual(4096 + '... [truncated]'.length); + expect(text.endsWith('... [truncated]')).toBe(true); + }; + + // Top-level unrecognized event type. + expectCapped( + normalizeDaemonEvent(envelopeOf('some_future_event', { payload: large })), + ); + // session_update with a non-record payload (no usable update record). + expectCapped(normalizeDaemonEvent(envelopeOf('session_update', large))); + // permission_request with a non-record payload and one without requestId. + expectCapped(normalizeDaemonEvent(envelopeOf('permission_request', large))); + expectCapped( + normalizeDaemonEvent( + envelopeOf('permission_request', { payload: large }), + ), + ); + // permission_resolved without requestId. + expectCapped( + normalizeDaemonEvent( + envelopeOf('permission_resolved', { payload: large }), + ), + ); + }); + it('classifies a session_update with no usable discriminator as malformed', () => { // `getSessionUpdatePayload` accepts any record, so these reach the default // branch with `kind === undefined`. They are broken frames, not kinds from diff --git a/packages/web-shell/client/components/artifacts/SubagentDetail.tsx b/packages/web-shell/client/components/artifacts/SubagentDetail.tsx index da1057a073..453810e53e 100644 --- a/packages/web-shell/client/components/artifacts/SubagentDetail.tsx +++ b/packages/web-shell/client/components/artifacts/SubagentDetail.tsx @@ -6,6 +6,7 @@ import { } from '@qwen-code/webui/daemon-react-sdk'; import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon'; import type { ACPToolCall, Message } from '../../adapters/types'; +import { WEB_SHELL_MAX_TRANSCRIPT_BLOCKS } from '../../constants/sessions'; import { useAnimationFrameTranscriptBlocks } from '../../hooks/useAnimationFrameTranscriptBlocks'; import { useMessagesFromBlocks } from '../../hooks/useMessages'; import { useSessionArtifacts } from '../../hooks/useSessionArtifacts'; @@ -365,6 +366,7 @@ export function SubagentDetail({ workspaceCwd={workspaceCwd} clientId={instance.clientId} maxQueued={256} + maxBlocks={WEB_SHELL_MAX_TRANSCRIPT_BLOCKS} subagentTranscriptMode="full" suppressOwnUserEcho > diff --git a/packages/web-shell/client/constants/sessions.test.ts b/packages/web-shell/client/constants/sessions.test.ts new file mode 100644 index 0000000000..8471e0353e --- /dev/null +++ b/packages/web-shell/client/constants/sessions.test.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { DAEMON_SESSION_DEFAULT_MAX_BLOCKS } from '@qwen-code/webui/daemon-react-sdk'; +import { WEB_SHELL_MAX_TRANSCRIPT_BLOCKS } from './sessions'; + +describe('web-shell session constants', () => { + it('keeps the transcript window aligned with the provider default', () => { + // Web Shell passes its own maxBlocks to every DaemonSessionProvider it + // mounts; if the provider default moves, this copy must move with it + // (see WEB_SHELL_MAX_TRANSCRIPT_BLOCKS). + expect(WEB_SHELL_MAX_TRANSCRIPT_BLOCKS).toBe( + DAEMON_SESSION_DEFAULT_MAX_BLOCKS, + ); + }); +}); diff --git a/packages/web-shell/client/constants/sessions.ts b/packages/web-shell/client/constants/sessions.ts index 50efec588b..0fb9188a55 100644 --- a/packages/web-shell/client/constants/sessions.ts +++ b/packages/web-shell/client/constants/sessions.ts @@ -26,11 +26,14 @@ export const WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS = 500; * (the main chat and each split pane). The daemon stays the authoritative * full-transcript source; this only caps the client's in-memory window. * - * The SDK default (200_000) is far beyond what the virtualized message list - * renders, and it inflates both the per-dispatch reducer cost (a full - * block-array copy) and the full-list message normalization. On a large - * transcript that turns a burst of buffered SSE events — e.g. the stream - * catching up when the tab returns from being hidden — into a multi-minute - * main-thread block. Bounding the window keeps very long sessions responsive. + * Intentionally equal to the provider's `DEFAULT_MAX_BLOCKS`; the equality is + * enforced by `sessions.test.ts` (importing the constant here instead of the + * provider would pull the webui barrel into every importer's module graph and + * break the enumerative `daemon-react-sdk` mocks in component tests). Bounding + * the window keeps very long sessions responsive: the per-dispatch reducer + * cost (a full block-array copy) and the full-list message normalization turn + * a burst of buffered SSE events — e.g. the stream catching up when the tab + * returns from being hidden — into a long main-thread block on large + * transcripts. */ export const WEB_SHELL_MAX_TRANSCRIPT_BLOCKS = 50_000; diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/webui/src/daemon-react-sdk.ts index b87f45d4b0..5336eb82e8 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/webui/src/daemon-react-sdk.ts @@ -31,6 +31,13 @@ */ export { DaemonSessionProvider } from './daemon/index.js'; +/** + * Default transcript block-count window applied by `DaemonSessionProvider` + * when no `maxBlocks` prop is given. Exported so UI surfaces can reference + * the provider default instead of hard-coding a copy that can drift. + */ +export { DEFAULT_MAX_BLOCKS as DAEMON_SESSION_DEFAULT_MAX_BLOCKS } from './daemon/session/index.js'; + /** * Wraps children with workspace-level daemon context. * Provides access to cross-session resources: tools, skills, MCP servers, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index f17998fd3e..f341a8df0f 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -22,6 +22,7 @@ import type { } from '@qwen-code/sdk/daemon'; import { DaemonHttpError, + estimateDaemonTranscriptBlockBytes, UNRECOGNIZED_DIAGNOSTICS_LIMIT, } from '@qwen-code/sdk/daemon'; import { @@ -114,6 +115,10 @@ interface MockSession { compactedReplay: DaemonEvent[]; liveJournal: DaemonEvent[]; }; + consumeReplaySnapshot: () => { + compactedReplay: DaemonEvent[]; + liveJournal: DaemonEvent[]; + }; events: (opts?: { signal?: AbortSignal; maxQueued?: number; @@ -4373,11 +4378,16 @@ describe('DaemonSessionProvider', () => { const sdk = await import('@qwen-code/sdk/daemon'); const realCreateStore = sdk.createDaemonTranscriptStore; const replayDispatchBatchSizes: number[] = []; + // The first store created is the provider's main store; every later + // store in this flow is the replay rebuild store (replay now rebuilds + // under the same maxBlocks cap, so the seed no longer identifies it). + let createStoreCalls = 0; const createStoreSpy = vi .spyOn(sdk, 'createDaemonTranscriptStore') .mockImplementation((seed) => { const store = realCreateStore(seed); - if (seed?.maxBlocks === Number.MAX_SAFE_INTEGER) { + createStoreCalls += 1; + if (createStoreCalls > 1) { const realDispatch = store.dispatch.bind(store); store.dispatch = (event) => { replayDispatchBatchSizes.push( @@ -4533,6 +4543,115 @@ describe('DaemonSessionProvider', () => { }, ); + it('keeps replay-derived pagination state across a same-session reconnect', async () => { + // Regression coverage: once consumeReplaySnapshot() releases the + // snapshot, a same-session reconnect (stream-end resubscribe) recomputes + // the history inputs empty — firstPersistedRecordId degrades to + // historyAnchorRecordId and replayHistoryWasTruncated to false. The + // history state the original injection initialized (hasMore / + // beforeRecordId derived from the replay window) must survive instead of + // being clobbered with the degraded recomputation, which would make + // older history unloadable until a page reload. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const events = vi.fn(async function* endOnceThenIdle( + opts: { signal?: AbortSignal } = {}, + ) { + if (events.mock.calls.length === 1) { + return; + } + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }); + const session = createMockSession({ + sessionId: 'session-pagination-reconnect', + // historyHasMore / historyAnchorRecordId stay at their defaults + // (false / undefined): pagination state derives solely from the replay + // window, so a clobbering recomputation would drop both. + replaySnapshot: { + compactedReplay: [ + { + v: 1, + type: 'history_truncated', + data: { + reason: 'replay_window_exceeded', + truncatedEvents: 4, + retainedEvents: 2, + maxBytes: 512, + fullTranscriptAvailable: true, + }, + }, + { + id: 5, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'retained replay' }, + _meta: { 'qwen.session.recordId': 'record-retained' }, + }, + }, + }, + ], + liveJournal: [], + }, + events, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [], + hasMore: false, + }); + let history: ReturnType | undefined; + + function Harness() { + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + historyPageSize: 25, + }); + await act(async () => { + await wait(5); + await flushPromises(); + }); + + // The stream ended once and the provider resubscribed the same session... + expect(events).toHaveBeenCalledTimes(2); + expect(events.mock.calls[1]?.[0]).toMatchObject({ + sseConnectReason: 'stream_end', + }); + // ...and the pagination state initialized by the replay injection + // survives the reconnect even though the snapshot was consumed. + expect(history?.hasMore).toBe(true); + await act(async () => history?.loadMore()); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledWith( + session.sessionId, + { + beforeRecordId: 'record-retained', + limit: 25, + clientId: session.clientId, + }, + ); + }); + it('keeps history-sourced unrecognized diagnostics on the sidechannel when paging (#8823)', async () => { // A session recorded by a newer daemon is exactly the forward-compat // case the sidechannel exists for: unknown persisted session_update @@ -5753,17 +5872,14 @@ describe('DaemonSessionProvider', () => { expect(blocks.length).toBeLessThanOrEqual(3); expect(JSON.stringify(blocks)).not.toContain('partial tail'); - expect(history?.hasMore).toBe(true); - await act(async () => history?.loadMore()); - expect(sdkMocks.getSessionTranscriptPage).toHaveBeenNthCalledWith( - 2, - initialSession.sessionId, - { - beforeRecordId: 'record-fresh-anchor', - limit: 100, - clientId: initialSession.clientId, - }, - ); + // R11-12: the bounded rebuild evicts the fresh snapshot's anchor record + // (record-fresh-anchor), and no retained block carries a recordId, so the + // re-anchor is uncomputable. Anchoring to the evicted record would leave + // it silently unreachable (exclusive-before never returns the anchor), so + // the affordance fails closed instead of paging from a stale anchor. + expect(history?.hasMore).toBe(false); + expect(history?.capacityReached).toBe(true); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); }); it.each([ @@ -12434,6 +12550,209 @@ describe('DaemonSessionProvider', () => { expect(history?.hasMore).toBe(false); }); + it('counts prepended history pages against the retention byte estimate', async () => { + // Pagination prepends blocks carrying raw tool payloads; the running + // retained-bytes estimate must grow with them or later live growth trims + // against an undercounted counter and exceeds the retention budget. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const replayEvent = ( + id: number, + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + _meta: { 'qwen.session.recordId': recordId }, + }, + }, + }); + const toolEvent = ( + id: number, + toolCallId: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId, + title: `Tool ${toolCallId}`, + status: 'completed', + rawInput: { payload: 'P'.repeat(100_000) }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-history-bytes', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [replayEvent(2, 'recent prompt', 'record-2')], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [ + toolEvent(0, 'tool-older-0', 'record-0'), + toolEvent(1, 'tool-older-1', 'record-1'), + ], + hasMore: false, + }); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + let retainedBytes = 0; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + retainedBytes = useDaemonTranscriptState().retainedBytes; + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + }); + const retainedBefore = retainedBytes; + expect(retainedBefore).toBeGreaterThan(0); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + + expect(blocks).toHaveLength(3); + const expected = blocks.reduce( + (total, block) => total + estimateDaemonTranscriptBlockBytes(block), + 0, + ); + expect(retainedBytes).toBeGreaterThan(retainedBefore); + expect(retainedBytes).toBe(expected); + }); + + it('rejects a history page that would overflow the retention byte budget', async () => { + // Pagination admission must be byte-budget-aware, not just block-count + // aware: merging a page that pushes the retained estimate over the + // budget leaves it over budget while the session is idle, and the next + // live trim evicts the freshly prepended oldest records, which the + // exclusive pagination anchor can never re-fetch — a permanent silent + // gap. The whole page is rejected atomically instead, mirroring the + // block-cap rejection. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const replayEvent = ( + id: number, + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + _meta: { 'qwen.session.recordId': recordId }, + }, + }, + }); + // ~10 MB estimated per block; 14 of them cross the default 128 MiB + // retention budget, so admitting the page would overflow it. + const heavyToolEvent = (id: number, index: number): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: `tool-${index}`, + title: `Tool ${index}`, + status: 'completed', + rawInput: { payload: `page-${index}-` + 'A'.repeat(5_000_000) }, + _meta: { + 'qwen.session.recordId': `record-${index}`, + qwenTranscript: { sourceRecordIds: [`record-${index}`] }, + }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-history-heavy-page', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [replayEvent(20, 'recent prompt', 'record-recent')], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: Array.from({ length: 14 }, (_, index) => + heavyToolEvent(index + 1, index), + ), + hasMore: true, + }); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + let retainedBytes = 0; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + retainedBytes = useDaemonTranscriptState().retainedBytes; + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + }); + const retainedBefore = retainedBytes; + expect(retainedBefore).toBeGreaterThan(0); + // The page alone (~140 MB estimated) exceeds the whole 128 MiB byte + // budget, so it can never be admitted in any occupancy state — a + // terminal pagination failure rather than a re-openable capacity latch + // (which would re-offer the same doomed page on every trim and hide + // everything older than it). + await act(async () => { + await expect(history?.loadMore()).rejects.toThrow( + 'Earlier history page exceeds the transcript retention window', + ); + await flushPromises(); + }); + + // Nothing from the page merges, the byte counter is untouched, and the + // failure latches as a terminal pagination error. + expect(blocks.filter((block) => block.kind === 'tool')).toHaveLength(0); + expect(blocks).toHaveLength(1); + expect(retainedBytes).toBe(retainedBefore); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: false, + paginationError: true, + }); + }); + it('drops fetched transcript events whose records are already displayed', async () => { // The pagination anchor can sit inside the retained window (e.g. the // daemon's transcript backfill for a live-journal overflow returns the @@ -12956,7 +13275,7 @@ describe('DaemonSessionProvider', () => { expect(notices.at(-1)).toBeUndefined(); }); - it('keeps an oversized initial replay intact and stops older pagination', async () => { + it('trims an oversized initial replay to the block cap and re-anchors older pagination', async () => { sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', features: ['session_transcript_pagination'], @@ -12974,7 +13293,10 @@ describe('DaemonSessionProvider', () => { update: { sessionUpdate: kind, content: { type: 'text', text }, - _meta: { 'qwen.session.recordId': recordId }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, }, }, }); @@ -12990,6 +13312,12 @@ describe('DaemonSessionProvider', () => { }, }); sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [], + hasMore: false, + }); let history: ReturnType | undefined; let blocks: readonly DaemonTranscriptBlock[] = []; @@ -13005,10 +13333,462 @@ describe('DaemonSessionProvider', () => { maxBlocks: 1, }); + // The rebuild keeps the most recent blocks within the cap instead of + // ratcheting the cap up to the replay size (which retained unbounded + // history and exhausted renderer memory on busy sessions). The trim is + // surfaced via capacityReached, and the anchor re-bases to the oldest + // RETAINED record: the pre-trim anchor (record-1) was evicted, and an + // exclusive anchor pointing at it could never re-fetch the trimmed + // stretch. expect(blocks).toMatchObject([ - { kind: 'user', text: 'recent prompt' }, { kind: 'assistant', text: 'recent answer' }, ]); + expect(history).toMatchObject({ + hasMore: true, + loading: false, + capacityReached: true, + }); + expect(sdkMocks.getSessionTranscriptPage).not.toHaveBeenCalled(); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledWith( + session.sessionId, + { + beforeRecordId: 'record-2', + limit: 25, + clientId: session.clientId, + }, + ); + }); + + it('surfaces capacity and re-anchors pagination when the rebuild count-trims a live session', async () => { + // Count corner: live (non-restored) sessions report historyHasMore=false, + // so the capacity latch must not be gated on it — the trimmed stretch is + // persisted daemon-side and fetchable through pagination. Pre-fix the + // trim stayed silent (no indicator, no load-older affordance) and the + // anchor pointed inside the trimmed span. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const event = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-rebuild-count-trim', + // historyHasMore stays at its false default: live session shape. + replaySnapshot: { + compactedReplay: [ + event(1, 'user_message_chunk', 'turn one prompt', 'record-1'), + event(2, 'agent_message_chunk', 'turn one answer', 'record-2'), + event(3, 'user_message_chunk', 'turn two prompt', 'record-3'), + event(4, 'agent_message_chunk', 'turn two answer', 'record-4'), + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [event(0, 'user_message_chunk', 'older prompt', 'record-0')], + hasMore: false, + }); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 2, + }); + + expect(blocks).toMatchObject([ + { kind: 'user', text: 'turn two prompt' }, + { kind: 'assistant', text: 'turn two answer' }, + ]); + expect(history).toMatchObject({ + hasMore: true, + loading: false, + capacityReached: true, + }); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + // Anchored at the oldest RETAINED record, not the evicted pre-trim one + // (record-1): pagination is exclusive of the anchor. The page does not + // fit the saturated window, so the atomic rejection re-latches. + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledWith( + session.sessionId, + { + beforeRecordId: 'record-3', + limit: 25, + clientId: session.clientId, + }, + ); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + }); + + it('surfaces capacity and re-anchors when the rebuild trims on the byte budget', async () => { + // Byte corner: the rebuild store runs under the default 128 MiB budget. + // Two heavy replay blocks cross it INSIDE the block cap, so a + // count-only saturation check never fires; the eviction is observable + // only through the truncation listener. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + // ~90 MB estimated per block; the second crosses the 128 MiB budget. + const heavyToolEvent = (id: number, recordId: string): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: recordId, + title: `Heavy ${recordId}`, + status: 'completed', + rawInput: { payload: 'A'.repeat(45_000_000) }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-rebuild-byte-trim', + replaySnapshot: { + compactedReplay: [ + heavyToolEvent(1, 'record-heavy-0'), + heavyToolEvent(2, 'record-heavy-1'), + { + id: 3, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'recent prompt' }, + _meta: { 'qwen.session.recordId': 'record-recent' }, + }, + }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [], + hasMore: false, + }); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + }); + + // The oldest heavy block was evicted mid-rebuild while the block count + // stayed far below the cap... + expect(blocks.filter((block) => block.kind === 'tool')).toHaveLength(1); + expect(blocks).toMatchObject([ + { kind: 'tool' }, + { kind: 'user', text: 'recent prompt' }, + ]); + // ...and the trim surfaced with the anchor re-based to the oldest + // retained record. + expect(history).toMatchObject({ + hasMore: true, + loading: false, + capacityReached: true, + }); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledWith( + session.sessionId, + { + beforeRecordId: 'record-heavy-1', + limit: 25, + clientId: session.clientId, + }, + ); + }); + + it('grows the latched rejected-page footprint by a re-anchoring trim’s evicted band and keeps the latch closed', async () => { + // A byte-budget rejection stores the rejected page's footprint. A later + // re-anchoring eviction trim re-serves the evicted band on the next fetch, + // so the latched footprint must grow by that band; when the grown page no + // longer fits, the latch stays closed (no fetch/reject churn, no spurious + // terminal failure). + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const replayEvent = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const liveGate = createDeferred(); + const session = createMockSession({ + sessionId: 'session-live-trim-anchor', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + replayEvent(1, 'user_message_chunk', 'a'.repeat(1100), 'record-1'), + replayEvent(2, 'agent_message_chunk', 'b'.repeat(100), 'record-2'), + ], + liveJournal: [], + }, + events: async function* liveEventsAfterGate( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + liveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield replayEvent( + 3, + 'user_message_chunk', + 'z'.repeat(450), + 'record-live-1', + ); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + const olderPage = { + v: 1 as const, + sessionId: session.sessionId, + events: [ + replayEvent(10, 'user_message_chunk', 'p'.repeat(400), 'record-a'), + ], + hasMore: false, + }; + sdkMocks.getSessionTranscriptPage.mockResolvedValue(olderPage); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 8, + maxRetainedBytes: 3600, + }); + + // The page alone fits the budget, but not on top of the current window: + // rejected, with its footprint recorded. + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(blocks.map((block) => block.sourceRecordIds?.[0])).toEqual([ + 'record-1', + 'record-2', + ]); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); + + // Live growth crosses the budget; the trim evicts record-1 and re-anchors + // to record-2. A faithful daemon re-serves the evicted record-1 on the next + // exclusive-before fetch (it is persisted and now before the anchor), so + // the re-anchored page is record-1 + record-a — larger than the latched + // footprint. The fix grows the latched footprint by the evicted band, so the + // re-open gate sees that grown page no longer fits and keeps the latch + // closed instead of re-opening into a fetch/reject churn (or misclassifying + // the now-larger page as a terminal failure). + await act(async () => { + liveGate.resolve(); + await flushTranscriptDispatch(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + paginationError: false, + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); + }); + + it('keeps the capacity latch closed on live trims when pagination is unavailable', async () => { + // The eviction re-open of the load-older affordance must apply the same + // gates as the sibling paths: without the pagination feature (or without + // an anchor) a re-opened affordance would call a route the daemon does + // not serve and latch a pagination error instead of the terminal + // capacity state. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: [], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const liveGate = createDeferred(); + const session = createMockSession({ + sessionId: 'session-latch-no-pagination', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replay prompt', 'record-1'), + chunk(2, 'agent_message_chunk', 'replay answer', 'record-2'), + ], + liveJournal: [], + }, + events: async function* liveEventAfterGate( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + liveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield chunk(3, 'user_message_chunk', 'live one', 'record-live-1'); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + let history: ReturnType | undefined; + + function Harness() { + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 2, + }); + + // Saturated replay on a non-pagination daemon latches the capacity + // state with the affordance closed. + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + + // Live growth count-trims; the re-open must stay gated. + await act(async () => { + liveGate.resolve(); + await flushTranscriptDispatch(); + }); expect(history).toMatchObject({ hasMore: false, loading: false, @@ -13017,6 +13797,592 @@ describe('DaemonSessionProvider', () => { expect(sdkMocks.getSessionTranscriptPage).not.toHaveBeenCalled(); }); + it('drops an in-flight history page when a trim re-anchors pagination mid-fetch', async () => { + // A byte/count trim firing while a load-older page is in flight + // re-anchors the exclusive `beforeRecordId`; merging the stale page + // afterwards would advance the anchor below the evicted band and make + // evicted-but-persisted records unreachable. The page must be dropped + // losslessly instead. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const firstLiveGate = createDeferred(); + const secondLiveGate = createDeferred(); + const session = createMockSession({ + sessionId: 'session-inflight-page-trim', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replay prompt', 'record-1'), + chunk(2, 'agent_message_chunk', 'replay answer', 'record-2'), + ], + liveJournal: [], + }, + events: async function* liveEventsAfterGates( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + firstLiveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield chunk(3, 'user_message_chunk', 'live one', 'record-live-1'); + await Promise.race([ + secondLiveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield chunk(4, 'agent_message_chunk', 'live two', 'record-live-2'); + yield chunk(5, 'user_message_chunk', 'live three', 'record-live-3'); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + // Page 1 merges (window 3 of 4, still paging room); page 2 resolves + // only when the test triggers it — after the mid-fetch trim. + const stalePage = createDeferred<{ + v: 1; + sessionId: string; + events: DaemonEvent[]; + hasMore: boolean; + }>(); + sdkMocks.getSessionTranscriptPage + .mockResolvedValueOnce({ + v: 1, + sessionId: session.sessionId, + events: [chunk(10, 'user_message_chunk', 'older prompt', 'record-a')], + hasMore: true, + }) + .mockReturnValueOnce(stalePage.promise) + .mockResolvedValueOnce({ + v: 1, + sessionId: session.sessionId, + events: [], + hasMore: false, + }); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 4, + }); + + // Admit the older page; the window is 3 of 4, paging stays available. + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(blocks.map((block) => block.sourceRecordIds?.[0])).toEqual([ + 'record-a', + 'record-1', + 'record-2', + ]); + expect(history).toMatchObject({ + hasMore: true, + loading: false, + capacityReached: false, + }); + + // One live block fills the window (4 of 4)... + await act(async () => { + firstLiveGate.resolve(); + await flushTranscriptDispatch(); + }); + + // ...then the next fetch (against record-a, the merged page's oldest + // record) starts, still in flight... + let pendingLoad: Promise | undefined; + await act(async () => { + pendingLoad = history?.loadMore(); + await flushPromises(); + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenNthCalledWith( + 2, + session.sessionId, + { + beforeRecordId: 'record-a', + limit: 25, + clientId: session.clientId, + }, + ); + + // ...while two more live blocks overflow the window; the trim evicts + // record-a AND record-1, re-anchoring to record-2. + await act(async () => { + secondLiveGate.resolve(); + await flushTranscriptDispatch(); + }); + + // The stale page (fetched against the evicted record-a anchor) now + // resolves; it must be dropped, not merged. + await act(async () => { + stalePage.resolve({ + v: 1, + sessionId: session.sessionId, + events: [chunk(11, 'agent_message_chunk', 'older answer', 'record-b')], + hasMore: true, + }); + await pendingLoad; + await flushPromises(); + }); + expect(blocks.map((block) => block.sourceRecordIds?.[0])).toEqual([ + 'record-2', + 'record-live-1', + 'record-live-2', + 'record-live-3', + ]); + expect(history).toMatchObject({ + hasMore: true, + loading: false, + }); + + // A retry fetches against the re-anchored anchor; nothing was lost. + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenNthCalledWith( + 3, + session.sessionId, + { + beforeRecordId: 'record-2', + limit: 25, + clientId: session.clientId, + }, + ); + }); + + it('does not churn fetch/reject while count-saturated trims leave zero headroom', async () => { + // A count trim restores the window to exactly maxBlocks, so a rejected + // page still cannot fit afterwards; the eviction re-open must stay gated + // on admission headroom instead of re-offering the doomed page on every + // live block. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const liveGate = createDeferred(); + const session = createMockSession({ + sessionId: 'session-churn-guard', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replay prompt', 'record-1'), + chunk(2, 'agent_message_chunk', 'replay answer', 'record-2'), + ], + liveJournal: [], + }, + events: async function* liveEventAfterGate( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + liveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield chunk(3, 'user_message_chunk', 'live one', 'record-live-1'); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + // A single-block page cannot join the saturated 2/2 window (count), but a + // lone block is below the block cap, so it routes to the re-openable + // latch rather than the terminal branch; the latch records its footprint. + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [chunk(10, 'user_message_chunk', 'older prompt', 'record-a')], + hasMore: true, + }); + let history: ReturnType | undefined; + + function Harness() { + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 2, + }); + + // The two-block page cannot join the saturated 2/2 window; the latch + // records its footprint. + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); + + // Live growth count-trims back to exactly maxBlocks — zero headroom — + // so the latch must NOT re-open into another doomed fetch. + await act(async () => { + liveGate.resolve(); + await flushTranscriptDispatch(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); + }); + + it('keeps the capacity latch while the freed hole is smaller than the rejected page', async () => { + // A byte-budget rejection must not be re-offered on every later trim: + // trimming stops as soon as the estimate is back under budget, so the + // freed hole is typically smaller than the rejected page. The re-open + // gate checks the rejected footprint against the post-trim occupancy. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const liveGate = createDeferred(); + const session = createMockSession({ + sessionId: 'session-byte-hole-guard', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replay prompt', 'record-1'), + ], + liveJournal: [], + }, + events: async function* liveEventAfterGate( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + liveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield chunk(3, 'user_message_chunk', 'z'.repeat(1800), 'record-live-1'); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + // Page estimate lands between "current + page over budget" and "page + // alone over budget": rejectable, but not terminally impossible. + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [chunk(10, 'user_message_chunk', 'y'.repeat(1700), 'record-a')], + hasMore: true, + }); + let history: ReturnType | undefined; + + function Harness() { + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 8, + maxRetainedBytes: 3900, + }); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + paginationError: false, + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); + + // Live growth crosses the budget; the trim evicts the small replay + // block, freeing far less than the rejected page's footprint, so the + // latch must stay closed. + await act(async () => { + liveGate.resolve(); + await flushTranscriptDispatch(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + paginationError: false, + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); + }); + + it('fails terminally when a single history page exceeds the retention budget', async () => { + // A page that alone exceeds the whole byte budget can never be admitted + // in any window state; re-offering it forever would hide everything + // older than it. Surface a terminal pagination failure instead. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-oversized-page', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replay prompt', 'record-1'), + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [chunk(10, 'user_message_chunk', 'y'.repeat(600), 'record-a')], + hasMore: true, + }); + let history: ReturnType | undefined; + + function Harness() { + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 8, + maxRetainedBytes: 1000, + }); + + await act(async () => { + await expect(history?.loadMore()).rejects.toThrow( + 'Earlier history page exceeds the transcript retention window', + ); + await flushPromises(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: false, + paginationError: true, + }); + }); + + it('releases the replay snapshot after injection and never raises the block cap', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: [], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-consume-replay', + lastEventId: 3, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replayed prompt'), + chunk(2, 'agent_message_chunk', 'replayed answer'), + { + id: 3, + v: 1, + type: 'turn_complete', + data: { stopReason: 'end_turn' }, + }, + ], + liveJournal: [], + }, + events: async function* oneLiveEventThenIdle( + opts: { signal?: AbortSignal } = {}, + ) { + yield chunk(4, 'user_message_chunk', 'live follow-up'); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + maxBlocks: 1, + }); + await act(async () => { + await vi.waitFor(() => + expect(blocks).toMatchObject([ + { kind: 'user', text: 'live follow-up' }, + ]), + ); + }); + + // The oversized replay was trimmed to the cap on injection... + expect(blocks).toHaveLength(1); + // ...the raw snapshot was released from the session client... + expect(session.consumeReplaySnapshot).toHaveBeenCalledTimes(1); + expect(session.replaySnapshot.compactedReplay).toHaveLength(0); + expect(session.replaySnapshot.liveJournal).toHaveLength(0); + // ...and later live growth still trims at the configured cap, i.e. the + // replay did not ratchet maxBlocks up to its own size. + }); + it('rejects a terminal older page that does not fit atomically', async () => { sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', @@ -13026,13 +14392,14 @@ describe('DaemonSessionProvider', () => { id: number, text: string, recordId?: string, + kind: 'user_message_chunk' | 'agent_message_chunk' = 'user_message_chunk', ): DaemonEvent => ({ id, v: 1, type: 'session_update', data: { update: { - sessionUpdate: 'user_message_chunk', + sessionUpdate: kind, content: { type: 'text', text }, ...(recordId ? { _meta: { 'qwen.session.recordId': recordId } } : {}), }, @@ -13042,15 +14409,26 @@ describe('DaemonSessionProvider', () => { sessionId: 'session-history-capacity', historyHasMore: true, replaySnapshot: { - compactedReplay: [replayEvent(2, 'recent prompt', 'record-2')], + compactedReplay: [ + replayEvent(1, 'replay prompt', 'record-1'), + replayEvent(2, 'replay answer', 'record-2', 'agent_message_chunk'), + ], liveJournal: [], }, }); sdkMocks.sessions.push(session); + // Three older blocks (alternating kinds so they do not merge): with the + // two retained blocks they overflow the cap (2 + 3 > 4), but the page + // alone is below the cap, so it is rejected atomically into the + // re-openable latch rather than partially prepended. sdkMocks.getSessionTranscriptPage.mockResolvedValue({ v: 1, sessionId: session.sessionId, - events: [replayEvent(1, 'older prompt')], + events: [ + replayEvent(10, 'older prompt'), + replayEvent(11, 'older answer', undefined, 'agent_message_chunk'), + replayEvent(12, 'older follow-up'), + ], hasMore: false, }); let history: ReturnType | undefined; @@ -13062,10 +14440,13 @@ describe('DaemonSessionProvider', () => { return null; } + // maxBlocks 4 keeps the 3-block page below the cap (so the rejection is + // count-based, not the terminal impossible branch) while the merged total + // still overflows the cap. await renderWithProvider(, { autoConnect: true, historyPageSize: 25, - maxBlocks: 1, + maxBlocks: 4, }); await act(async () => { await history?.loadMore(); @@ -13077,7 +14458,10 @@ describe('DaemonSessionProvider', () => { loading: false, capacityReached: true, }); - expect(blocks).toMatchObject([{ kind: 'user', text: 'recent prompt' }]); + expect(blocks).toMatchObject([ + { kind: 'user', text: 'replay prompt' }, + { kind: 'assistant', text: 'replay answer' }, + ]); }); it('uses a full load for a legacy controlled clientId rebind', async () => { @@ -13311,6 +14695,11 @@ function createMockSession(opts: Partial = {}): MockSession { compactedReplay: [], liveJournal: [], }, + consumeReplaySnapshot: vi.fn(() => { + const snapshot = session.replaySnapshot; + session.replaySnapshot = { compactedReplay: [], liveJournal: [] }; + return snapshot; + }), events: opts.events ?? createIdleEvents(), }; return session; diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 535e4d0378..ae3127cd5f 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -22,7 +22,10 @@ import { DaemonSessionClient, UNRECOGNIZED_DIAGNOSTICS_LIMIT, createDaemonTranscriptStore, + estimateDaemonTranscriptBlockBytes, extractServerTimestamp, + isTrimmedPermissionBlockId, + isTrimmedToolBlockId, isUnrecognizedDiagnosticReason, matchTurnEvent, normalizeDaemonEvent, @@ -32,6 +35,7 @@ import { type DaemonTranscriptBlock, type DaemonTranscriptState, type DaemonTranscriptStore, + type DaemonTranscriptTruncationDetail, type DaemonTurnCompleteData, type DaemonUiEvent, type DaemonUnrecognizedDiagnostic, @@ -160,11 +164,23 @@ interface LiveJournalRepairEpisode { interface TranscriptHistoryMaterialization { blocks: readonly DaemonTranscriptBlock[]; nextOrdinal: number; + retainedBytes: number; toolBlockByCallId: Record; permissionBlockByRequestId: Record; unrecognizedDiagnostics: readonly DaemonUnrecognizedDiagnostic[]; } +type TranscriptHistoryAdmission = + | { admitted: true; materialization: TranscriptHistoryMaterialization } + | { + admitted: false; + reason: 'count' | 'bytes'; + pageBlocks: number; + pageBytes: number; + /** True when the page can never be admitted, even into an empty window. */ + impossible: boolean; + }; + const SESSION_TRANSCRIPT_PAGINATION_FEATURE = 'session_transcript_pagination'; const CLIENT_IDENTITY_FEATURE = 'client_identity'; const WORKSPACE_ACP_PREHEAT_FEATURE = 'workspace_acp_preheat'; @@ -267,7 +283,7 @@ function materializeTranscriptHistory( current: DaemonTranscriptState, events: DaemonUiEvent[], maxBlocks: number, -): TranscriptHistoryMaterialization | undefined { +): TranscriptHistoryAdmission { // Drop fetched events whose source records are already displayed. // `beforeRecordId` pagination is exclusive of the anchor but the anchor // can sit inside the retained window (e.g. the daemon's transcript @@ -285,6 +301,35 @@ function materializeTranscriptHistory( displayedRecordIds.add(recordId); } } + // Secondary content-aware dedup for blocks that carry no recordId — the + // locally echoed user prompt, which `suppressOwnUserEcho` keeps from ever + // unioning the daemon's recordId-stamped echo. RecordId dedup is blind to + // it, so once a trim leaves it as the oldest retained block, a load-older + // page returning that same prompt's persisted record would materialize a + // second user block and double-count it. The collision is strictly a + // boundary pair — the window's oldest block (the echo) against the page's + // newest user block (that same prompt's persisted record, adjacent to the + // window) — so only that pair is compared below. Keying on text window-wide + // would instead drop DISTINCT older prompts the user happened to send twice + // ("yes", a retry), permanently orphaning their assistant replies. + // The key must key on echo PRESENCE, not non-empty text: image/file-only + // prompts submit with empty text, so a `text !== ''` gate would skip their + // dedup and double-render the prompt. Fold media into the key (image/file + // counts) so two distinct media-only prompts at the boundary don't collapse. + const userBlockBoundaryKey = ( + block: DaemonTranscriptBlock | undefined, + ): string | undefined => { + if (block?.kind !== 'user') return undefined; + const text = (block as { text?: string }).text ?? ''; + const images = (block as { images?: unknown[] }).images?.length ?? 0; + const files = (block as { files?: unknown[] }).files?.length ?? 0; + return `${text}img:${images}file:${files}`; + }; + const oldestRetainedBlock = current.blocks[0]; + const boundaryEchoKey = + (oldestRetainedBlock?.sourceRecordIds?.length ?? 0) === 0 + ? userBlockBoundaryKey(oldestRetainedBlock) + : undefined; const freshEvents = displayedRecordIds.size === 0 ? events @@ -296,23 +341,83 @@ function materializeTranscriptHistory( ); const historyStore = createDaemonTranscriptStore({ maxBlocks: Number.MAX_SAFE_INTEGER, + // Trim-free by intent: a media-heavy page would otherwise cross the + // default byte budget mid-build and evict the page's oldest records, + // which the exclusive pagination anchor can never re-fetch. + maxRetainedBytes: Number.POSITIVE_INFINITY, nextOrdinal: current.nextOrdinal, retainSubagentBlocks: current.retainSubagentBlocks, }); historyStore.dispatch(freshEvents); const history = historyStore.getSnapshot(); - if (history.blocks.length + current.blocks.length > maxBlocks) { - return undefined; + // Drop the page's newest user block only when it duplicates the window's + // oldest recordId-less echo (the boundary pair). A same-text user block + // deeper in older history is a distinct prompt and must survive. + let pageBlockList = history.blocks; + if (boundaryEchoKey !== undefined) { + for (let i = history.blocks.length - 1; i >= 0; i -= 1) { + const block = history.blocks[i]; + if (block?.kind !== 'user') continue; + if (userBlockBoundaryKey(block) === boundaryEchoKey) { + pageBlockList = [ + ...history.blocks.slice(0, i), + ...history.blocks.slice(i + 1), + ]; + } + break; + } + } + let pageBytes = 0; + for (const block of pageBlockList) { + pageBytes += estimateDaemonTranscriptBlockBytes(block); + } + const pageBlocks = pageBlockList.length; + // `impossible` must be evaluated across BOTH dimensions, regardless of + // which branch rejects: a page that alone fills the whole block window can + // never be admitted (an anchored window always retains at least one block), + // and likewise for the byte budget. Equality is already impossible, hence + // `>=`. A page rejected by one dimension but impossible in the other would + // route to the re-openable latch whose re-open gate is then unsatisfiable — + // terminal either way. + const impossible = + pageBlocks >= maxBlocks || pageBytes >= current.maxRetainedBytes; + // Count admission: an over-count merge stays untrimmed while the session + // is idle, and the next live trim evicts the freshly prepended oldest + // records, which the exclusive pagination anchor can never re-fetch — a + // permanent silent gap. Reject atomically. + if (pageBlocks + current.blocks.length > maxBlocks) { + return { + admitted: false, + reason: 'count', + pageBlocks, + pageBytes, + impossible, + }; + } + // Byte-budget admission: same silent-gap hazard as the count cap — an + // over-budget merge is evicted oldest-first by the next live trim. + if (current.retainedBytes + pageBytes > current.maxRetainedBytes) { + return { + admitted: false, + reason: 'bytes', + pageBlocks, + pageBytes, + impossible, + }; } return { - blocks: history.blocks, - nextOrdinal: history.nextOrdinal, - toolBlockByCallId: history.toolBlockByCallId, - permissionBlockByRequestId: history.permissionBlockByRequestId, - // History pages can carry frames recorded by newer daemon versions, exactly - // forward-compat case the sidechannel exists for (#8823); keep them - // instead of dropping the throwaway store's diagnostics. - unrecognizedDiagnostics: history.unrecognizedDiagnostics, + admitted: true, + materialization: { + blocks: pageBlockList, + nextOrdinal: history.nextOrdinal, + retainedBytes: pageBytes, + toolBlockByCallId: history.toolBlockByCallId, + permissionBlockByRequestId: history.permissionBlockByRequestId, + // History pages can carry frames recorded by newer daemon versions, exactly + // forward-compat case the sidechannel exists for (#8823); keep them + // instead of dropping the throwaway store's diagnostics. + unrecognizedDiagnostics: history.unrecognizedDiagnostics, + }, }; } @@ -320,18 +425,58 @@ function applyTranscriptHistory( current: DaemonTranscriptState, history: TranscriptHistoryMaterialization, ): DaemonTranscriptState { + // A page-resurrected real block mapping must win over the current window's + // TRIMMED sentinel for the same callId — otherwise the resurrected block is + // orphaned and every later live update for that tool hits the sentinel + // branch (a false "output trimmed" error block plus dropped updates). + // Real-vs-real collisions cannot occur (the recordId dedup filter drops + // already-displayed records before materialization). + const toolBlockByCallId: Record = { + ...history.toolBlockByCallId, + }; + for (const [callId, blockId] of Object.entries(current.toolBlockByCallId)) { + if ( + isTrimmedToolBlockId(blockId) && + toolBlockByCallId[callId] !== undefined + ) { + continue; + } + toolBlockByCallId[callId] = blockId; + } + // A resurrected tool is live content again; clear its trimmed-notification + // flag so a future re-trim reports it once instead of staying silent. + const trimmedToolNotificationByCallId: Record = { + ...current.trimmedToolNotificationByCallId, + }; + for (const callId of Object.keys(history.toolBlockByCallId)) { + delete trimmedToolNotificationByCallId[callId]; + } + // Same sentinel-aware merge for permission blocks: a page-resurrected real + // mapping must win over the current window's TRIMMED_PERMISSION sentinel, or + // a resurrected pending permission never flips to resolved (the permission + // upsert/resolve paths early-return on the sentinel). + const permissionBlockByRequestId: Record = { + ...history.permissionBlockByRequestId, + }; + for (const [requestId, blockId] of Object.entries( + current.permissionBlockByRequestId, + )) { + if ( + isTrimmedPermissionBlockId(blockId) && + permissionBlockByRequestId[requestId] !== undefined + ) { + continue; + } + permissionBlockByRequestId[requestId] = blockId; + } return { ...current, blocks: [...history.blocks, ...current.blocks], + retainedBytes: current.retainedBytes + history.retainedBytes, nextOrdinal: history.nextOrdinal, - toolBlockByCallId: { - ...history.toolBlockByCallId, - ...current.toolBlockByCallId, - }, - permissionBlockByRequestId: { - ...history.permissionBlockByRequestId, - ...current.permissionBlockByRequestId, - }, + toolBlockByCallId, + trimmedToolNotificationByCallId, + permissionBlockByRequestId, // History entries are older than anything received live, so they go // first; the slice keeps the newest entries within the sidechannel cap. unrecognizedDiagnostics: [ @@ -517,10 +662,14 @@ interface HeartbeatFailureState { // Keep enough transcript history for large daemon replay streams so event order // and subagent grouping survive replay. Rendering is virtualized, but message -// normalization still rebuilds from retained blocks today, so this high default -// is a history-preservation tradeoff rather than a claim that large transcripts -// are CPU-free. Callers can pass a smaller maxBlocks in constrained contexts. -const DEFAULT_MAX_BLOCKS = 200_000; +// normalization still rebuilds from retained blocks today, so this default is a +// history-preservation tradeoff rather than a claim that large transcripts are +// CPU-free. This is a block-COUNT ceiling; the memory ceiling is enforced +// separately by the transcript store's retention byte budget, because blocks +// can carry large raw tool payloads (an implicit 200k window let a single busy +// session exhaust renderer memory). Callers can pass a smaller maxBlocks in +// constrained contexts. +export const DEFAULT_MAX_BLOCKS = 50_000; const TRANSCRIPT_DISPATCH_BATCH_MS = 16; const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = { @@ -547,6 +696,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { createSessionRequest, maxQueued = 1024, maxBlocks = DEFAULT_MAX_BLOCKS, + maxRetainedBytes, historyPageSize, subagentTranscriptMode = 'full', suppressOwnUserEcho = true, @@ -589,14 +739,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { activeWorkspaceCwdRef.current = resolvedWorkspaceCwd; } - const store = useMemo( - () => - createDaemonTranscriptStore({ - maxBlocks, - retainSubagentBlocks: subagentTranscriptMode === 'full', - }), - [maxBlocks, subagentTranscriptMode], - ); const sessionRef = useRef(undefined); const sessionConfigGenerationRef = useRef( new WeakMap(), @@ -609,6 +751,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { loading: boolean; capacityReached: boolean; paginationError: boolean; + /** + * Footprint of the last page rejected by admission. The eviction + * re-open of the capacity latch consults it so the affordance only + * reappears once enough capacity has been freed for that page to be + * admitted; undefined when the latch came from replay saturation. + */ + rejectedPage?: { blocks: number; bytes: number }; }>({ hasMore: false, loading: false, @@ -621,6 +770,169 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { capacityReached: false, paginationError: false, }); + // Monotonic counter bumped whenever a block trim invalidates the + // pagination position (the anchor record may have been evicted). A + // load-older fetch captures it before the await and drops the page if it + // moved mid-fetch, so a stale page can never advance the anchor below the + // evicted band. + const paginationGenerationRef = useRef(0); + const store = useMemo( + () => + createDaemonTranscriptStore({ + maxBlocks, + ...(maxRetainedBytes !== undefined ? { maxRetainedBytes } : {}), + retainSubagentBlocks: subagentTranscriptMode === 'full', + onTruncation: (detail) => { + if (detail.kind !== 'blocks') return; + const history = transcriptHistoryRef.current; + const activeSession = sessionRef.current; + if (!activeSession || history.sessionId !== activeSession.sessionId) { + return; + } + // Trimming evicts oldest-first, so it can remove the very record + // the exclusive `beforeRecordId` anchor points at; the daemon + // never returns the anchor itself, so the evicted stretch would + // become unreachable. Re-anchor to the oldest retained record and + // atomically drop a stale cursor (loadMore prefers cursor over + // beforeRecordId, and a cursor-addressed position can never be + // re-based after the blocks it points past are evicted). A rewind + // (`evictedOldest === false`) drops the newest blocks and leaves + // the oldest anchor intact, so it must not trigger re-anchoring. + if (detail.evictedOldest !== false) { + if (detail.oldestRetainedRecordId !== undefined) { + history.beforeRecordId = detail.oldestRetainedRecordId; + history.cursor = undefined; + // A re-anchoring trim invalidates a latched rejectedPage + // footprint: the daemon (exclusive-before, served from disk) + // re-serves the evicted band on the next fetch, so the page will + // be larger than latched. Grow the latched footprint by the + // evicted band so the re-open gate measures the page the + // re-anchored fetch actually gets — a stale (too-small) footprint + // would churn fetch/reject, or misclassify a now-larger page as + // terminal. `store.getSnapshot()` is still pre-trim here: the + // store swaps its state only after the reduce completes. + if (history.rejectedPage) { + const preTrim = store.getSnapshot(); + const postTrimBlockCount = + detail.blockCount ?? preTrim.blocks.length; + const postTrimRetainedBytes = + detail.retainedBytes ?? preTrim.retainedBytes; + history.rejectedPage = { + blocks: + history.rejectedPage.blocks + + Math.max(0, preTrim.blocks.length - postTrimBlockCount), + bytes: + history.rejectedPage.bytes + + Math.max(0, preTrim.retainedBytes - postTrimRetainedBytes), + }; + } + // A live trim evicts oldest blocks that stay persisted + // daemon-side, so there is now fetchable content older than the + // re-set anchor. A session that loaded unlatched (hasMore=false, + // capacityReached=false) must surface that affordance, or the + // evicted band is unreachable until a reload. Mirror the replay + // path's olderHistoryReachable gates. + if (!history.capacityReached && !history.hasMore) { + const features = sessionCapabilitiesRef.current?.features; + const windowCaps = store.getSnapshot(); + const postTrimRetainedBytes = + detail.retainedBytes ?? windowCaps.retainedBytes; + const byteCap = + detail.maxRetainedBytes ?? windowCaps.maxRetainedBytes; + const olderHistoryReachable = + Array.isArray(features) && + features.includes(SESSION_TRANSCRIPT_PAGINATION_FEATURE) && + postTrimRetainedBytes < byteCap; + if (olderHistoryReachable) { + history.hasMore = true; + setTranscriptHistoryState({ + hasMore: true, + loading: false, + capacityReached: false, + paginationError: history.paginationError, + }); + } + } + } else { + // Re-anchor uncomputable — no retained block carries a + // recordId. The current anchor points at an evicted record the + // exclusive pagination contract can never return again; fail + // closed instead of offering an affordance that skips the + // evicted band. + history.beforeRecordId = undefined; + history.cursor = undefined; + if (history.hasMore) { + history.hasMore = false; + setTranscriptHistoryState({ + hasMore: false, + loading: history.loading, + capacityReached: history.capacityReached, + paginationError: history.paginationError, + }); + } + } + } + // Oldest-first eviction can invalidate an in-flight page's anchor; + // bump the generation so the stale page is dropped on resolve. A + // rewind leaves the anchor band untouched, so in-flight pages stay + // valid and must not be dropped. + if (detail.evictedOldest !== false) { + paginationGenerationRef.current += 1; + } + if (history.capacityReached) { + // Eviction freed retention capacity, so the page rejected at the + // latch may fit now — re-open the load-older affordance, but + // only where the sibling paths would have offered it: the daemon + // must support pagination and a positional anchor must exist. + const features = sessionCapabilitiesRef.current?.features; + const paginationSupported = + Array.isArray(features) && + features.includes(SESSION_TRANSCRIPT_PAGINATION_FEATURE); + const anchored = + history.beforeRecordId !== undefined || + history.cursor !== undefined; + if (!paginationSupported || !anchored) { + return; + } + // Admission-headroom gate: only re-open when the rejected page + // would actually be admitted now. A count trim restores the + // window to exactly maxBlocks (zero headroom), so without this + // check every live block during streaming would re-open the + // latch into an immediate fetch/reject cycle. Caps are stable + // across a trim; the snapshot only backs detail-field fallbacks. + const windowCaps = store.getSnapshot(); + const postTrimBlockCount = + detail.blockCount ?? windowCaps.blocks.length; + const postTrimRetainedBytes = + detail.retainedBytes ?? windowCaps.retainedBytes; + const blockCap = detail.maxBlocks ?? windowCaps.maxBlocks; + const byteCap = + detail.maxRetainedBytes ?? windowCaps.maxRetainedBytes; + const rejected = history.rejectedPage; + // A footprint-less latch (replay saturation) re-opens only on + // real count headroom: while the count window is saturated, + // count admission rejects every page regardless of bytes. + const admissionHeadroom = rejected + ? rejected.blocks + postTrimBlockCount <= blockCap && + rejected.bytes + postTrimRetainedBytes <= byteCap + : postTrimBlockCount < blockCap; + if (!admissionHeadroom) { + return; + } + history.rejectedPage = undefined; + history.hasMore = true; + history.capacityReached = false; + setTranscriptHistoryState({ + hasMore: true, + loading: false, + capacityReached: false, + paginationError: history.paginationError, + }); + } + }, + }), + [maxBlocks, maxRetainedBytes, subagentTranscriptMode], + ); const eventStreamRef = useRef< | { sessionId: string; @@ -1519,7 +1831,18 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) && (activeSession.historyHasMore || replayHistoryWasTruncated) && firstPersistedRecordId !== undefined; - if (!repairingEpisode) { + const replayInjected = + shouldInjectReplaySnapshot && replayEvents.length > 0; + // After the snapshot is consumed the replay-derived inputs above + // (firstPersistedRecordId, replayHistoryWasTruncated) recompute + // empty on delta-resume reconnects; keep the history state that + // the original injection initialized instead of clobbering it. + if ( + !repairingEpisode && + (replayInjected || + transcriptHistoryRef.current.sessionId !== + activeSession.sessionId) + ) { transcriptHistoryRef.current = { sessionId: activeSession.sessionId, ...(firstPersistedRecordId !== undefined @@ -1537,6 +1860,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { paginationError: false, }); } else if ( + repairingEpisode && !markerStillVisible && firstPersistedRecordId !== undefined ) { @@ -1544,8 +1868,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { firstPersistedRecordId; transcriptHistoryRef.current.cursor = undefined; } - const replayInjected = - shouldInjectReplaySnapshot && replayEvents.length > 0; if (needsStoreReset && !replayInjected) { // Reset needed but no replay data (e.g. fresh session) — reset // immediately since there is no dispatch to batch with. @@ -1674,27 +1996,59 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { (group) => group.transcript, ); let replayExceededCapacity = false; + let replayTrimmed = false; + let replayTrimmedAnchor: string | undefined; const rebuildReplay = repairingEpisode !== undefined || replayTarget !== undefined || needsStoreReset || store.getSnapshot().blocks.length === 0; if (rebuildReplay) { - const replayMaxBlocks = repairingEpisode - ? markerStillVisible + // Ordinary replay rebuilds under the same cap as live growth: + // a session loaded mid-turn can carry a live journal with tens + // of thousands of events, and retaining it all (the previous + // uncapped rebuild) exhausted renderer memory. Trimming keeps + // the most recent blocks; older history stays reachable via + // pagination. + const replayMaxBlocks = + repairingEpisode && markerStillVisible ? repairingEpisode.checkpoint.maxBlocks - : maxBlocks - : Number.MAX_SAFE_INTEGER; + : maxBlocks; + const observeReplayTrim = ( + detail: DaemonTranscriptTruncationDetail, + ) => { + // A rewind also fires `kind: 'blocks'` but with + // `evictedOldest: false` — it drops the NEWEST blocks and + // leaves the oldest pagination anchor valid, so it must not + // latch the capacity/re-anchor path (same gate as the live + // store's onTruncation handler above). + if (detail.kind === 'blocks' && detail.evictedOldest !== false) + replayTrimmed = true; + }; + // Both rebuild branches can trim (count cap or byte budget), so + // both observe it — a marker-visible repair seeded from the + // checkpoint is just as able to evict the pagination anchor as + // an ordinary rebuild. const replayStore = createDaemonTranscriptStore( repairingEpisode && markerStillVisible ? { ...repairingEpisode.checkpoint, maxBlocks: replayMaxBlocks, + onTruncation: observeReplayTrim, } : { maxBlocks: replayMaxBlocks, retainSubagentBlocks: subagentTranscriptModeRef.current === 'full', + // Rebuild under the same byte budget as the live store + // so an oversized replay is trimmed to the same ceiling. + ...(maxRetainedBytes !== undefined + ? { maxRetainedBytes } + : {}), + // The count cap and the default byte budget can both + // evict mid-rebuild; observe either so the pagination + // anchor and capacity indicator reconcile below. + onTruncation: observeReplayTrim, }, ); let nextCheckpoint: DaemonTranscriptState | undefined; @@ -1711,12 +2065,26 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } } const replayState = replayStore.getSnapshot(); + // A rebuild trim (count cap or byte budget) evicted older + // blocks; a merely saturated window leaves no in-store room + // for pagination either way — surface capacityReached for both. + // Repair rebuilds reconcile too: they evict the anchor just as + // an ordinary rebuild does. replayExceededCapacity = - repairingEpisode === undefined && - replayState.blocks.length > maxBlocks; - const committedMaxBlocks = repairingEpisode - ? replayMaxBlocks - : Math.max(maxBlocks, replayState.blocks.length); + replayTrimmed || replayState.blocks.length >= replayMaxBlocks; + if (replayExceededCapacity) { + // The pre-trim anchor can sit inside the trimmed stretch; + // re-anchor below to the oldest RETAINED record so + // pagination fetches exactly the dropped records. + replayTrimmedAnchor = replayState.blocks.find( + (block) => (block.sourceRecordIds?.length ?? 0) > 0, + )?.sourceRecordIds?.[0]; + } + // Replay must never ratchet the retention window above the + // configured cap: the committed cap is what bounds every + // later dispatch, and an escalation here turned one large + // replay into permanent unbounded retention. + const committedMaxBlocks = replayMaxBlocks; store.reset({ ...replayState, maxBlocks: committedMaxBlocks, @@ -1771,11 +2139,49 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setWorkspaceEventSignals, ); } - if (replayExceededCapacity && historyHasMore) { - transcriptHistoryRef.current.hasMore = false; + if (replayExceededCapacity) { + if (replayTrimmed) { + if (replayTrimmedAnchor !== undefined) { + transcriptHistoryRef.current.beforeRecordId = + replayTrimmedAnchor; + } else { + // The rebuild trimmed but no retained block carries a + // recordId, so a re-anchor to a retained record is + // uncomputable. Any pre-trim anchor points at an evicted + // record the exclusive pagination contract can never return + // again — drop it unconditionally, mirroring the live store's + // fail-closed branch. Scanning only the fresh replayEvents + // would miss recordIds trimmed from the repair checkpoint in + // a marker-visible live-journal repair, leaving a stale anchor + // with the affordance still on; when no recordId ever existed + // the anchor is already undefined, so the drop is a no-op. + transcriptHistoryRef.current.beforeRecordId = undefined; + } + // A rebuild trim can evict the records a cursor points past; + // drop it so the beforeRecordId (re-anchored or pre-trim) is + // authoritative. + transcriptHistoryRef.current.cursor = undefined; + } + // Trimmed/saturated replay content stays persisted daemon-side + // and is fetchable through pagination, so keep the load-older + // affordance — but only while admission has real headroom: a + // positional anchor and byte-budget room. Without byte-budget + // headroom (e.g. a single oversized block whose estimate alone + // exceeds the budget) no page can ever be admitted, so offering + // the affordance would burn it on the first click with no + // terminal signal. + const postRebuild = store.getSnapshot(); + const olderHistoryReachable = + Array.isArray(capabilities?.features) && + capabilities.features.includes( + SESSION_TRANSCRIPT_PAGINATION_FEATURE, + ) && + transcriptHistoryRef.current.beforeRecordId !== undefined && + postRebuild.retainedBytes < postRebuild.maxRetainedBytes; + transcriptHistoryRef.current.hasMore = olderHistoryReachable; transcriptHistoryRef.current.capacityReached = true; setTranscriptHistoryState({ - hasMore: false, + hasMore: olderHistoryReachable, loading: false, capacityReached: true, paginationError: false, @@ -1794,6 +2200,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ); } setConnection((c) => ({ ...c, catchingUp: undefined })); + // Release the raw snapshot only after the injection above + // completed: if normalization/dispatch threw, the recovery path + // reloads the session, and the still-retained snapshot keeps the + // window consistent until then. On success it is never read again + // (SSE continues from lastEventId; older history via pagination), + // so dropping it unpins busy-session snapshots that can reach + // tens of MiB after adaptive journal growth. + activeSession.consumeReplaySnapshot(); } setConnection((current) => ({ ...current, @@ -2870,6 +3284,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { sessionScope, maxQueued, maxBlocks, + maxRetainedBytes, store, restoreSessionId, restoreWorkspaceCwd, @@ -3161,6 +3576,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { if (options?.force !== true) { return; } + // A fail-closed trim can drop both anchors. With neither cursor nor + // beforeRecordId, the daemon defaults the request to the journal's + // oldest page, which would be prepended below the window and re-stamp + // a bogus anchor. Refuse to re-arm anchor-less; the affordance stays + // closed until a later trim re-establishes an anchor. + if ( + history.beforeRecordId === undefined && + history.cursor === undefined + ) { + return; + } // The failed page's cursor was never advanced, so clearing the // latched error retries that exact page. history.paginationError = false; @@ -3176,6 +3602,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { capacityReached: false, paginationError: false, }); + const fetchPaginationGeneration = paginationGenerationRef.current; let terminalFailure = false; try { const page = await activeSession.getTranscriptPage({ @@ -3193,6 +3620,22 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) { return; } + if (paginationGenerationRef.current !== fetchPaginationGeneration) { + // A retention trim re-anchored pagination while this page was in + // flight. The page was fetched against the stale anchor; merging it + // would advance the anchor below the evicted band and make the + // evicted-but-persisted records unreachable. Drop it without + // mutating pagination state — every record it carries is older + // than the new anchor and will be re-served by the next fetch. + history.loading = false; + setTranscriptHistoryState({ + hasMore: history.hasMore, + loading: false, + capacityReached: history.capacityReached, + paginationError: history.paginationError, + }); + return; + } if (page.partial || page.replayError) { terminalFailure = true; throw new Error( @@ -3246,7 +3689,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ); } } - const historyMaterialization = + const admission = uiEvents.length > 0 ? materializeTranscriptHistory( store.getSnapshot(), @@ -3254,10 +3697,30 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { maxBlocks, ) : undefined; - if (uiEvents.length > 0 && !historyMaterialization) { + if (admission && !admission.admitted) { + if (admission.impossible) { + // A page that alone exceeds the whole window (block count or + // byte budget) can never be admitted in any occupancy state. + // Surface a terminal pagination failure instead of a re-openable + // capacity latch — otherwise every later trim would re-offer the + // same doomed page, and everything older than it would stay + // unreachable with no terminal signal. + history.rejectedPage = undefined; + terminalFailure = true; + throw new Error( + 'Earlier history page exceeds the transcript retention window', + ); + } history.hasMore = false; history.loading = false; history.capacityReached = true; + // Remember the rejected page's footprint: the eviction re-open + // must only fire once enough capacity has been freed for THIS page + // to be admitted, or streaming trims churn fetch/reject/re-render. + history.rejectedPage = { + blocks: admission.pageBlocks, + bytes: admission.pageBytes, + }; setTranscriptHistoryState({ hasMore: false, loading: false, @@ -3266,7 +3729,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }); return; } + const historyMaterialization = admission?.admitted + ? admission.materialization + : undefined; if (historyMaterialization) { + history.rejectedPage = undefined; store.reset( applyTranscriptHistory(store.getSnapshot(), historyMaterialization), ); @@ -3298,6 +3765,22 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) { return; } + if (paginationGenerationRef.current !== fetchPaginationGeneration) { + // A retention trim re-anchored — or the fail-closed branch dropped — + // the pagination anchor while this fetch was in flight. Restoring + // `hasMore` here would revive the load-older affordance in the + // anchor-less state the fail-closed branch just closed, and the next + // fetch (no cursor, no beforeRecordId) would default to the oldest + // page and corrupt the anchor. Leave the fail-closed state intact. + history.loading = false; + setTranscriptHistoryState({ + hasMore: history.hasMore, + loading: false, + capacityReached: history.capacityReached, + paginationError: history.paginationError, + }); + return; + } const retryable = !terminalFailure && (!(error instanceof DaemonHttpError) || diff --git a/packages/webui/src/daemon/session/index.ts b/packages/webui/src/daemon/session/index.ts index 55194c37f5..3360bf3a5d 100644 --- a/packages/webui/src/daemon/session/index.ts +++ b/packages/webui/src/daemon/session/index.ts @@ -6,6 +6,7 @@ export { DaemonSessionProvider, + DEFAULT_MAX_BLOCKS, useDaemonActions, useOptionalDaemonActions, useDaemonSessionOwnerGuard, diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index a08e938d9a..9a594dd69a 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -144,6 +144,14 @@ export interface DaemonSessionProviderProps { maxQueued?: number; /** Maximum normalized transcript blocks retained in memory. */ maxBlocks?: number; + /** + * Maximum estimated bytes of transcript blocks retained in memory. + * Trimming evicts oldest blocks until the estimate is back under this + * budget; a block-count window alone is not a memory ceiling because + * blocks can carry large raw tool payloads. Defaults to the transcript + * store's built-in budget. + */ + maxRetainedBytes?: number; /** Latest persisted records requested during an existing-session load. */ historyPageSize?: number; /** Keep the full subagent transcript, or retain only bounded root summaries. */ From a074d3b0423b8c99ea272b598602d7cf0c24b15c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Fri, 21 Aug 2026 03:48:03 +0000 Subject: [PATCH 4/4] chore(ci): Disable install scripts in release CI and guard security-checks workflow (#9577) * chore(ci): Disable install scripts in release CI and guard security-checks workflow * fix(ci): complete release install hardening * test(ci): pin release install step count Co-authored-by: Qwen-Coder * fix(ci): scope release PAT to push step Co-authored-by: Qwen-Coder * fix(ci): export GH_TOKEN so the release-branch push uses CI_BOT_PAT * fix(ci): export GH_TOKEN so the credential helper sees it at push time An inline GH_TOKEN prefix only covers the gh auth setup-git call itself; the helper re-resolves the token when git push invokes it, so the push would fall back to the job token with persist-credentials disabled. * fix(test): anchor setup-git ordering check after the export line A comment in the push step mentions gh auth setup-git before the export, so indexOf found the comment first and the ordering assertion inverted. * style(test): wrap long line to satisfy prettier * fix(ci): address review findings on PAT handling and install comments - Pin gh auth setup-git before the git push it authenticates in both release and finalize workflow tests, so moving credential setup after the push no longer passes. - Correct the replay comment: npm run generate is not a lifecycle script and workspace lifecycle scripts stay disabled. - Drop the overstated push-boundary claim and record why the push needs the bot PAT rather than the job token. * test(ci): pin CI_BOT_PAT out of install steps and the publish job header * style(test): apply prettier's exact re-wrap for the two flagged calls * test(ci): pin CI_BOT_PAT out of the workflow-level headers too --------- Co-authored-by: Qwen-Coder --- .github/CODEOWNERS | 3 ++ .github/workflows/finalize-release.yml | 12 +++-- .github/workflows/release.yml | 54 ++++++++++++++----- .../tests/ai-release-notes-workflow.test.js | 19 +++++++ scripts/tests/package-scripts.test.js | 31 +++++++++-- 5 files changed, 101 insertions(+), 18 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f754ea98d6..467311f7dd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,6 +9,9 @@ /.github/workflows/release.yml @pomelo-nwu @wenshao /.github/workflows/finalize-release.yml @pomelo-nwu @wenshao +# --- Security gate workflows require core maintainer approval --- +/.github/workflows/security-checks.yml @pomelo-nwu @wenshao + # --- Core package --- /packages/core/ @wenshao @tanzhenxin @yiliang114 @LaZzyMan @doudouOUC diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml index f94079e154..7e864545ad 100644 --- a/.github/workflows/finalize-release.yml +++ b/.github/workflows/finalize-release.yml @@ -53,6 +53,7 @@ jobs: token: '${{ secrets.CI_BOT_PAT }}' ref: '${{ steps.meta.outputs.release_branch }}' fetch-depth: 0 + persist-credentials: false - name: 'Setup Node.js' if: |- @@ -68,9 +69,13 @@ jobs: ${{ steps.meta.outputs.is_stable == 'true' }} env: NPM_CONFIG_PREFER_OFFLINE: 'true' - QWEN_SKIP_PREPARE: '1' run: |- - npm ci --no-audit --progress=false + npm ci --ignore-scripts --no-audit --progress=false + # Replay the root postinstall (patch-package) and commit-info + # generation; dependency and workspace lifecycle scripts remain + # disabled. + npm run postinstall + npm run generate - name: 'Configure Git User' if: |- @@ -182,11 +187,12 @@ jobs: if: |- ${{ steps.meta.outputs.is_stable == 'true' }} env: - GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GH_TOKEN: '${{ secrets.CI_BOT_PAT }}' BRANCH_NAME: '${{ steps.meta.outputs.release_branch }}' RELEASE_TAG: '${{ env.RELEASE_TAG }}' run: |- set -euo pipefail + gh auth setup-git node scripts/generate-changelog.js git add CHANGELOG.md if git diff --cached --quiet -- CHANGELOG.md; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f7778f335..7cebbae25b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,9 +98,13 @@ jobs: - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' - QWEN_SKIP_PREPARE: '1' run: |- - npm ci --no-audit --progress=false + npm ci --ignore-scripts --no-audit --progress=false + # Replay the root postinstall (patch-package) and commit-info + # generation; dependency and workspace lifecycle scripts remain + # disabled. + npm run postinstall + npm run generate - name: 'Get the version' id: 'version' @@ -169,9 +173,13 @@ jobs: - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' - QWEN_SKIP_PREPARE: '1' run: |- - npm ci --no-audit --progress=false + npm ci --ignore-scripts --no-audit --progress=false + # Replay the root postinstall (patch-package) and commit-info + # generation; dependency and workspace lifecycle scripts remain + # disabled. + npm run postinstall + npm run generate - name: 'Format Project' run: |- @@ -227,9 +235,13 @@ jobs: - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' - QWEN_SKIP_PREPARE: '1' run: |- - npm ci --no-audit --progress=false + npm ci --ignore-scripts --no-audit --progress=false + # Replay the root postinstall (patch-package) and commit-info + # generation; dependency and workspace lifecycle scripts remain + # disabled. + npm run postinstall + npm run generate - name: 'Build Bundle' run: |- @@ -274,9 +286,13 @@ jobs: - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' - QWEN_SKIP_PREPARE: '1' run: |- - npm ci --no-audit --progress=false + npm ci --ignore-scripts --no-audit --progress=false + # Replay the root postinstall (patch-package) and commit-info + # generation; dependency and workspace lifecycle scripts remain + # disabled. + npm run postinstall + npm run generate - name: 'Build Bundle' run: |- @@ -371,11 +387,10 @@ jobs: - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: - # Persist the bot PAT for release-branch pushes so downstream CI - # workflows are triggered. token: '${{ secrets.CI_BOT_PAT }}' ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 + persist-credentials: false - name: 'Setup Node.js' uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 @@ -392,9 +407,13 @@ jobs: - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' - QWEN_SKIP_PREPARE: '1' run: |- - npm ci --no-audit --progress=false + npm ci --ignore-scripts --no-audit --progress=false + # Replay the root postinstall (patch-package) and commit-info + # generation; dependency and workspace lifecycle scripts remain + # disabled. + npm run postinstall + npm run generate - name: 'Configure Git User' run: |- @@ -425,6 +444,7 @@ jobs: IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}' RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + CI_BOT_PAT: '${{ secrets.CI_BOT_PAT }}' run: |- git add package.json package-lock.json packages/*/package.json packages/channels/*/package.json integrations/external-context/package.json if git diff --staged --quiet; then @@ -433,6 +453,16 @@ jobs: git commit -m "chore(release): ${RELEASE_TAG}" fi if [[ "${IS_DRY_RUN}" == "false" ]]; then + # Restore the bot PAT in this step rather than persisting it in + # git credentials at checkout, so dependency installation and + # build tooling in earlier steps cannot read write credentials. + # The push itself needs the PAT (not the job token): pushes made + # with GITHUB_TOKEN do not trigger downstream CI workflows. + # Export (not inline) so GH_TOKEN is still set when `git push` + # invokes the credential helper, which re-resolves the token at + # push time rather than at `gh auth setup-git` time. + export GH_TOKEN="${CI_BOT_PAT}" + gh auth setup-git # The guard runs scripts/get-release-version.js from the # checked-out ref — the operator-controlled dispatch input `ref` # — not the branch this workflow file came from. A ref predating diff --git a/scripts/tests/ai-release-notes-workflow.test.js b/scripts/tests/ai-release-notes-workflow.test.js index 5dc1bc3437..d9f120b8aa 100644 --- a/scripts/tests/ai-release-notes-workflow.test.js +++ b/scripts/tests/ai-release-notes-workflow.test.js @@ -62,6 +62,8 @@ describe('stable release notes workflow', () => { it('finalizes stable releases asynchronously', () => { const validate = getStep(finalizeWorkflow, 'Validate stable release tag'); + const checkout = getStep(finalizeWorkflow, 'Checkout release branch'); + const install = getStep(finalizeWorkflow, 'Install Dependencies'); const generate = getStep( finalizeWorkflow, 'Generate AI-assisted release notes', @@ -69,6 +71,11 @@ describe('stable release notes workflow', () => { const update = getStep(finalizeWorkflow, 'Update GitHub Release notes'); const changelog = getStep(finalizeWorkflow, 'Regenerate CHANGELOG.md'); + expect( + finalizeWorkflow.slice(0, finalizeWorkflow.indexOf('jobs:')), + ).not.toContain('CI_BOT_PAT'); + expect(install).not.toContain('CI_BOT_PAT'); + expect(finalizeWorkflow).toContain("types: ['published']"); expect(finalizeWorkflow).toContain( 'github.event.release.prerelease == false', @@ -79,6 +86,13 @@ describe('stable release notes workflow', () => { ); expect(validate).toContain('is not a stable release tag'); expect(validate).toContain('exit 1'); + expect(checkout).toContain('persist-credentials: false'); + expect(install).toContain( + 'npm ci --ignore-scripts --no-audit --progress=false', + ); + expect(install).toContain('npm run postinstall'); + expect(install).toContain('npm run generate'); + expect(install).not.toContain('QWEN_SKIP_PREPARE'); expect(generate).toContain('timeout-minutes: 35'); expect(generate).toContain('continue-on-error: true'); @@ -101,6 +115,11 @@ describe('stable release notes workflow', () => { 'gh release edit "${RELEASE_TAG}" --notes-file "${RELEASE_NOTES_FILE}"', ); expect(changelog).not.toContain('continue-on-error: true'); + expect(changelog).toContain("GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'"); + expect(changelog).toContain('gh auth setup-git'); + expect(changelog.indexOf('gh auth setup-git')).toBeLessThan( + changelog.indexOf('git push origin "${BRANCH_NAME}"'), + ); }); it('updates the changelog before opening the release PR', () => { diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index 44e37eaee4..0b5027a4c4 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -402,14 +402,23 @@ describe('package scripts', () => { it('skips release install-time prepare and builds before publish bundling', () => { const workflow = readWorkflow('.github/workflows/release.yml'); + expect(workflow.slice(0, workflow.indexOf('jobs:'))).not.toContain( + 'CI_BOT_PAT', + ); const installSteps = workflow.match( - / {6}- name: 'Install Dependencies'[\s\S]*? {10}npm ci --no-audit --progress=false/g, + / {6}- name: 'Install Dependencies'[\s\S]*?(?=\n {6}- name: '|\n {4}[A-Za-z0-9_-]+:|$)/g, ) || []; - expect(installSteps.length).toBeGreaterThanOrEqual(5); + expect(installSteps.length).toBe(5); for (const installStep of installSteps) { - expect(installStep).toContain("QWEN_SKIP_PREPARE: '1'"); + expect(installStep).toContain( + 'npm ci --ignore-scripts --no-audit --progress=false', + ); + expect(installStep).toContain('npm run postinstall'); + expect(installStep).toContain('npm run generate'); + expect(installStep).not.toContain('QWEN_SKIP_PREPARE'); + expect(installStep).not.toContain('CI_BOT_PAT'); } for (const jobName of ['integration_none', 'integration_docker']) { @@ -419,6 +428,10 @@ describe('package scripts', () => { } const publishJob = getWorkflowJob(workflow, 'publish'); + expect(publishJob.slice(0, publishJob.indexOf('steps:'))).not.toContain( + 'CI_BOT_PAT', + ); + const checkoutStep = getWorkflowStep(publishJob, 'Checkout'); const gitConfigStep = getWorkflowStep(publishJob, 'Configure Git User'); const commitStep = getWorkflowStep( publishJob, @@ -429,10 +442,22 @@ describe('package scripts', () => { 'Build Bundle and Prepare Package', ); + expect(checkoutStep).toContain('persist-credentials: false'); expect(gitConfigStep).toContain('git config core.hooksPath .husky'); expect(publishJob.indexOf(gitConfigStep)).toBeLessThan( publishJob.indexOf(commitStep), ); + expect(commitStep).toContain("CI_BOT_PAT: '${{ secrets.CI_BOT_PAT }}'"); + expect(commitStep).toContain('export GH_TOKEN="${CI_BOT_PAT}"'); + expect(commitStep).toContain('gh auth setup-git'); + const exportTokenIdx = commitStep.indexOf( + 'export GH_TOKEN="${CI_BOT_PAT}"', + ); + const setupGitIdx = commitStep.indexOf('gh auth setup-git', exportTokenIdx); + expect(setupGitIdx).toBeGreaterThan(exportTokenIdx); + expect(setupGitIdx).toBeLessThan( + commitStep.indexOf('git push --force --set-upstream'), + ); expect(buildStep).toContain('npm run build\n npm run bundle'); });