Skip to content

fix: tier-aware cadence reset — T1 captures no longer starve T2 (issue #364) - #365

Open
ranxianglei wants to merge 3 commits into
masterfrom
2026-09-04_t2-cadence-capture-fix
Open

fix: tier-aware cadence reset — T1 captures no longer starve T2 (issue #364)#365
ranxianglei wants to merge 3 commits into
masterfrom
2026-09-04_t2-cadence-capture-fix

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Problem

Issue #364 (P1): the compress-processing handler in lib/messages/inject/inject.ts reset the tier cadence baselines (lastTier2NudgeTokens / lastTier3NudgeTokens = currentTokens) on every compress — including raw-message T1 captures, which increase tier-1 quality instead of consuming it. In compression-active sessions every T1 capture re-armed the growthFloor wait (22.5K on defaults), so T2 could only fire in the gap between two T1 captures. Observed in a 21-day hub session: tier-1 quality stayed above the 50K trigger threshold for 1222h with 2325 T1 captures in between, T2 firing only after the lag.

Fix

Classify the just-processed compress call by its range-boundary prefix — the convention already documented in lib/compress/state.ts:81-83 (m-prefix = raw T1 capture; b-prefix = T2+ distillation):

  • m-only boundaries → capture → baselines untouched (the fix).
  • any b-prefix (incl. mixed batches) → real distillation → baselines reset as before — preserves the fix: T2/T3 nudge loop — preserve cadence baseline after compress #235 loop-prevention invariant (never undefined).
  • unparsable/missing boundaries → conservative: reset (loop-prevention wins).

New isCaptureOnlyCompress in lib/messages/query.ts (with tolerant extractCompressBoundaryIds: input as object or JSON string). The reset in inject.ts is wrapped in if (!isCaptureOnlyCompress(lastCompressMsg)). No new state fields, no persisted-format change.

Tests

  • tests/query-pure.test.ts: +11 unit tests (m/b/mixed/string/malformed/user/undefined).
  • tests/inject.test.ts: +3 integration tests per AGENTS.md §5.7 — multi-turn capture with production preserve-recent knobs asserting baselines unchanged across turns (side-effect assertion on lastTier2NudgeTokens), distill-reset contrast (fix: T2/T3 nudge loop — preserve cadence baseline after compress #235 lock), full cadence cycle: capture → baseline held → growth → T2 fires.
  • §5.7.3 verified: with the fix temporarily disabled, the capture + cycle tests FAIL, the distill test and all legacy tests stay green.
  • Full suite: 1091/1091 pass; typecheck + build green; scripts/ci/check-pr.sh all checks pass.

Deferred (tracked in #364 discussion)

Devlog

devlog/2026-09-04_t2-cadence-capture-fix/ (REQ.md + WORKLOG.md).

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 Reviewing PR #365 (fix for #364). Initial findings so far:

Bug verified on master. lib/messages/inject/inject.ts:139-140 resets lastTier2NudgeTokens / lastTier3NudgeTokens = currentTokens inside the currentTurnHasCompress handler for every processed compress message — including raw-message T1 captures. Combined with the cadence gate at inject.ts:440-442 (cadenceMet = lastNudge === undefined || currentTokens - lastNudge >= growthFloor, where growthFloor = max(minNudgeGrowthFloor, minNudgeGrowthRatio × nudgeGrowthTokens) = 22.5K on defaults), each T1 capture re-arms the full growth-floor wait while tier-1 tokens keep accumulating past the 50K trigger (inject.ts:439). The starvation mechanism described in #364 checks out against the code path.

Diff reviewed (6 files, +470/−9): the boundary-prefix classification matches the convention already documented at lib/compress/state.ts:81-83 (m-prefix = T1 capture, b-prefix = T2+ distillation), and the conservative fallbacks (unparsable/missing boundaries → reset) preserve the #235 loop-prevention invariant. No new state fields, no persisted-format change.

Now checking out pr/365/head (386d572) to run the full suite, typecheck/build, check-pr.sh, and the §5.7.3 "fix-disabled → new tests fail" verification. Will report back with results + independent review notes.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

📦 Built Plugin Artifact

Branch: 2026-09-04_t2-cadence-capture-fix (e6fd6f3)

Option A — Install from npm PR tag (recommended)

opencode plugin opencode-acp@pr-365 --global

Each push to this PR publishes a new version under the pr-365 npm tag.

Option B — Install from GitHub

opencode plugin "github:ranxianglei/opencode-acp#2026-09-04_t2-cadence-capture-fix" --global

Option C — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf opencode-acp-pr365.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
  1. Restart opencode to pick up changes.

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 ## Review complete — APPROVE (no code changes required)

Full verification + dual-agent review (§5.3 code / §5.6 test, two independent sessions) done at head 386d572 (base master @ 9b7adfd, v1.14.27).

1. Bug verification (precondition for the fix)

Confirmed on master: lib/messages/inject/inject.ts:139-140 unconditionally resets lastTier2NudgeTokens/lastTier3NudgeTokens = currentTokens inside the compress-processing handler (deduped by lastProcessedCompressMessageId, :123-124). Combined with the cadence gate at :440-442 (cadenceMet = currentTokens - lastNudge >= growthFloor, floor = max(5K, 0.45×50K) = 22.5K), every T1 capture re-arms the full wait — exactly the starvation described in #364. The tier-convention claim checks out against lib/compress/state.ts:80-83 (m-prefix = T1, b-prefix = T2+).

2. Verification results on PR head

Check Result
npm run typecheck ✅ clean
npm run build ✅ clean
Full suite 1091/1091 pass (96.3s)
scripts/ci/check-pr.sh ✅ branch name, devlog REQ+WORKLOG, version unchanged → changelog skip
§5.7.3 mutation (my own run) ✅ with the guard disabled (if (!captureOnly)if (true)): capture multi-turn test FAILS, cycle test FAILS, distill-guard test PASSES, all legacy tests green. Restored byte-exact, tree clean, 92/92 re-green.
Diff hygiene ✅ 6 files +470/−9, no version bump, no persisted-state format change, barrel export consistent

3. Dual-agent reviews — both APPROVE-WITH-NITS

Code reviewer verified: /^b\d+$/i matches the canonical block-ref grammar in lib/message-ids.ts:70-91 (no false pos/neg; unresolvable edge refs like b0 fail compression anyway → conservative direction is safe); message-mode input shape was removed in PR #233 so legacy shapes classify conservatively; classification runs once per new compress message only (negligible perf); fresh-session/resetOnCompaction path (baselines undefined) fires T2 at most once when tier-1 ≥ 50K and self-throttles via :535 — no new loop risk; #235 invariant (never undefined) preserved for all b-prefix/unparsable cases.

Test reviewer traced every assertion to its code path (cycle-test turn B: currentTokens = exactly 136K from last assistant usage; min-nudge floor 15%×1M = 150K provably suppresses the T1 nudge, so shouldInjectThisTurn=true can only come from the T2 tier trigger — good isolation). §5.7 satisfied: multi-turn shared state, side-effect assertions on baselines, production preserveRecentMessages=5. Independent mutation run reproduced my §5.7.3 result exactly (25 !== 500000, 135000 !== 100000; distill test unaffected).

Non-blocking nits (all minor, optional follow-ups):

  1. Classification uses findLast (last compress msg of the turn) — a same-turn distill→capture interleaving wouldn't reset; degrades gracefully to the designed cadence since T2 firing itself advances the baseline (:535-537). A turn-aggregate some(m => !isCaptureOnlyCompress(m)) would be strictly more correct.
  2. JSON-string branch in extractCompressBoundaryIds is defensive dead code under current SDK types (state.input is always an object) — harmless future-proofing.
  3. JSDoc "(mNNNNN boundaries)" is looser than actual any-non-b behavior; garbage boundary strings classify capture-only but such calls fail resolution anyway.
  4. Test nit: seeded blocks omit some CompressionBlock fields — identical to the merged [Bug]: Growth nudges ignore minContextLimit and fire below it #342 test pattern, and tests/ is excluded from typecheck.

None of these affect correctness or the #235/#364 guarantees. Happy to take nit #1 as a small follow-up if you want it.

4. Status & next steps

ework-agent added 2 commits September 4, 2026 19:54
… hardcodes scenario paths; bot PAT lacks workflow scope to edit it); re-scoped content only
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant