From fe375e573888a1a91389a941e59313e0303351ec Mon Sep 17 00:00:00 2001 From: guowenjie <691675122@qq.com> Date: Mon, 9 Nov 2026 11:05:31 +0800 Subject: [PATCH 1/3] kernel: bridge span-safe gaps at the merge gate so mega-turn islands survive minCompressRange (production 2026-11-09 no-ranges incident) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production shape (acp host, 2026-11-09 night): a mega-turn with a protected-tool exchange every ~4 rounds + a mid-turn block cut mid-group fragmented the compressible surface into 3-5K-char islands. Every island sat below compress.minCompressRange (5000 chars), mergeRangesToThreshold dropped them all, recommendedRanges=[] reached the host, and the emergency lane fell back to irreversible physical pruning three times in one night — while the host's message-level diagnostic still counted 84-149 'collectible' messages. 28 spine-cut no-range diagnostics same day, other sessions. Two independent killers, both fixed: 1. mergeRangesToThreshold closed its batch at every index gap, so sub-gate islands could never accumulate. It now bridges across gaps buildCompressibleRanges marks span-safe (bridgeableGapBefore): protected-tool exchanges (apply carves them via filterProtectedToolMessages, 'Bug 39') and integrity-withdrawn members (apply withdraws them again, they stay visible — #564 reverse-split semantics). The per-CALL #847 gate sums chars over all ranges in the request, so a bridged span clears the gate each island missed. Hard gaps (blocks, carriers, media, dead refs, unrefed, soft zone — whose interior hits truncate the fold at the first protected message, #3953) still close the batch, and sub-gate remnants still emit nothing. buildCompressibleRanges tracks gap composition (hardGapSinceCompressible) and chains it across withdrawals. 2. Ranges ending adjacent to a mid-group block cut died at screening regardless: the pair-boundary adjustment widened the span across the coverage boundary, dragging the block in as nested/consumed, and the merge-integrity path resolved the unit to fold-nothing (fatal=null, filteredIds=0). projectUnit now clamps the ADJUSTMENT's widened head/tail at the coverage boundary (clampAdjustmentAtCoverage) — safe because covered halves are invisible to the integrity judge (#5531); the requested span itself is never clamped, so refold candidates keep resolving. The clamp reuses the shared index map so the #5337 O(units x n) discipline holds. Observability (the incident was undiagnosable from the host side): screenRecommendedUnits warns (budgeted) for every silent drop class — fatal, empty projection, refold-guard — and the recommend node warns with stage-by-stage numbers when the funnel zeroes with material present. Tests: tests/megaturn-protected-gap-bridging.test.ts pins the incident shape end-to-end (bridge + clamp + full-chain advertise, plus a negative for hard gaps). issue5531 closed-loop updated: the recovery block is found by coverage now that the clamp keeps the seed block active instead of dragging it into a merge. Full suite 1626/1626. --- kernel/src/compress.ts | 18 ++ kernel/src/recommend.ts | 149 +++++++++-- kernel/src/types.ts | 16 ++ kernel/src/unit-projection.ts | 94 ++++++- ...sue5531-stranded-reasoning-residue.test.ts | 12 +- .../megaturn-protected-gap-bridging.test.ts | 246 ++++++++++++++++++ 6 files changed, 514 insertions(+), 21 deletions(-) create mode 100644 kernel/tests/megaturn-protected-gap-bridging.test.ts diff --git a/kernel/src/compress.ts b/kernel/src/compress.ts index e93db9def1..c97f2ed3e0 100644 --- a/kernel/src/compress.ts +++ b/kernel/src/compress.ts @@ -49,6 +49,7 @@ import { buildCompressibleRanges, mergeRangesToThreshold, screenRecommendedUnits, + warnScreenDrop, } from "./recommend.js"; import { runPipeline, @@ -1501,6 +1502,23 @@ const recommendNode: PipelineNode = { ctx.config, computeProtectedRefs(screenArray, io.state, ctx.config, ctx.countTokens), ); + // Production 2026-11-09 mega-turn incident: the funnel can zero out BELOW + // the "nothing compressible" level — segments exist and hold messages, but + // every island dies at the merge gate or the screen, and the host only + // sees recommendedRanges=[] while its own message-level diagnostic still + // counts collectible material ("collectible>0 yet ranges=0" was + // undiagnosable from the host side for a full night). Warn (budgeted) + // with the stage-by-stage numbers so the killing stage is named directly. + if (contextRanges.compressible.length > 0 && recommendedRanges.length === 0) { + const segMsgs = contextRanges.compressible.reduce((s, r) => s + r.count, 0); + const minChars = ctx.config.compress.minCompressRange; + const subGate = contextRanges.compressible.filter( + (r) => (r.chars ?? r.tokens * 4) < minChars, + ).length; + warnScreenDrop( + `funnel zeroed with material present: segments=${contextRanges.compressible.length} (${segMsgs} msgs, ${subGate} below ${minChars}-char merge gate) → all died at merge/screen; host sees no-ranges`, + ); + } const recommendation: Recommendation = { contextRanges, recommendedRanges, diff --git a/kernel/src/recommend.ts b/kernel/src/recommend.ts index 23a2ca2631..1d413f66cd 100644 --- a/kernel/src/recommend.ts +++ b/kernel/src/recommend.ts @@ -278,6 +278,11 @@ export function buildCompressibleRanges( id: string; ref: string; gapBefore: boolean; + /** True when this message's preceding gap is HARD (contains at least one + * skip the apply side cannot tolerate inside a spanning fold — see + * hardGapSinceCompressible). False = the gap is span-safe (protected-tool + * exchanges and/or integrity-withdrawn members only) or absent. */ + gapHard: boolean; tokens: number; chars: number; isTool: boolean; @@ -316,6 +321,20 @@ export function buildCompressibleRanges( // two rules coincide, so ranges are byte-identical to the old behavior. let skipSinceCompressible = false; let skipSinceProtected = false; + // Span-safety classification (production 2026-11-09 mega-turn incident): a + // gap the apply side tolerates INSIDE a spanning fold — protected-tool + // exchanges (filterProtectedToolMessages carves them, "Bug 39") and + // integrity-withdrawn members (the fold gate withdraws them again at apply, + // they stay visible; #564 reverse-split semantics) — is SPAN-SAFE, and + // mergeRangesToThreshold may bridge across it so sub-gate islands can + // accumulate to minCompressRange. Every OTHER skip (block coverage, live + // checkpoint carrier, unrecoverable media, soft zone — whose interior hits + // TRUNCATE the fold at the first protected message (#3953) — dead-ref + // holes, BLOCKED/unrefed) is hard: no bridge, ever. Set on any skip other + // than the protected-exchange branch; cleared when a compressible message + // is pushed. The protected branch leaves it untouched, so a gap that mixes + // protected + hard skips stays hard in either order. + let hardGapSinceCompressible = false; let msgIndex = -1; // #3192/#3205: numeric set of recorded-dead refs. A dropped message leaves a // hole in the ref map that array-adjacency segmentation bridges, so we break @@ -330,10 +349,17 @@ export function buildCompressibleRanges( for (const msg of messages) { msgIndex++; const ref = state.messageRefs.byRaw[msg.id]; - if (!ref || ref === "BLOCKED") continue; + if (!ref || ref === "BLOCKED") { + // No ref = no slot in the ref map: mergeRangesToThreshold sees an index + // hole here even though no skip flag fires. Record it as a hard gap so + // bridging never spans an unrefed message (its foldability is untested). + hardGapSinceCompressible = true; + continue; + } if (isSyntheticOrPruned(msg, covered)) { skipSinceCompressible = true; skipSinceProtected = true; + hardGapSinceCompressible = true; continue; } @@ -349,6 +375,7 @@ export function buildCompressibleRanges( if (isLiveCheckpointCarrier(msg, state)) { skipSinceCompressible = true; skipSinceProtected = true; + hardGapSinceCompressible = true; continue; } @@ -361,6 +388,7 @@ export function buildCompressibleRanges( if (hasUnrecoverableMediaPayload(msg)) { skipSinceCompressible = true; skipSinceProtected = true; + hardGapSinceCompressible = true; continue; } @@ -382,12 +410,16 @@ export function buildCompressibleRanges( }); skipSinceProtected = false; skipSinceCompressible = true; + // Protected-exchange skip: the ONLY bridge-able skip class (see the + // hardGapSinceCompressible doc). Leave hardGapSinceCompressible + // untouched — a protected gap following a hard gap stays hard. continue; } if (protectedZoneRefs?.has(ref)) { skipSinceCompressible = true; skipSinceProtected = true; + hardGapSinceCompressible = true; continue; } @@ -404,12 +436,14 @@ export function buildCompressibleRanges( const hi = Math.max(curRefNum, lastCompressibleRefNum); if (hi - lo > 1 && hasDeadRefBetween(deadRefNums, lo, hi)) { skipSinceCompressible = true; + hardGapSinceCompressible = true; } } compressibleMsgs.push({ id: msg.id, ref, gapBefore: skipSinceCompressible, + gapHard: hardGapSinceCompressible, tokens: countMessageTokens(msg, countTokens), chars: (msg.text ?? "").length, isTool: isToolMessage(msg), @@ -419,6 +453,7 @@ export function buildCompressibleRanges( if (curRefNum !== null) lastCompressibleRefNum = curRefNum; skipSinceCompressible = false; skipSinceProtected = true; + hardGapSinceCompressible = false; } // Foldability: the fold gate (src/compress.ts) withdraws every message whose @@ -444,15 +479,31 @@ export function buildCompressibleRanges( covered, ).withdrawn; if (unfoldedIds.size > 0) { + // Withdrawn members are SPAN-SAFE (apply withdraws them again, they stay + // visible — see the hardGapSinceCompressible doc): removing one merges the + // gap before it with the gap after it, and the merged gap stays span-safe + // iff both were. Chain the hardness across consecutive withdrawals; the + // surviving record inherits the combined classification. let gapPending = false; + let pendingHard = false; const kept: typeof compressibleMsgs = []; for (const info of compressibleMsgs) { if (unfoldedIds.has(info.id)) { gapPending = true; + pendingHard = pendingHard || info.gapHard; continue; } - kept.push(gapPending ? { ...info, gapBefore: true } : info); + kept.push( + gapPending + ? { + ...info, + gapBefore: true, + gapHard: pendingHard || info.gapHard, + } + : info, + ); gapPending = false; + pendingHard = false; } compressibleMsgs = kept; } @@ -474,6 +525,9 @@ export function buildCompressibleRanges( toolPct: first.isTool ? 100 : 0, textPct: first.isTool ? 0 : 100, userMsgs: first.isUser ? 1 : 0, + ...(first.gapBefore && !first.gapHard + ? { bridgeableGapBefore: true } + : {}), }; for (let i = 1; i < group.length; i++) { const info = group[i]!; @@ -566,6 +620,10 @@ function mergeBatch(batch: CompressibleRange[]): CompressibleRange { // position-based merging (formatRanges) still sees it after this batch is // re-emitted as a single range. if (first.noMergeBefore === true) merged.noMergeBefore = true; + // Bridge flag propagates the same way: a batch that BRIDGED into this + // range keeps the marker so any later re-merge sees the span crosses + // span-safe gaps (and only those — hard gaps never set it). + if (first.bridgeableGapBefore === true) merged.bridgeableGapBefore = true; return merged; } @@ -586,7 +644,22 @@ function rangeChars(r: CompressibleRange): number { * `minChars`. A sub-threshold tail is folded into the preceding batch * (overshoot allowed); if no batch precedes it, nothing is emitted — the * whole remainder is below the gate, so no selection of it can pass and - * offering it only yields guaranteed-rejected calls (billion-context #847). */ + * offering it only yields guaranteed-rejected calls (billion-context #847). + * + * Exception — span-safe gap bridging (production 2026-11-09 mega-turn): + * batches accumulate ACROSS a gap the apply side tolerates inside a spanning + * fold (`bridgeableGapBefore`: gaps made exclusively of protected-tool + * exchanges — carved at apply, filterProtectedToolMessages "Bug 39" — and/or + * integrity-withdrawn members — withdrawn again at apply, they stay visible) + * instead of closing at it. The per-CALL #847 gate sums chars over every + * range in the request, so a bridged span clears the gate that each island + * individually missed. Hard gaps (blocks, carriers, media, dead-ref holes, + * unrefed messages, soft zone — whose interior hits truncate the fold at the + * first protected message, #3953) still close the batch — their spans are + * doomed or carve-to-nothing (#498 / #2663). Sub-threshold remnants STILL + * emit nothing: the apply gate is per-call, but a lone island below it has + * no legal partner to ride with, and advertising it only yields the + * guaranteed-rejected call #847 exists to prevent. */ export function mergeRangesToThreshold( ranges: CompressibleRange[], minChars: number, @@ -596,10 +669,11 @@ export function mergeRangesToThreshold( let batch: CompressibleRange[] = []; let batchChars = 0; // Close the running batch at a boundary: emit it only when it alone clears - // the gate. A sub-threshold segment bounded by a gap cannot be offered (it - // fails the apply-side #847 gate) and must not be stretched across the gap - // to reach the threshold, or the emitted span covers non-compressible - // content (#498). + // the gate. A sub-threshold segment bounded by a hard gap cannot be offered + // (it fails the apply-side #847 gate) and must not be stretched across the + // gap to reach the threshold, or the emitted span covers non-compressible + // content (#498). A span-safe gap does NOT close the batch — the span + // bridges it and apply carves/withdraws the interior (see the header doc). const closeBatch = () => { if (batch.length > 0) { if (batchChars >= minChars) result.push(mergeBatch(batch)); @@ -610,17 +684,22 @@ export function mergeRangesToThreshold( for (const r of ranges) { // #3192/#3205: hard boundary at a dead-ref hole — never bridge across it, // even though the hole carries no array-index gap (see noMergeBefore). + // (A dead-ref gap is a hard gap, so bridgeableGapBefore is unset on + // ranges following one; the explicit check keeps that invariant + // authoritative even on hand-built ranges that set both.) if (r.noMergeBefore) closeBatch(); const prev = batch[batch.length - 1]; - // A physical array-index gap between the previous range and this one marks - // a block / protected / pruned boundary (buildCompressibleRanges leaves a - // slot hole there; user-turn splits stay contiguous). Without index data we - // keep the legacy accumulate-across-everything behavior. + // A physical array-index gap between the previous range and this one + // marks a block / covered / carrier / pruned boundary (buildCompressibleRanges + // leaves a slot hole there; user-turn splits stay contiguous). Without + // index data we keep the legacy accumulate-across-everything behavior — + // EXCEPT that a span-safe gap does not close the batch (bridge). if ( prev && prev.endIndex !== undefined && r.startIndex !== undefined && - r.startIndex > prev.endIndex + 1 + r.startIndex > prev.endIndex + 1 && + !r.bridgeableGapBefore ) { closeBatch(); } @@ -634,7 +713,8 @@ export function mergeRangesToThreshold( !batch[0]!.noMergeBefore && (prev.endIndex === undefined || batch[0]!.startIndex === undefined || - batch[0]!.startIndex <= prev.endIndex + 1); + batch[0]!.startIndex <= prev.endIndex + 1 || + batch[0]!.bridgeableGapBefore === true); if (contiguous) { result[result.length - 1] = mergeBatch([prev, ...batch]); } else if (batchChars >= minChars) { @@ -694,19 +774,34 @@ export function screenRecommendedUnits( // config-validation warn in processTurn); behavior is unchanged — // still return false, the unit is never advertised. if (!(err instanceof BoundaryNotFoundError)) { - console.warn( - `[acp-kernel] screenRecommendedUnits dropped unit ${unit.startRef}..${unit.endRef} after projection error: ${ + warnScreenDrop( + `dropped unit ${unit.startRef}..${unit.endRef} after projection error: ${ err instanceof Error ? err.message : String(err) }`, ); } return false; } - if (!projection || projection.fatal !== null) return false; + if (!projection || projection.fatal !== null) { + // Production 2026-11-09 mega-turn incident: whole-funnel zeroing is + // undiagnosable when units vanish here with no trace — the host only + // sees "kernel ranges=0" while its own message-level diagnostic still + // counts collectible material. Warn (budgeted, same channel as the + // projection-error warn above) so a total screen wipe is attributable. + if (projection?.fatal) { + warnScreenDrop( + `dropped unit ${unit.startRef}..${unit.endRef}: ${projection.fatal.error.message}`, + ); + } + return false; + } if ( projection.filteredIds.length === 0 && projection.consumedBlockIds.length === 0 ) { + warnScreenDrop( + `dropped unit ${unit.startRef}..${unit.endRef}: nothing foldable in span (filteredIds=0, consumedBlocks=0)`, + ); return false; } // #3371: the apply lane's livelock guard (compress.ts, pi#199) throws on @@ -725,8 +820,30 @@ export function screenRecommendedUnits( !projection.isBlockBoundary && evaluateRefold(state, unit).kind !== "refold" ) { + warnScreenDrop( + `dropped unit ${unit.startRef}..${unit.endRef}: consumes only active block(s), not a refold`, + ); return false; } return true; }); } + +// Budgeted warn channel for screen-stage drops: the first SCREEN_DROP_WARN_BUDGET +// drops in the process are logged verbatim, afterwards only every Nth (a run of +// doomed units over a long session must stay observable without flooding the +// host log — the production incident produced 28 no-range events/day). Exported +// for the recommend pipeline node's funnel-zero warn (same channel, same budget). +const SCREEN_DROP_WARN_BUDGET = 40; +const SCREEN_DROP_WARN_STRIDE = 100; +let screenDropWarnCount = 0; +export function warnScreenDrop(detail: string): void { + screenDropWarnCount += 1; + if ( + screenDropWarnCount > SCREEN_DROP_WARN_BUDGET && + screenDropWarnCount % SCREEN_DROP_WARN_STRIDE !== 0 + ) { + return; + } + console.warn(`[acp-kernel] screenRecommendedUnits ${detail}`); +} diff --git a/kernel/src/types.ts b/kernel/src/types.ts index 7e7b4f4699..6ba7d8de0b 100644 --- a/kernel/src/types.ts +++ b/kernel/src/types.ts @@ -708,6 +708,22 @@ export interface CompressibleRange { * has no index gap and the index check alone would let mergeRangesToThreshold * bridge back into one doomed span bracketing the dead ref. */ noMergeBefore?: boolean; + /** buildCompressibleRanges sets this when the gap immediately before this + * range is SPAN-SAFE: composed exclusively of members the apply side + * tolerates INSIDE a spanning fold — protected-tool exchanges + * (config.protectedTools / always-protected list, carved by + * filterProtectedToolMessages, "Bug 39") and integrity-withdrawn members + * (the fold gate withdraws them again at apply; they stay visible, #564 + * reverse-split semantics). mergeRangesToThreshold bridges across such + * gaps so sub-gate islands can accumulate to minCompressRange instead of + * all dying below it (production 2026-11-09: a mega-turn with a protected + * exchange every ~4 rounds fragmented every island to 3-5K chars, + * mergeRangesToThreshold dropped them all → recommendedRanges=[] → the + * host's emergency lane fell back to irreversible physical pruning while + * the host's four-bucket diagnostic still showed 84 "collectible" + * messages). Hard gaps (blocks, carriers, media, dead refs, unrefed + * messages, soft zone) never set this — they keep splitting ranges. */ + bridgeableGapBefore?: boolean; } export interface ProtectedRange { diff --git a/kernel/src/unit-projection.ts b/kernel/src/unit-projection.ts index 428d3eb672..690015731c 100644 --- a/kernel/src/unit-projection.ts +++ b/kernel/src/unit-projection.ts @@ -165,10 +165,17 @@ export function projectUnit(input: UnitProjectionInput): UnitProjection { } } } - const adjustedIds = applyPairBoundaryAdjustments( + const adjustedIds = clampAdjustmentAtCoverage( + applyPairBoundaryAdjustments(resolved, input.messages, shared), resolved, input.messages, - shared, + // Fresh folds pass the active coverage; hand-built inputs without it + // (unit tests) keep the legacy unclamped adjustment behavior. + input.preExistingCoverage, + // #5337 discipline: reuse the shared index map — the clamp runs on every + // screened unit, and a per-call map rebuild would resurface the O(units×n) + // quadratic the shared context exists to prevent. + byId, ); const skippedCarrierIds = adjustedIds.filter((id) => liveCarrierIds.has(id)); if (skippedCarrierIds.length > 0) { @@ -916,6 +923,89 @@ function applyPairBoundaryAdjustments( return ids; } +/** + * Clamp the pair-boundary ADJUSTMENT's widened head/tail at active-coverage + * boundaries (production 2026-11-09 mega-turn incident). + * + * When a range ends (or starts) right where a block was cut mid-group — a + * spine-cut/fold boundary slicing between a round's reasoning and its + * tool-call — the pair/reasoning adjustment widens the range ACROSS the + * boundary to close the pair, pulling in already-covered members. The drag + * makes the block visible in range → nestedBlock/consumed machinery engages + * → the merge-integrity path resolves the unit to fold-nothing (fatal=null, + * filteredIds=0) or a split fatal: every range ending adjacent to a mid-group + * block cut dies at screening. This predates the merge-gate bridging fix — + * bridging only exposed it, by finally letting units reach the screen with + * that geometry. + * + * Clamping is safe precisely because the partner half is COVERED: #5531 makes + * covered halves invisible to the integrity judge (a covered call cannot + * strand its turn's reasoning; a covered result leaves no unanswered + * tool_call_id), so folding the visible part while the covered partner stays + * covered is the allowed reverse-split shape. The requested span itself is + * NEVER clamped — only the adjustment's widening — so hand-built ranges that + * deliberately target covered spans (refold candidates) keep resolving. + */ +function clampAdjustmentAtCoverage( + adjustedIds: string[], + resolved: { startIndex: number; endIndex: number; messageIds: string[] }, + messages: CoreMessage[], + coverage?: ReadonlySet, + /** Shared id→index map (#5337): reuse the caller's O(n) derivation instead + * of rebuilding it per call on the screening hot path. */ + indexById?: Map, +): string[] { + if (!coverage || coverage.size === 0 || adjustedIds.length === 0) { + return adjustedIds; + } + // Recover the adjusted span's index bounds (ids are contiguous by index — + // applyPairBoundaryAdjustments pushes by loop index). + let startIdx: number | undefined; + let endIdx: number | undefined; + if (indexById) { + startIdx = indexById.get(adjustedIds[0]!); + endIdx = indexById.get(adjustedIds[adjustedIds.length - 1]!); + } else { + const built = new Map(); + for (let i = 0; i < messages.length; i++) { + const id = messages[i]?.id; + if (id !== undefined) built.set(id, i); + } + startIdx = built.get(adjustedIds[0]!); + endIdx = built.get(adjustedIds[adjustedIds.length - 1]!); + } + if (startIdx === undefined || endIdx === undefined) return adjustedIds; + const isCovered = (i: number): boolean => { + const id = messages[i]?.id; + return id !== undefined && coverage.has(id); + }; + // Pull the widened TAIL back to just before the first covered member, and + // the widened HEAD forward likewise. Only the WIDENED portion is clamped — + // covered ids inside the requested span stay (refold/consumed semantics). + let clamped = false; + for (let i = resolved.endIndex + 1; i <= endIdx; i++) { + if (isCovered(i)) { + endIdx = i - 1; + clamped = true; + break; + } + } + for (let i = resolved.startIndex - 1; i >= startIdx; i--) { + if (isCovered(i)) { + startIdx = i + 1; + clamped = true; + break; + } + } + if (!clamped) return adjustedIds; + const ids: string[] = []; + for (let i = startIdx; i <= endIdx; i++) { + const msg = messages[i]; + if (msg) ids.push(msg.id); + } + return ids; +} + function filterProtectedToolMessages( directMessageIds: string[], messages: CoreMessage[], diff --git a/kernel/tests/issue5531-stranded-reasoning-residue.test.ts b/kernel/tests/issue5531-stranded-reasoning-residue.test.ts index 4a84ab26a9..ffd7ca9c04 100644 --- a/kernel/tests/issue5531-stranded-reasoning-residue.test.ts +++ b/kernel/tests/issue5531-stranded-reasoning-residue.test.ts @@ -195,9 +195,15 @@ describe("#5531 recommend: stranded reasoning+text are advertised again", () => }); assert.deepEqual(res.result.errors, [], JSON.stringify(res.result.errors)); assert.ok(res.result.blocksCreated >= 1, "a recovery block must be created"); - const block = res.state.blocks.find((b) => b.active && b !== state.blocks[0]); - assert.ok(block, "new block distinct from the seed block"); - assert.ok(block.effectiveMessageIds.includes("r")); + // The recovery block is the one covering the stranded reasoning. The seed + // block (call+result only) may stay active alongside it: ranges ENDING at + // a mid-group block cut are clamped at the coverage boundary instead of + // dragging the seed in as consumed (unit-projection clampAdjustmentAtCoverage), + // so "distinct from the seed" is no longer the reliable discriminator here. + const block = res.state.blocks.find( + (b) => b.active && b.effectiveMessageIds.includes("r"), + ); + assert.ok(block, "a recovery block covering the stranded reasoning must exist"); assert.ok(block.effectiveMessageIds.includes("t")); }); }); diff --git a/kernel/tests/megaturn-protected-gap-bridging.test.ts b/kernel/tests/megaturn-protected-gap-bridging.test.ts new file mode 100644 index 0000000000..3604b7deff --- /dev/null +++ b/kernel/tests/megaturn-protected-gap-bridging.test.ts @@ -0,0 +1,246 @@ +// Regression pins for the production 2026-11-09 mega-turn no-ranges incident. +// +// Shape: a mega-turn whose rounds are interleaved with protected-tool +// exchanges (config.protectedTools — todo/tool_learn/…), with an active block +// cut mid-group through the middle. The protected exchanges + integrity +// withdrawals fragment the compressible surface into islands of ~3-5K chars — +// each individually BELOW the apply-side minCompressRange gate, and (because +// the apply gate sums chars per CALL, compress.ts #847) perfectly legal to +// fold together. Pre-fix, mergeRangesToThreshold dropped every sub-gate island +// → recommendedRanges=[] → the host's emergency lane fell back to irreversible +// physical pruning while its four-bucket diagnostic still counted 84 +// "collectible" messages. Three pins: +// 1. mergeRangesToThreshold BRIDGES across span-safe gaps so islands +// accumulate to the gate (and still never bridges a hard gap); +// 2. the bridged unit SURVIVES screening — projectUnit clamps the +// pair-boundary adjustment at the coverage boundary instead of dragging +// the adjacent block in as consumed (the second, independent killer); +// 3. the whole recommend chain advertises ≥1 range for the incident shape. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + computeProtectedRefs, + buildCompressibleRanges, + mergeRangesToThreshold, + screenRecommendedUnits, +} from "../src/recommend.js"; +import { createInitialState } from "../src/state.js"; +import { assignRefs } from "../src/refs.js"; +import { defaultConfig } from "../src/config.js"; +import { defaultCountTokens } from "../src/tokenize.js"; +import type { + CompressibleRange, + CompressionState, + Config, + CoreMessage, +} from "../src/types.js"; + +const CJK = "上下文压缩水位判定折叠范围构建完整性撤回保护区覆盖"; +const cjk = (n: number): string => + Array.from({ length: n }, (_, i) => CJK[i % CJK.length]).join(""); + +const PROTECTED_TOOLS = [ + "tool_learn", + "skill_learn", + "todo", + "compress", + "acp_status", + "search_context", + "absorb", + "recall", +]; +const COMMON = ["read", "run_code", "glob", "grep", "browser_evaluate", "write"]; + +function config(): Config { + return defaultConfig(112000, { + protectedTools: [...PROTECTED_TOOLS], + preserveRecentMessages: 8, + preserveRecentTokens: 8000, + nudge: { ...defaultConfig(112000).nudge, maxContextLimitPct: 0.8 }, + }); +} + +/** 50 small rounds; every 4th round's exchange rides a protected tool; the + * newest 24 messages are unrefed (state persist lag, as in production). */ +function buildIncident(): { + messages: CoreMessage[]; + state: CompressionState; +} { + const messages: CoreMessage[] = []; + messages.push({ id: "m00001", role: "user", contentType: "text", text: cjk(600) }); + let n = 1; + const nid = () => `m${String(++n).padStart(5, "0")}`; + for (let r = 0; r < 50; r++) { + messages.push({ + id: nid(), + role: "assistant", + contentType: "reasoning", + text: cjk(250 + ((r * 137) % 450)), + }); + if (r % 2 === 0) { + messages.push({ id: nid(), role: "assistant", contentType: "text", text: cjk(60 + ((r * 61) % 150)) }); + } + const isProt = r > 2 && r % 4 === 0; + const tool = isProt + ? (["todo", "tool_learn", "search_context", "skill_learn"] as const)[r % 4]! + : COMMON[(r * 7) % COMMON.length]!; + const callId = `tc-${r}`; + messages.push({ + id: nid(), + role: "assistant", + contentType: "tool-call", + toolName: tool, + toolCallId: callId, + text: `{"r":${r}}`, + }); + messages.push({ + id: nid(), + role: "tool", + contentType: "tool-result", + toolName: tool, + toolCallId: callId, + text: cjk(200 + ((r * 331) % 1600)), + }); + } + const state = createInitialState(); + const noRefIds = new Set(messages.slice(-24).map((m) => m.id!)); + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + shouldSkip: (m) => noRefIds.has(m.id!), + }).map; + // Active block cut mid-group through the middle (the earlier growth-lane + // fold): start on a compressible message so the round's call/result stay + // covered while the reasoning stays visible. + let startIdx = 30; + while ( + startIdx < messages.length && + messages[startIdx]!.contentType === "tool-result" + ) { + startIdx++; + } + const ids = messages.slice(startIdx, startIdx + 86).map((m) => m.id!); + state.blocks = [ + { + blockId: "b1", + runId: "r1", + tier: 1, + topic: "mid", + summary: cjk(400), + directMessageIds: ids, + effectiveMessageIds: ids, + directBlockIds: [], + compressedTokens: 40000, + createdAt: Date.now(), + survivedCount: 0, + generation: 0, + active: true, + }, + ]; + return { messages, state }; +} + +function minChars(cfg: Config): number { + return cfg.compress.minCompressRange; +} + +describe("mega-turn protected-gap bridging (2026-11-09 incident)", () => { + it("buildCompressibleRanges flags protected-only gaps as bridgeable", () => { + const { messages, state } = buildIncident(); + const cfg = config(); + const protectedRefs = computeProtectedRefs(messages, state, cfg, defaultCountTokens); + const ranges = buildCompressibleRanges(messages, state, cfg, protectedRefs, defaultCountTokens, messages); + assert.ok(ranges.compressible.length > 0, "islands must exist"); + const bridged = ranges.compressible.filter((r) => r.bridgeableGapBefore === true); + assert.ok(bridged.length > 0, "at least one island follows a span-safe gap"); + }); + + it("mergeRangesToThreshold bridges span-safe gaps so islands clear the gate", () => { + const { messages, state } = buildIncident(); + const cfg = config(); + const gate = minChars(cfg); + const protectedRefs = computeProtectedRefs(messages, state, cfg, defaultCountTokens); + const ranges = buildCompressibleRanges(messages, state, cfg, protectedRefs, defaultCountTokens, messages); + // Precondition of the incident: islands exist below the gate. + const subGate = ranges.compressible.filter((r) => (r.chars ?? 0) < gate); + assert.ok(subGate.length > 0, "fixture must produce sub-gate islands"); + const merged = mergeRangesToThreshold(ranges.compressible, gate); + assert.ok( + merged.length > 0, + "bridged islands must accumulate past the merge gate (pre-fix: all dropped)", + ); + for (const unit of merged) { + assert.ok( + (unit.chars ?? 0) >= gate, + `every emitted unit clears the gate, got ${unit.chars}`, + ); + } + }); + + it("mergeRangesToThreshold still refuses to bridge a hard gap", () => { + // Two islands separated by a block-coverage gap: no bridge flag on the + // second island → sub-gate islands on both sides stay unmerged. + const island = (startIdx: number): CompressibleRange => ({ + startRef: `m${String(startIdx).padStart(5, "0")}`, + endRef: `m${String(startIdx + 2).padStart(5, "0")}`, + startIndex: startIdx, + endIndex: startIdx + 2, + count: 3, + tokens: 900, + chars: 2000, + toolPct: 50, + textPct: 50, + userMsgs: 0, + }); + const merged = mergeRangesToThreshold([island(10), island(40)], 5000); + assert.deepEqual(merged, [], "hard-gapped sub-gate islands must not merge"); + }); + + it("the bridged unit survives screening next to a mid-group block cut", () => { + const { messages, state } = buildIncident(); + const cfg = config(); + const gate = minChars(cfg); + const protectedRefs = computeProtectedRefs(messages, state, cfg, defaultCountTokens); + const ranges = buildCompressibleRanges(messages, state, cfg, protectedRefs, defaultCountTokens, messages); + const merged = mergeRangesToThreshold(ranges.compressible, gate); + assert.ok(merged.length > 0); + const screened = screenRecommendedUnits(merged, messages, state, cfg, protectedRefs); + assert.ok( + screened.length > 0, + "bridged unit must survive projectUnit (clamp keeps the adjacent block out of the span)", + ); + // The surviving span must not reach into covered territory: its END stays + // on the visible side of the mid-group block cut. + const covered = new Set(); + for (const b of state.blocks ?? []) { + if (b.active) for (const id of b.effectiveMessageIds ?? []) covered.add(id); + } + const indexById = new Map(); + messages.forEach((m, i) => indexById.set(m.id!, i)); + for (const unit of screened) { + const endIdx = indexById.get(unit.endRef); + assert.ok(endIdx !== undefined, "endRef resolves to an array slot"); + assert.ok( + !covered.has(messages[endIdx!]!.id!), + "span end stays on the visible side of the block boundary", + ); + } + }); + + it("full recommend chain advertises ranges for the incident shape", () => { + const { messages, state } = buildIncident(); + const cfg = config(); + const gate = minChars(cfg); + const protectedRefs = computeProtectedRefs(messages, state, cfg, defaultCountTokens); + const ranges = buildCompressibleRanges(messages, state, cfg, protectedRefs, defaultCountTokens, messages); + const merged = mergeRangesToThreshold(ranges.compressible, gate); + const screened = screenRecommendedUnits(merged, messages, state, cfg, protectedRefs); + assert.ok( + screened.length > 0, + "pre-fix this funnel returned [] and the host fell back to physical pruning", + ); + const viable = screened.filter((r) => r.tokens >= 200); + assert.ok(viable.length > 0, "at least one unit clears the 200-token viable floor"); + }); +}); From fe89925541390aa988f733efacaf66a3d9ffd155 Mon Sep 17 00:00:00 2001 From: guowenjie <691675122@qq.com> Date: Thu, 12 Nov 2026 12:45:05 +0800 Subject: [PATCH 2/3] chore(kernel): bump 0.0.195 -> 0.0.196 (version discipline for the megaturn gap-bridging source change) --- kernel/package-lock.json | 4 ++-- kernel/package.json | 2 +- package-lock.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kernel/package-lock.json b/kernel/package-lock.json index da83f9717b..42edfa9cb7 100644 --- a/kernel/package-lock.json +++ b/kernel/package-lock.json @@ -1,12 +1,12 @@ { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/kernel/package.json b/kernel/package.json index c130a693b8..b3a585a447 100644 --- a/kernel/package.json +++ b/kernel/package.json @@ -1,6 +1,6 @@ { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196,", "description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.", "license": "MIT", "author": "ranxianglei", diff --git a/package-lock.json b/package-lock.json index b333eb55fd..e2ff6fb483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ }, "kernel": { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196", "dev": true, "license": "MIT", "devDependencies": { From ed2926e17de81e57bbe717780f0cdc72397cd137 Mon Sep 17 00:00:00 2001 From: guowenjie <691675122@qq.com> Date: Thu, 12 Nov 2026 13:05:14 +0800 Subject: [PATCH 3/3] fix(kernel): correct 0.0.196 version string typo (stray comma inside the version literal made lock-sync drift) --- kernel/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kernel/package.json b/kernel/package.json index b3a585a447..f03fc7adee 100644 --- a/kernel/package.json +++ b/kernel/package.json @@ -1,6 +1,6 @@ { "name": "acp-kernel", - "version": "0.0.196,", + "version": "0.0.196", "description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.", "license": "MIT", "author": "ranxianglei",