Files
ea3017b930 feat(memory): opt-in extraction cadence after a no-op run (#13571)
* feat(memory): opt-in extraction cadence after a no-op run

Managed auto-memory forks an extractor after every user turn, about
9-10k input tokens even when it saves nothing. Add an internal, default-off
experiment (QWEN_CODE_MEMORY_EXTRACT_NOOP_SKIP_TURNS, 0-3) that skips up to
N turns after a run that read memory and saved nothing.

The extractor reads the last 40 curated entries captured after the turn,
not the cursor's unprocessed slice, so a skip is allowed only while every
unprocessed entry still fits half of that tail, and only below the
compaction warning. Failed or MAX_TURNS runs never arm; the cursor and the
#6311 hold-back are unchanged.

Skipped turns are flushed before boundaries that would discard them:
compaction (all paths, in the compression service), /clear, /resume,
/branch, the OpenTUI session switch, and ACP session close. Cadence state
is per session and in memory only. Process exit is unchanged.

Refs #13004

* fix(cli): flush skipped memory turns before session usage is captured

/resume, /branch and the OpenTUI switch flushed after opening the telemetry
swap transaction, and /clear flushed after persisting session usage, so
the flush's own model call could land outside the outgoing session's
recorded usage or be dropped by a rollback. Flush first, while the
outgoing session is still current and before either capture.

* test(memory): pin a skipped turn surviving an older trailing run

* ci(docs): format the daemon event-schema table for the Prettier gate

docs/developers/daemon/09-event-schema.md reached main unformatted in
8505479d87 (#13281), whose docs-only CI profile skipped the Prettier lane.
scripts/lint.js --prettier runs `prettier --check .` across the whole repo, so
every branch with that commit as an ancestor now fails Lint & Static on a file
it never touched. Normalize the emphasis markers and table padding so this
branch's own gate can pass.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmuxqta15bw

* fix(memory): make the pending-extract flush honest about what it saved

`flushPendingExtract` deleted the cadence entry up front and resolved `true`
for any run that did not throw, so every boundary certified success while the
owed turns could be gone. Tighten the method around what it can prove:

- resolve `false` and keep the pending turns when the bypass run extracted
  nothing (session mismatch, memory pressure, a queued slot) or threw, so a
  later boundary retries them instead of a no-op consuming them
- share one run between overlapping boundaries for a session, each caller
  keeping its own timeout, so a second boundary waits instead of passing at
  once on an entry the first one already took
- delete the entry again after the bypass run settles: `recordCadenceOutcome`
  re-armed it under the outgoing session id, which `/resume` restores, so a
  resumed session inherited a skip the design forbids
- drop the snapshot when the live session moved on (`startNewSession()`
  mutates the Config in place, so both sides of the cache-safe mismatch guard
  move together and it never fires), and when a newer run for the session
  already covered it, which is what otherwise persists the extract cursor
  backwards and turns the next run into a full re-scan
- correct the JSDoc: a run whose caller timed out keeps going but is not
  registered in `inFlight`, so `drain()` does not wait for it; log the
  timeout arm instead of resolving `false` silently

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuy53ol838

* refactor(memory): keep only the ACP-close flush for skipped extraction turns

Shrink #13571 to the opt-in cadence plus the one boundary flush #13004
adopted for phase 1:

- drop the /clear, /resume, /branch and OpenTUI switch flushes and the
  pre-compaction flush; a switch now drops both sessions' cadence state
  (Config.startNewSession -> discardExtractCadence) so a resumed id never
  inherits a skip
- give the ACP close flush only what remains of the close budget, so the
  close stays under the bridge's outer wait
- fail closed in isBelowCompactionWarn on a zero prompt count
- state the skip-time-only window bound as a known limit, and record the
  experiment and its boundary losses in the design doc (EN and zh)

* fix(memory): reset the #13004 cadence state and correct five stale comments

`resetExtractStateForTests()` documented itself as clearing all extract
scheduling state but skipped the two maps this PR added, so a shared-manager
suite that armed a cadence skip leaked it into the next test and silently
suppressed that test's extraction. Clear both.

Also correct five comments that claimed guarantees the code does not give:
the flush JSDoc said `drain()` never waits for a caller-timed-out flush, but
the extraction it schedules is tracked; `EXTRACT_FLUSH_TIMEOUT_MS` is a
no-budget fallback, not the ACP-close bound; the flush slot comment named a
daemon-mode peer session that cannot appear in a per-manager map; the cadence
skip window is measured at skip time only; and a skip armed below the warn
tier can still be pending when compaction fires.

Comment-only apart from the two `clear()` calls, which the new
`resetExtractStateForTests()` case pins.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuyk3t08d5

* docs(memory): keep the flush slot comment true across a session switch

Self-review of the previous commit: my rewrite of the slot comment claimed
another session's extraction can never appear in these maps. It can —
`startNewSession()` mutates one canonical Config in place (config.ts:6197 calls
`discardExtractCadence(previousSessionId, nextSessionId)` on this same manager)
and both maps are keyed by projectRoot/taskId, not by session, which is why the
stale-session guard right below exists. Name both holders instead.

Also drop "smaller" from the `EXTRACT_FLUSH_TIMEOUT_MS` doc: the ACP close
budget is caller-supplied, so the residue is not provably under 60s.

Corrects the previous commit's message as well: the new test pins
`extractCadence.clear()`; `extractFlushRuns.clear()` is contract-only, since no
caller reaches the helper with a live flush.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuyk3t08d5

* fix(acp): keep the #13004 close flush from sizing process exit

disposeSessions() reached closeStoredSession with no drainTimeoutMs, so the
close flush sized itself from SESSION_DRAIN_TIMEOUT_MS (30s). Both of its
callers are process exit (SIGTERM/SIGINT and IDE disconnect): nothing bounds
that drain from outside, and the generation aborts ahead of it make the close
gate and drain return at once, so the flush received almost the whole 30s and
held exit — and every session MCP subprocess — open for it. A forked extraction
ran ahead of drainPoolBeforeExit, runExitCleanup and process.exit(0), which is
what the comment above runExitCleanup exists to prevent.

Process teardown now skips the flush. That is what the design record already
promised ("process exit do not flush: up to N skipped turns are lost there"),
so this narrows the code to the documented contract rather than adding a second
timeout knob to tune. The interactive close and the daemon's sessionClose keep
flushing inside their bridge-sized budget.

Test: a disposal case whose flushPendingExtract never settles. Before the fix
disposeSessions() does not reach the pool drain within 2s (red: "expected
'timeout' to be 'disposed'"); after it, the flush is never called and the close
still runs to completion.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuyqjactdi

* test(memory): pin the cadence flag and the warn tier; correct the close-flush claim

R1-14 (pin 1 of the class): extract-cadence.test.ts covers
getExtractNoopSkipTurns' digit-only parsing, trim and 0-3 clamp. It is the flag
every other cadence site is downstream of, so it is the cheapest pin; the
remaining mutations in the class are not claimed here and the thread stays open.
Each mutant was run alone: dropping .trim() -> "expected +0 to be 2", dropping
the /^\d+$/ check -> "abc: expected NaN to be +0", dropping the Math.min clamp
-> "4: expected 4 to be 3".

R2-2: the isBelowCompactionWarn fake reported exactly the values the
implementation falls back to — DEFAULT_TOKEN_LIMIT is 200_000, the same window
the fake returns, and computeThresholds already defaults an undefined pct to
DEFAULT_PCT — so both of its cases stayed green under either fallback. Added a
32K-window case and a 0.5 autoCompactThreshold case that differ from both.
Mutants run alone: window forced to DEFAULT_TOKEN_LIMIT and pct dropped each
fail with "expected true to be false".

R2-4: the design record stated the ACP close flush as an unqualified guarantee
and left out the one boundary where it drops silently. The cache-safe slot the
flush reads is process-global (forkedAgent.ts currentCacheSafeParams), so in a
multi-session daemon another session's turn-completion capture makes
getSessionMismatchResult fire and runPendingExtractFlush return false — a
boolean the ACP caller discards without a log. Both the EN record and its zh-CN
twin now call the close flush best-effort and list that boundary among the
losses the paired runs must measure.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuyqjactdi

* test(memory): drop the message argument vitest/valid-expect rejects

* fix(memory): drop cadence outcomes of extractions in flight across a switch

recordCadenceOutcome re-created the entry discardExtractCadence had just
dropped when the extraction finished after the switch, and a restored id
(resume, or a failed resume rolling back) then met an unearned arm. Every
discard bumps an epoch; a run captured before it drops its outcome.

* fix(memory): drop a queued extraction's cadence outcome across a switch

Capture the cadence discard epoch where a trailing extract request is
queued rather than where runExtract starts, so a switch landing while the
request waits in extractQueued still drops its outcome instead of re-arming
the departed session id (R3-4). Refresh the stored epoch when a newer turn
supersedes the queued params.

Bump the same counter in resetExtractStateForTests() so an already in-flight
run cannot re-arm an id the helper just cleared, and correct that test's
justification comment, which described a shared-manager shape no suite in
this repo has (R2-5). Pin the DEFAULT_TOKEN_LIMIT fallback of
isBelowCompactionWarn with a no-window config, and correct the comment that
claimed the two existing cases cover both fallbacks (R2-2).

Reword the design doc's compaction claim in EN and zh-CN: the warning-tier
condition only makes compaction less likely, since the screenshot trigger
and a forced /compress both compact below the warn tier (R3-6).

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuzmop02ey

* fix(memory): keep a carried skip's spent cadence budget

A trailing run whose snapshot predates the pending skip carries that skip
forward, but `recordCadenceOutcome` still reset `skips` to 0, so the session
could skip up to 2N consecutive turns while the design record and the
`cadence` telemetry certify "up to N skipped turns". Keep the spent budget on
that branch and pin it with a test that goes red without the change.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuzzjnorfk

* fix(memory): distinguish a history shrink from a trailing cadence run

The carry test in recordCadenceOutcome ordered two history lengths, but
chat compaction shrinks history too, so after one compaction every later
completed run re-stored the pre-compaction pending snapshot and reset
lastExtractedLength to the short post-compaction length. A run shorter
than what the session already extracted now means the snapshot predates a
shrink and is dropped instead of carried or replayed by the close flush.

The memory_tool branch also kept the pending snapshot it had just
disarmed, so a close-time flush inspected the skipped turn while forking
over the live tail that wrote the memory file; drop the snapshot with it.

The shouldSkipForCadence doc comment claimed a skip can only overlap a
trailing request, but the cadence is evaluated before the extractRunning
branch, so a direct run overlaps it too.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmv0cemawgb

* fix(memory): bind a flush's answer to its own snapshot

`extractFlushRuns` was keyed by session id alone, so a boundary that
flushed after a skip had replaced `pending` joined an older run's promise
and was told those turns were extracted, while a run that had parked on the
project slot before the skip deleted the newer snapshot's cadence entry
unconditionally on success. Key the shared run by the snapshot it owns, and
let the success path drop the entry only when that snapshot still owns it.

Two cases in `manager.test.ts` cover each half: a retry issued while the
older run is still parked, and a retry after it settled without extracting
the newer snapshot.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmv0goxwkgj

* fix(memory): key the extract cadence entry by project root

`/cd` and the daemon's `session/cd` call `Config.relocateWorkingDirectory`,
which moves a live session's project root without a session switch.
`startNewSession()` is the only production caller of `discardExtractCadence`,
so an armed skip survives the move and suppresses a turn in the new project on
the strength of a no-op run that only ever read the old project's memory --
certifying "nothing to remember" about files it never opened. Neither route
clears history either, so `pendingEntries` stays small and positive and the
gate keeps firing.

Record the project root on the cadence entry and refuse to skip when it no
longer matches. Every other piece of extract scheduling state in this class
(`extractRunning`, `extractCurrentTaskId`, `extractQueued`) is already keyed by
project root; the cadence map was the one that was not.

The parked snapshot is deliberately left in place rather than discarded on
mismatch: `runPendingExtractFlush` still runs it against its own captured
`projectRoot`, which is where those owed turns belong, and `extract.ts` resolves
every path from `params.projectRoot`. A root guard on the flush side would
destroy that owed extraction instead of protecting it.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmv0tjwjth4

* fix(memory): invalidate a parked extract snapshot when history shrinks under it

The cadence carry and the close-time flush both used history length as a
stand-in for temporal order, so a compaction that shrank the history to a
length still above the recorded cursor read as a newer trailing request:

- scheduleExtract carried the pre-shrink snapshot forward together with its
  spent skip budget, keeping the gate shut until the history regrew past it.
  It now drops the snapshot at admission when the incoming history is shorter,
  which within one session only a shrink can produce.
- runPendingExtractFlush refused only on a forward cursor comparison, so a
  shrink landing while it was parked let it replay the abandoned snapshot and
  resolve true to a boundary that had already moved on. It now also refuses
  when the entry holds no pending snapshot at all.

Each case is pinned by a test that goes red when its own fix is reverted.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmv22k9vj0e

* fix(memory): drop a pending flush snapshot after the session relocated

The close-time flush replayed a pending snapshot earned in project A
after `/cd` moved the session to project B. The fork's conversation
comes from the session's cache-safe capture, not from the snapshot's
history, so the run read B's turns against A's memory context and wrote
A's memory from them. `relocateWorkingDirectory` mutates the live
Config in place, so the session-id guard cannot see the move.

`runPendingExtractFlush` now checks the live project root the same way
it checks the live session id, and drops the snapshot instead of
forking it.

Also records the `cadence` skip reason in the scheduleExtract flow doc
that the PR body and telemetry unions already carry.

* fix(memory): drop a cadence outcome for a root the session already left

`/cd` relocates the live Config in place and fires no discard, so an
extraction still in flight for the old root settles after the new root has
re-keyed the entry and parked its own snapshot. The whole-entry
`extractCadence.set` in `recordCadenceOutcome` was root-blind: it overwrote
the live root, watermark and skip budget with the abandoned root's and
dropped the pending snapshot, so the ACP close flush found nothing to
extract and resolved `true` — certifying turns as extracted that never were.

Guard the outcome on the live root, mirroring the flush relocation guard,
and compare against `params.config.getProjectRoot()` rather than the carried
entry's root so the first run after a `/cd` can still re-key the entry and
re-arm the cadence in the new project. Placed after the disabled-experiment
delete so turning the flag off still clears state.

This is the only `extractCadence.set` call in the file, so guarding it closes
the cadence write entrance for the relocation class.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmv2nzvmx19

* fix(core): keep the cadence in sync through relocation and shrink

Three follow-ups from the review of the opt-in extraction cadence, all in
the flush bookkeeping around a pending snapshot:

- `recordCadenceOutcome` now records the coverage of a completed run before
  the root-change and discard-epoch guards drop its outcome. Both exits leave
  the session's entry — and any snapshot parked behind it — in place, so a
  later boundary replayed a snapshot the run had already extracted and
  persisted the cursor backwards, which the next run reads as a reason to
  re-scan everything after it. Only the snapshot this run's own root parked
  is covered: after a `/cd` the entry belongs to the live root, whose newer
  snapshot this run never extracted.
- `runPendingExtractFlush` tests snapshot identity before length. A newer run
  that shrinks the history and then skips again leaves a *shorter* snapshot
  parked, which the forward-only length comparison cannot see, so the parked
  flush forked turns no live history had.
- The relocation guard clears only the snapshot it refuses. A `/cd` keeps the
  session, so a skip in the new root can have parked a newer snapshot behind
  the parked flush; deleting the entry left that snapshot with no run at all.

Each case is pinned by a test that goes red when its own fix is reverted.

* fix(memory): abandon a parked extract flush once its budget expires

* docs(memory): record the abandoned flush in the loss table

* refactor(memory): trim extraction cadence API surface

* fix(memory): preserve skipped turns and invalidate stale cadence

* test(memory): match the skippedHistory arg in the extractAgent spy assertion

runAutoMemoryExtract now forwards params.skippedHistory as the third
argument to runAutoMemoryExtractionByAgent, so the spy receives three
arguments. The existing assertion still expected two, which made
Test (ubuntu-latest, Node 22.x) fail deterministically on this branch.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmv9bozsb73

* fix(memory): retain skipped history until extraction consumes it

* fix(memory): keep cadence snapshots honest across compaction, close, and image-only tails (#13004)

- Date cadence coverage/carry by request order instead of history length,
  so a compaction between schedule and settle can no longer drop a parked
  snapshot a run never extracted (R6-2, R13-1).
- Abort the close-flush fork when its budget expires instead of letting it
  run against a torn-down Config; the aborted outcome stays pending for a
  later boundary (R13-2).
- Treat parked skipped history as new input so an image-only unprocessed
  tail cannot swallow it through the no-new-message early return (R13-4).

Each regression is pinned by a test built from the reproduced failure arm;
findings were verified against a458f81e before fixing.

* style: apply prettier to the extract-cadence files and merged docs

The Lint & Static lane short-circuited at the freshness gate on this
stale branch, so step 31 Run Prettier never ran. After merging main the
gate resolves and prettier flags four files: the two design docs brought in
by the merge and this PR's memory manager sources. Formatted only those
files, no whole-tree rewrite.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmvdo5pktf4

* fix(memory): measure cadence windows from the snapshot's own base (#13004)

Park skippedFrom beside the skipped slice so a carried settle cannot move
the supplement and skip-window gates off the slice's real base, name the
40-entry capture bound once, and splice no foreign-root snapshot into a
re-keyed entry. Drop the part of the slice the captured tail still holds
at the fork join instead of replaying it, and invalidate a parked snapshot
where compaction shrinks the history, before a cancelled turn hides the
shrink from every scheduleExtract.

* test(memory): pin the cross-root carry guard at the supplement gate (#13004)

The regression case for the carry root condition probed at history 43,
measured from the entry's re-keyed base (43 - 2 = 41 > 40) — the same
span both with and without a foreign snapshot carried, so removing
previous.projectRoot === params.projectRoot from the carry left it
green (verified by mutation). Probe at 45 instead: measured from the
snapshot's own base (skippedFrom 4) the gate fires exactly when a
foreign snapshot survives the settle, and the case now fails when the
root condition is reverted.

* fix(memory): dedup a parked slice the captured tail fully covers (#13004)

uncoveredParkedPrefix only proved coverage when the captured tail began
inside the parked slice. In the close-flush shape the tail is the whole
short history, so it begins before the slice: the anchor matched nothing
and the whole slice was prepended again — the duplication R14-1 asked to
remove. Prove that shape separately with a contiguous whole-slice match,
which one matching value cannot, since repeated content would otherwise
be able to cut turns the tail never held.

Probe: parked [turn 2, reply 2] inside the 6-entry tail produced 8
extraHistory entries before this change and 6 after; removing the new
branch turns the regression case red.

* test(memory): pin the supplement window boundary (#13004)

The supplement gate is a strict past-window test measured from the parked slice's own base, but every existing case probed it one entry past the boundary, where a >= comparison would pass too. At exactly 42 - 2 = 40 the captured tail still holds the slice, so the turn extracts without prepending it; flipping the gate to >= turns this case red and leaves the rest green.

* fix(memory): refuse cadence replays whose state no longer owns them

A parked flush fell through both staleness guards when its cadence entry
had been deleted whole (`state === undefined`), and forked a snapshot
nothing owned, persisting its cursor over the live one. Refuse the replay
when the entry is gone.

A run whose early return advanced the cursor without forking (no
new-user-text tail, session mismatch, memory pressure) was still taken as
coverage for a parked snapshot by the coverage delete and the re-key. Only
a run that forked settles the entry; the others retry the parked turns.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvfrj6szjg

* fix(memory): prove parked-slice coverage by whole runs, invalidate on rewind

`uncoveredParkedPrefix` accepted a single entry equal to the tail's first
one as proof, so an unrelated later turn sharing only a tool name dropped
every parked entry behind it — including the durable instruction it
carried, which nothing re-scans once the cursor moves. Compare the whole
overlap entry for entry, include `functionCall.args` in the key, and take
the longest overlap so repeated content cannot cut unheld turns.

`LlmClient.setHistory()`/`truncateHistory()` shrink the timeline a parked
cadence snapshot was cut from without a session switch, so the close flush
replayed it at `bypassCadence` over a history that no longer holds those
turns. Invalidate the parked snapshot where the shift is decided, next to
the existing file-read-cache and IDE-context invalidations.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvfrj6szjg

* style(memory): apply prettier to extractionAgentPlanner test

Lint & Static (run 39329627709) flagged
packages/core/src/memory/extractionAgentPlanner.test.ts; formatting-only fix,
locally reproduced with `npx prettier --check` before and after.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmvftbhkrjk

* fix(memory): prove parked-slice coverage over the captured rendering

The coverage proof compared the raw parked slice with the curated, slimmed
tail, so media entries and curation-merged user turns never matched and the
whole slice was prepended again: duplicated turns, inverted chronology and
re-shipped media. Slim the slice as the capture does and compare flattened
part sequences, which curation merges but never rewrites.

* fix(memory): guard the #13004 cadence against stale supplements, cursor rewinds and cross-chat invalidation

Three defects in the extract-cadence state this PR adds, each confirmed real
by source read at eb955298ee and each now pinned by a regression test:

- R18-1: the parked-slice supplement was spliced at schedule time only, so a
  request waiting in extractQueued replayed a slice a concurrent run had
  already consumed and had its snapshot deleted for. runExtract now re-checks
  the stamped snapshot identity and drops the orphaned slice. A flush replay
  carries no stamp, so the slice the flush exists to extract survives.
- R18-2: a close-time flush replay wrote the project extract cursor backwards
  (to the snapshot length, or to 0 after the shrink clamp) after a non-forking
  run had already persisted a later offset. The replay's write is now floored
  at the offset it read; a genuine post-compaction run still moves it down.
- R18-3: the compaction shrink hook was keyed on the Config's session id while
  compress() runs per chat, so a subagent's derived Config (same session id,
  same manager) or a forked chat (same Config instance) dropped the main
  session's parked snapshot. invalidatePendingExtract now takes the requesting
  Config and refuses a snapshot another Config owns, and the call site skips
  forked chats. Callers that pass no owner keep the unconditional drop.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvn9lerfu3

* fix(memory): retain replay debt until extraction makes progress

* test(cli): mock memory invalidation in restore admission fixture

* fix(memory): drop the parked extract snapshot on the fast-compression rewrite

`tryCompressChatFast()` rewrites history through `LlmChat.compressFast()` ->
`LlmChat.setHistory()`, so it bypasses both existing invalidation hooks:
`LlmClient.setHistory()` and `ChatCompressionService.compress()`.

`compressFast()` drops thought-only model turns via
`stripThoughtPartsFromContent()`, so the live history really does shorten. A
cadence snapshot parked by `QWEN_CODE_MEMORY_EXTRACT_NOOP_SKIP_TURNS` then
survives the shrink. If the session closes before another turn reaches
`scheduleExtract()` (whose shrink guard would have deleted it),
`runPendingExtractFlush()` replays it with `bypassCadence: true` — the very
flag that exempts the replay from that guard — writing durable memory derived
from a timeline that no longer exists and resolving `true` to the boundary.

Invalidate at the fourth rewrite site, alongside the sibling post-rewrite
resets and after the COMPRESSED early return: a fast compression that shrinks
nothing (`afterEstimate >= beforeEstimate`) never rewrites history and must
not drop the snapshot.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvo5qtfnv2

* fix(memory): cut the cadence park from the base the fit test measured

shouldSkipForCadence sized the skip fit from
`pendingFrom = state.pending?.skippedFrom ?? state.lastExtractedLength`
but cut and stamped the parked snapshot from `state.lastExtractedLength`
alone. When an in-flight run scheduled before the park settles and takes
recordCadenceOutcome's carry branch, it writes `lastExtractedLength` from
its own longer, pre-compaction index space while leaving
`pending.skippedFrom` older. The next skip then parks
`history.slice(lastExtractedLength)` -- an offset counted in a different
index space -- so `skippedHistory` comes out empty and the previously
parked turns are silently discarded, and the recorded `skippedFrom`
exceeds the snapshot's own `history.length` against the field contract.

Cut and stamp the park from `pendingFrom` so the gate and the park share
one base. With no snapshot parked, `pendingFrom` is
`state.lastExtractedLength`, so that path is byte-identical to before, and
the existing `pendingEntries` bound keeps the slice inside
EXTRACT_CADENCE_MAX_PENDING_ENTRIES.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvoils4rvh

* fix(memory): retain skipped turns across foreground memory writes

* fix(memory): keep the cadence experiment opt-in and align its gates

Four deferred findings on the #13004 extraction-cadence surface:

- Both test setups now delete QWEN_CODE_MEMORY_EXTRACT_NOOP_SKIP_TURNS, the
  way they already drop other operator-owned inputs. The suites read the flag
  from the ambient environment before, so one exported value switched the
  experiment on for unrelated suites: 153/544 core client tests and 637/1292
  cli Session tests failed with no code change.
- The flag was absent from PROJECT_ENV_HARDCODED_EXCLUSIONS, so a project
  .env could switch a default-off experiment on for every session in that
  repository. It joins the hardcoded tier, pinned in shared-env-keys.
- isBelowCompactionWarn compared the bare last prompt count while both
  compaction gates also measure the retained output. Callers now pass
  prompt + output, and the doc states the quantity and the WARN_BUFFER
  reserve instead of the false "read crossed routes" premise.
- The cadence gate ran before the memory-pressure gate, so a pressured turn
  was recorded as a 'cadence' skip and spent the experiment's budget. The
  cadence gate now yields to pressure, and a memory_pressure result no longer
  disarms the entry.

* test(memory): pin the cadence telemetry and the session-switch discard

Two R1-14 sites on the #13004 surface shipped without a test that could fail
on them:

- The cadence skip's telemetry event: a case now passes a Config and asserts
  logMemoryExtract receives status 'skipped' with skipped_reason 'cadence'.
  Mutating the reason reddens it.
- The switch-side discard in Config.startNewSession: a case spies on
  MemoryManager.discardExtractCadence for both a fresh id and a same-id
  resume. Removing the call reddens it.

Re-measured R17-12 at this head and found nothing to fix: the persistent
runAutoMemoryExtract implementation installed by the resetExtractStateForTests
suite is undefined by the time the next suite runs even with no reset in
place, so the leak the finding describes does not reproduce here.

* test(memory): pin the epoch refresh when a request supersedes the queue

R1-14: deleting `queued.epoch = this.cadenceDiscardEpoch` left the whole
suite green. The refresh is what keeps a request that supersedes a queued one
after a session switch judged by the new epoch, so its completed run still
arms the cadence for the session it now describes — without it the superseded
run's outcome is discarded as pre-switch and the experiment goes inert until
the next completed run.

* fix(memory): report cadence flushes a close boundary drops

D6-4: the close flush was dropped silently when the close budget was already
exhausted, and runPendingExtractFlush returned false silently when its own
deadline expired while it waited for the project slot. At a close no later
boundary retries, so those turns are lost. Both drops now emit one warning
naming the reason and the owed turn count, through a new
MemoryManager.pendingExtractTurns. No retry and no deadline extension.

Pins: the ACP close harness asserts that a positive residue runs the flush
with the remaining budget, and that an exhausted budget skips it and reports
reason 'close_budget_exhausted' with owedTurns; the manager suite pins
'flush_deadline_expired'. Removing either report reddens its case.

Also dedupes the resolver-capture pattern in the cadence suite onto the
file's deferred<T>() — 17 sites through one holdNextExtract helper, net -110
lines with assertions untouched — and pins the skip-window negative half.

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
2026-10-27 22:08:18 +08:00
..