Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions devlog/2026-09-04_t2-cadence-capture-fix/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# REQ - T2 distillation starved by per-compress cadence reset (issue #364 P1)

## Context

Source: issue #364 (ranxianglei/opencode-acp). A 21-day hub session (glm-5.3, limit=1M,
v1.14.x) fired Tier-2 distillation only 3 times; tier-1 quality crossed the 50K trigger
threshold 4 times, twice with 12~22h delays while remaining above threshold (23 and 25
T1 captures in between).

## Root cause (verified on current master)

`lib/messages/inject/inject.ts` compress-processing handler (`:117-179`): every NEW
compress message — regardless of tier — reset the tier cadence baselines:

```ts
state.nudges.lastTier2NudgeTokens = currentTokens
state.nudges.lastTier3NudgeTokens = currentTokens
```

The reset was introduced by #235 to stop T2/T3 re-trigger loops (undefined baseline =
"never fired" → immediate re-fire). But it also fires for raw-message T1 captures,
which INCREASE tier-1 quality instead of consuming it. In compression-active sessions
every T1 capture re-arms the growthFloor wait (22.5K on defaults), so T2 can only fire
in the gap between two T1 captures — systematic distillation starvation.

## Scope decision

- THIS PR — fix #2 of the issue (tier-aware cadence reset). Confirmed live bug, auto-fixes
the 12~22h delays, no product decisions required.
- DEFERRED — fix #1 (decouple T2/T3 trigger threshold from `nudgeGrowthTokens`, new config
field + default value). Needs the owner's ruling on the default (absolute ~20K vs
anchor-count >= 12 vs dual whichever-first); ~8-file config surface. Fast follow-up.
- DEFERRED — fix #3 (tier checks also run on T1-nudge turns): after this fix T1 nudges
are spaced by growthFloor, so the residual T2 delay is one turn, not hours.
- DEFERRED — fix #4 (pointer-ize consumed anchors): separate issue.

## Design

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

- `mNNNNN` boundaries only → raw-message capture → do NOT touch tier baselines.
- any `bN` boundary → real distillation/condensation → reset baselines (preserve #235).
- unparsable/missing boundaries → conservative: reset (loop-prevention wins).

## Acceptance criteria

- [x] T1 capture compress leaves `lastTier2NudgeTokens`/`lastTier3NudgeTokens` unchanged.
- [x] Block-ref distill compress still resets them (and never to `undefined`) — #235 lock.
- [x] Existing #235 regression test (inject.test.ts phase 1-3) still green.
- [x] §5.7: multi-turn, side-effect assertions on baselines, production config
(`preserveRecentMessages > 0`), growth-cycle test.
- [x] New tests FAIL when the fix is reverted (verified by temporarily disabling the guard).
- [x] typecheck + build + full suite green.
57 changes: 57 additions & 0 deletions devlog/2026-09-04_t2-cadence-capture-fix/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# WORKLOG - T2 distillation starved by per-compress cadence reset (issue #364 P1)

## Changes

| File | Change |
| --- | --- |
| `lib/messages/query.ts` | New `isCaptureOnlyCompress` (boundary-prefix classifier) + `extractCompressBoundaryIds` (tolerant input reader: object or JSON-string). Conservative false on unparsable input. |
| `lib/messages/inject/inject.ts` | Import `isCaptureOnlyCompress`; wrap the tier-baseline reset in `if (!isCaptureOnlyCompress(lastCompressMsg))` — captures no longer move the baselines. |
| `tests/query-pure.test.ts` | +11 unit tests for the classifier (m/b/mixed/string/malformed/whitespace/user/non-compress/undefined). |
| `tests/inject.test.ts` | +3 integration tests: capture multi-turn baseline-hold (§5.7, production preserve-recent knobs), distill reset contrast (#235 lock), full cadence cycle (capture → baseline held → growth → T2 fires). |

## Verification

- Full suite: 1091/1091 pass (`npm run test`).
- typecheck (`tsc --noEmit`) + build (`tsup`) green.
- Fail-without-fix (§5.7.3): temporarily set `captureOnly = false` →
`issue #364 P1` and `issue #364 cycle` tests FAIL; the distill contrast test and all
legacy tests stay green; fix then re-applied.
- Existing #235 regression test unaffected: its compress fixture uses `input: {}` →
classifier returns conservative false → reset still happens.
- CI Docker E2E caught a semantics change in scenario 11 (first PR push failed
`e2e: tier2BaselineSet === true — got null`). The fake LLM can only emit m-refs
(scripts/e2e/README Known Limitation 1), so scenario 11's `tier2BaselineSet: true`
was locking the OLD unconditional-reset behavior — a T1 capture no longer sets the
baseline (that IS the fix). Content re-scoped to assert `tier2BaselineSet: false`
(unset stays unset through captures); the #235 never-undefined invariant remains
locked by the unit tests (phase 1-3 + b-prefix contrast). The FILENAME is kept as
`11-tier2-baseline-preserved-after-compress.json` (now a slight misnomer): the e2e
job in `.github/workflows/ci.yml` hardcodes the explicit scenario path list, and
the bot PAT lacks `workflow` scope — pushes touching `.github/workflows/` are
rejected by the remote ("refusing to allow a Personal Access Token to create or
update workflow ... without `workflow` scope"), so ci.yml cannot be updated from
this environment. A human may rename file + `ci.yml:59` together in a cosmetic
follow-up. README scenario table updated. E2E coverage of the distill reset path
needs fake-LLM b-ref support — tracked as known limitation, not introduced here.

## Notes / decisions

- Detection reads the last compress message's tool-part input boundaries; matches the
documented convention in `lib/compress/state.ts:81-83`. No new state fields, no
persisted-format change.
- Mixed `m`+`b` batch treated as distillation (conservative for loop-prevention).
- `lastTier3NudgeTokens` also moves on any real distillation (T2 or T3) — harmless:
the T3 threshold gate (`tier2Tokens >= nudgeGrowthTokens`) independently prevents
premature T3 firing.
- During the cycle test the pre-existing downward baseline correction
(`inject.ts:294-302`) legitimately re-anchors `lastPerMessageNudgeTokens` to
currentTokens — asserted explicitly to document the interaction.
- Environment hiccup during work: workspace volume hit ENOSPC mid-task; resumed after
~300 MB freed; one edit was silently truncated (lost a `})`) and was repaired by the
syntax-error bisect (esbuild "Unexpected end of file" → structure map → restored).

## Follow-ups

- Fix #1 (decouple T2/T3 threshold; new `tierTriggerTokens` config field) — waiting on
owner's default-value ruling (issue #364 discussion; interacts with #300).
- Fix #3 (tier checks on T1-nudge turns) — optional, residual delay is one turn now.
24 changes: 16 additions & 8 deletions lib/messages/inject/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isProtectedUserMessage,
messageHasCompress,
messageHasCompressAttempt,
isCaptureOnlyCompress,
} from "../query"
import { saveSessionState } from "../../state/persistence"
import {
Expand Down Expand Up @@ -130,14 +131,21 @@ export const injectCompressNudges = (
state.nudges.iterationNudgeAnchors.clear()
state.nudges.lastNudgeShownTokens = undefined
state.nudges.lastToolOutputNudgeTokens = undefined
// Preserve tier cadence baselines instead of resetting to undefined.
// Resetting to undefined causes T2/T3 to immediately re-trigger on
// the next turn (cadence check treats undefined as "never fired"),
// creating a loop: T2 fires → compress attempted → baseline reset
// → T2 fires again. Set to currentTokens so the growthFloor gate
// applies naturally.
state.nudges.lastTier2NudgeTokens = currentTokens
state.nudges.lastTier3NudgeTokens = currentTokens
// Preserve tier cadence baselines instead of resetting to undefined
// (undefined = "never fired" → T2/T3 re-trigger immediately after
// their own compress — issue #235). Set to currentTokens so the
// growthFloor gate applies from here on.
//
// But only real distillations/condensations (block-ref boundaries)
// may move the baselines. A raw-message T1 capture only ADDS
// tier-1 summaries; resetting after every capture re-arms the
// growthFloor wait — that is what starves T2 in compression-active
// sessions (issue #364 P1).
const captureOnly = isCaptureOnlyCompress(lastCompressMsg)
if (!captureOnly) {
state.nudges.lastTier2NudgeTokens = currentTokens
state.nudges.lastTier3NudgeTokens = currentTokens
}

const currentTurnHasSuccessfulCompress = messages
.slice(currentTurnStart)
Expand Down
75 changes: 75 additions & 0 deletions lib/messages/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,81 @@ export const messageHasCompressAttempt = (message: WithParts): boolean => {
return parts.some((part) => part.type === "tool" && part.tool === "compress")
}

/**
* Classifies a compress tool call by its range boundaries to detect whether it is
* a raw-message capture (T1) or a summary distillation/condensation (T2/T3).
*
* Returns `true` ONLY when the call is positively identified as a T1 capture: at
* least one range boundary is present and NONE of them is a block ref (`bN`). A
* block-ref boundary means the call consumes existing summaries (T2/T3); those
* must keep resetting the tier cadence baselines to prevent re-trigger loops
* (issue #235). A pure message capture (`mNNNNN` boundaries) only ADDS tier-1
* summaries, so resetting the baselines after every capture is what starves T2
* distillation (issue #364 P1) — callers skip the reset for these.
*
* Returns `false` when any boundary is a block ref OR when no parsable boundary
* is found (conservative: preserve the loop-prevention reset).
*/
export const isCaptureOnlyCompress = (message: WithParts | undefined): boolean => {
if (!isMessageWithInfo(message)) {
return false
}
if (message.info.role !== "assistant") {
return false
}

const parts = Array.isArray(message.parts) ? message.parts : []
let sawBoundary = false
for (const part of parts) {
if (!(part.type === "tool" && part.tool === "compress")) {
continue
}
for (const startId of extractCompressBoundaryIds(part.state?.input)) {
sawBoundary = true
if (/^b\d+$/i.test(startId)) {
return false // any block-ref boundary → T2/T3 distillation/condensation
}
}
}
// >=1 boundary present and none were block refs → pure raw-message T1 capture.
return sawBoundary
}

/**
* Extracts the startId/endId boundary refs from a compress tool part's input.
* The input may arrive as a parsed object or a JSON string; malformed shapes
* yield an empty list rather than throwing.
*/
function extractCompressBoundaryIds(rawInput: unknown): string[] {
let content: unknown[] = []
if (typeof rawInput === "string") {
try {
const parsed: unknown = JSON.parse(rawInput)
const c = (parsed as { content?: unknown })?.content
content = Array.isArray(c) ? (c as unknown[]) : []
} catch {
return []
}
} else if (rawInput && typeof rawInput === "object") {
const c = (rawInput as { content?: unknown }).content
content = Array.isArray(c) ? (c as unknown[]) : []
}

const ids: string[] = []
for (const entry of content) {
if (!entry || typeof entry !== "object") {
continue
}
const { startId, endId } = entry as { startId?: unknown; endId?: unknown }
for (const sid of [startId, endId]) {
if (typeof sid === "string" && sid.trim() !== "") {
ids.push(sid.trim())
}
}
}
return ids
}

export const isIgnoredUserMessage = (message: WithParts): boolean => {
if (!isMessageWithInfo(message)) {
return false
Expand Down
2 changes: 1 addition & 1 deletion scripts/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ the turn counter for real conversation turns.
| `08-nudge-with-protection.json` | Nudge→compress WITH protection enabled → verify compress succeeds despite protected zone, nudge baseline set, protected messages survived |
| `09-nudge-refire-after-compress.json` | Multi-turn nudge→compress→growth→re-nudge→re-compress. Verifies minBlockCount ≥ 1 (full re-nudge cycle with baseline reset is in scenario 10 + unit tests), maxBlockCount ≤ 8 |
| `10-autonomous-nudge-refire.json` | Issue #176: Autonomous session (bash tool calls grow context) → first nudge→compress → continued growth → second nudge→second compress → verify minBlockCount ≥ 2, maxCompressCallsVisible ≤ 2 |
| `11-tier2-baseline-preserved-after-compress.json` | Bug #235 regression: verify lastTier2NudgeTokens preserved (not reset to undefined) after compress. Tests compress handler baseline preservation, not T2 cadence (T2 never fires — consumption chain leaves only 1 active T1 block) |
| `11-tier2-baseline-preserved-after-compress.json` | Issue #364: verify raw-message T1 captures (m-refs) do NOT touch lastTier2NudgeTokens — stays unset when T2 never fired. The #235 never-undefined invariant is locked by unit tests on the distill/conservative reset path (filename kept from the pre-#364 revision because the CI e2e job hardcodes scenario paths) |
| `12-consumed-call-hiding.json` | Bug #236 regression: T1 compresses auto-consume previous blocks → verify lastRequestCompressCalls=1 (consumed calls hidden from LLM) |

### Scenario Format
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tier2-baseline-preserved-after-compress",
"description": "Bug #235 regression: lastTier2NudgeTokens must be preserved (not reset to undefined) after compress. The compress handler at inject.ts:121-128 sets lastTier2NudgeTokens=currentTokens on every compress attempt. Before fix (#235): handler reset to undefined, causing T2 to re-fire every turn without growth. Note: T2 never actually fires in this scenario because each T1 compress auto-consumes the previous block (search.ts auto-detection), leaving only 1 active T1 block. The assertion tests the compress handler's baseline preservation, not T2 cadence. T2 distillation testing requires fake LLM support for bNN block-ID refs (not yet implemented).",
"description": "Issue #364 semantics: raw-message T1 captures (mNNNNN refs — all the fake LLM can emit) must NOT touch the tier cadence baselines. lastTier2NudgeTokens stays unset here because T2 never fires (each T1 compress auto-consumes the previous block, search.ts auto-detection, leaving 1 active T1 block) and captures no longer set it. The #235 invariant (baseline never reset to undefined once T2 has fired) is locked by the distill/conservative reset path in unit tests (tests/inject.test.ts phase 1-3 + the b-prefix contrast test); E2E coverage of that path needs fake LLM support for bNN block-ID refs (not yet implemented).",
"acpConfig": {
"compress": {
"minCompressRange": 0,
Expand Down Expand Up @@ -28,7 +28,7 @@
"minBlockCount": 2,
"maxBlockCount": 8,
"activeBlockCount": 1,
"tier2BaselineSet": true,
"tier2BaselineSet": false,
"nudgeBaselineSet": true,
"maxCompressCallsVisible": 3
}
Expand Down
Loading
Loading