Skip to content
Open
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,8 +413,8 @@ Versions follow the merge of a `*_release-v*` branch; CI publishes to npm on tag

### Fixes

- **Session input baselines no longer outlive their measurement context — failure arms adopt the billing-caliber reading, and a model switch retires trusted baselines (#2683)**: two producers of stale baselines in one mechanism family. (1) `armFailureShrink` (no-usage relay 5xx / network failure) adopted the char-count UPPER BOUND of the outbound payload as `session.stats.lastInputTokens` — a 1.7–4× over-reading persisted as the session baseline, inflating preflight entry budgets, responses-lane output headroom and displays, and surviving a model/window change until the next usage report (live case: 441,193 armed against a 202,885-token payload; after a 1M→400k model switch the phantom meter read 142% and two ~3-minute emergency folds ran on a payload already at 62% of the new window). The arm now adopts the same BILLING-CALIBER reading the preflight trigger uses (k̂-scaled chars/4 + wire overhead + image reserve), and its log names the provenance (`(k̂-calibrated outbound reading)` / `(raw outbound reading)`). This supersedes #1498-F1's bound floor: the kernel truncate-loop consumer that justified it no longer exists (every processTurn feed is quarantined since #1492/#1839; preflight runs noEmergencyTruncate), so the bound's only live effect was phantom mass. **Behavior change (old→new):** the armed value on a no-usage failure drops from the char-count upper bound to the billing-caliber estimate — it may now land BELOW a future bill on dense JSON/CJK routes (chars/4 undercounts, #553); acceptable because estimate-grade values are quarantined from every decision floor and k̂ closes most of the gap where route+model provenance matches. (2) A usage-grade baseline measured under model X kept deciding requests for model Y: only ORIGIN changes demoted it (#1933 F2), so after a mid-session model switch the foreign bill pinned the preflight loop's decisionTokens above the fold target forever — folding until budget exhaustion although the payload fit. A real model change (both sides known; unknown→known never counts) now retires the trusted-grade baseline at the per-request model-stamp site (`retireCrossModelBaselines`, logged with both models and the retired value) BEFORE prepare sizes the nudge and before the trigger runs; the next usage report re-anchors. Estimate-grade values survive the switch (byte-scale properties of the payload, not of the billing model); same-model window moves keep the bill valid, so genuine overflow still folds. Tests: `tests/issue2683-cross-model-baseline.test.ts` (G1 cross-model retirement → zero summary calls + re-anchor; G2 arm scale band [chars/8, char-count) + provenance clause; G3 control: same-model over-window bill still folds via its floor) and the re-scaled #1498-F2 assertion in `tests/issue1493-arm-outbound-payload.test.ts`.
- **Anthropic Threads continue-turns survive the compress loop: re-requests continue from the latest upstream response id instead of re-sending the client's already-consumed `previous_message_id` (#2701)**: with `thread: {type:"continue", previous_message_id}` (Claude Code 2.1.295 via Threads), the conversation lives server-side and the request body carries only the turn's 1–2 delta messages. The loop's internal round-2 fetch CONSUMES the client's continue target, and the legacy round-2 body — a full-history rebuild from `adapter.buildRequest(coreMessages, ...)` re-spreading the client's `thread` field — re-sent exactly that consumed target: upstream 400 `"The requested `previous_message_id` has already been continued. Each response `id` may be continued at most once"`, turn dead, fold lost. Worse, that rebuild re-stamps system via `appendSystemText(systemPrompt, originalSystem)` — different bytes than the steady prepare path (`injectSystem` + `#1085`-anchored stamp) ever sends — poisoning the per-thread fingerprint: after every fold the NEXT client continue mismatched with `"differing field(s): cache_control, system, tools"` (the class-2 replay 400s; corrects #2648's P3 note — folds ARE inherited by the server thread once a create delivers them, the limitation was fold-only-deliverable-at-create plus this drift). Four coordinated changes, anthropic-only, zero new knobs: (1) the adapter records EVERY round's `message_start` id (`threadTailId` + `threadResponseIds`, exposed as optional `CompressLoopAdapter` hooks) and reports the tail id on the final terminal `message_delta`; (2) on a threads-CONTINUE turn, every re-request (main re-request, the three nudge retries, the degraded thinking-strip retry) is rebuilt delta-shaped — `{...requestBody, thread: {…, previous_message_id: latestRoundId}, messages: coreToAnthropic(unsent delta)}` — where the delta carries ONLY the loop's own client-invisible additions since the last accepted send (proxy tool_results + retrieval injections; assistant output already lives server-side in r_k; visibility markers deliberately excluded — a server thread is append-only and would carry the client noise forever); system/tools/every other byte stay identical to round 1's body (fingerprint stable); acceptance bookkeeping rolls the accepted window when a round's response starts; (3) the #413 blind truncation retry is barred once the sent body's continue target is provably consumed (a response id was observed since the send) — a byte-identical re-send would 400; pre-start truncations keep the retry; (4) `src/server/thread-tracking.ts` persists every observed id into `session.metadata.anthropicThreadTail` (`{tail, consumed[]}`, capped 20, recorded in the loop's `finally` so consumed ids survive even turns that die mid-round), and `prepareAnthropic` rewrites an inbound continue whose target sits in the consumed set to the tracked tail (unknown ids pass through untouched — restart/foreign-route safe; a `create` resets tracking). Thread CREATE turns keep the legacy full rebuild: a create re-sends the whole (possibly folded) history — the only channel that can deliver a fold to the server-side thread. Known limitation (documented in code): a fold executed during a CONTINUE turn is not reflected server-side until the next create (“silent lie” — the loop's local view is folded, the thread keeps the pre-fold text; emergency truncation stays impossible on continues for the same reason). Tests: `tests/issue2701-threads-continue.test.ts` — re-request continues from `msg_r1` with a single-message tool_result delta and byte-identical system/tools; ids persisted as `{tail: msg_r2, consumed: [msg_r1]}`; terminal `message_delta` carries the tail id; pre-start truncation keeps the blind retry (byte-identical re-send); post-start truncation bars it (no re-fetch, client-visible error); create turns keep the full-rebuild path; thread-tracking unit tests (roll, cap, rewrite-to-tail, unknown-id passthrough, create reset).

- **Outgoing bodies are scrubbed for unpaired surrogate escapes at every send seam — JSON.stringify hides the half as escape text, so the existing guard could never see it (#2565, #816/#828/#1615 family, fourth site)**: a conversation could become permanently stuck against a strict upstream — every request rejected with a non-retryable 400 `invalid_request`, every other conversation on the same provider/key fine. Two defects combined. (1) Ingest: a summary can hold a real lone half (a model that copies literal `\uXXXX` prose out of tool output — our ACP-status panel text is emitted that way — has it re-decoded along the summarizer path), and it was then stored verbatim and re-emitted on every later request. (2) Wire: `scrubLoneSurrogates()` existed in `src/text-safe.ts` but its only caller was `summaryFingerprintLine` (`src/stream.ts`, the log fingerprint line) — the outgoing body was never scrubbed, and applying it at the send seam is not enough, because `JSON.stringify` renders a lone half as the **six-character escape text** `\uXXXX`: by the time the body is a string there is no code unit left for the code-unit regex to find. Now `src/text-safe.ts` also exports `scrubLoneSurrogateEscapes()` — walks backslash runs so an escape only starts on an **odd**-length run (doubled `\\uXXXX`, i.e. literal prose a model quoted, is left byte-identical), keeps a well-formed high+low escape pair verbatim, and rewrites an unpaired half to the `\uFFFD` escape — plus `scrubLoneSurrogatesOnWire()` (that, over the existing real-unit scrub) which is applied at all four serialization seams: `src/loop/core.ts` `fetchUpstream`'s `wireBodyStr`, `src/server/relay.ts` (the final-send seam, before `applyResign` so the signature covers the sent bytes, and the context-overflow refold re-send), and `src/reasoning-guard.ts`'s round re-send; `kernel/src/compress.ts` scrubs summaries at `applyCompression` ingest so the half is never stored in the first place (shared `scrubLoneSurrogates()` in `kernel/src/truncate.ts`). Buffer bodies (raw passthrough) are deliberately untouched. Tests: `tests/text-safe.test.ts` pins the escape scanner (lone high/low escape → `\uFFFD`, valid escape pair kept, doubled backslash prose kept, ordinary escapes `\n` `\t` `\u0041` `\u4e2d` kept, short hex and trailing backslash kept), `tests/wire-lone-surrogate-scrub.test.ts` is an e2e against a mock upstream (poisoned body still gets a 200 and no unpaired half reaches the upstream, valid pairs survive, U+FFFD present; a clean body is not over-scrubbed), `kernel/tests/scrub-lone-surrogates.test.ts` covers the code-unit scrub and the ingest scrub.

- **Claude Code forks resolve their parent from the transcript, refs are never re-issued after inheritance, large appends no longer read as rewrites (#2426)**: an SDK `forkSession` followed by a plain resume (opencode-claude retrying a turn after a usage-limit error) carries no `--fork-session` SessionStart declaration, and the first forked request differs from the parent's last request in its tail (resume-time `<system-reminder>` / skill diffs) — so the byte-exact `findResumeParent` rejected the true parent (equal length, last message differs) and adopted a far older ancestor: 18 folded blocks were not inherited, ~1871 messages were folded again, and `unannounced-rewrite` fired. Three independent fixes: (1) `src/claude-transcript-lineage.ts` reads the `forkedFrom` marker Claude Code stamps on every fork transcript line (`<CLAUDE_CONFIG_DIR or ~/.claude>/projects/*/<id>.jsonl`, header-only read ≤ 256 KB, realpath confined to the projects root, cycle-safe, depth ≤ 8) and walks it to the nearest ancestor bili already knows; the #1486 resume-inheritance block asks it first for requests identified by `x-claude-code-session-id` (anthropic, main lane, loopback only) and falls back to the prefix match when no transcript / marker / known ancestor exists. (2) Adoption and `applyCompactionArchive` pruning now reserve the parent's ref space (`bili:ref-floor` sentinel in `messageRefs.byRaw`, excluded from rewrite accounting) so a number that denoted message X in the parent can never be re-issued in the child. (3) `detectUnannouncedHistoryRewrite` also requires `knownIncoming / knownBefore < 0.5`, so a turn that appends many messages is no longer reported as an unannounced rewrite while a real rewrite still is. No new config or env knobs. One existing test that hard-coded the child reusing the parent's ref numbers (`tests/fork-ccr-adoption.test.ts`) now asserts numbering above the parent's highest ref.
Expand Down
4 changes: 2 additions & 2 deletions kernel/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 20 additions & 12 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3108,7 +3108,7 @@ export function outboundPayloadBreakdown(
opts: ProxyOptions,
route: ReturnType<typeof resolveUpstream>,
reqUrl: string,
): { textEstimate: number; overheadEstimate: number; imageTokens: number; payloadEstimate: number; armEstimate: number } {
): { textEstimate: number; overheadEstimate: number; imageTokens: number; payloadEstimate: number; armEstimate: number; armCalibrated: boolean } {
const billingUpstream = route?.rewrittenUrl ?? (/^https?:\/\//i.test(reqUrl) ? reqUrl : opts.upstream);
const imageTokens = imageReserveFor(prepared.session, prepared.protocol, prepared.body, opts, billingUpstream);
// #1498-F2: a kernel-transform failure leaves processedMessages empty while
Expand All @@ -3117,17 +3117,25 @@ export function outboundPayloadBreakdown(
const msgs = prepared.processedMessages.length > 0 ? prepared.processedMessages : prepared.originalMessages;
const textEstimate = estimateCoreMessages(msgs);
const overheadEstimate = estimateWireOverhead(prepared.protocol, prepared.body);
// #1498-F1: the arm value is not only a preflight floor — the kernel's
// tool-result truncation loop consumes it as its remaining-depth input, so
// an optimistic text estimate (chars/4 undercounts dense JSON/code by up to
// ~4x, #553) stops the loop one candidate early and re-breaks the #604
// relay rescue. The arming quantity is therefore floored by the text char
// bound, which never undershoots and deliberately does NOT count image
// base64 — image-heavy payloads keep the billing-accurate payloadEstimate
// (#1493) while text-dominated near-window payloads arm deep enough for the
// truncation loop to clear hidden upstream tolerances (#604).
const armEstimate = Math.max(textEstimate, estimateCoreMessagesUpper(msgs)) + overheadEstimate + imageTokens;
return { textEstimate, overheadEstimate, imageTokens, payloadEstimate: textEstimate + overheadEstimate + imageTokens, armEstimate };
// #2683 (supersedes the #1498-F1 bound floor): the arm adopts a
// BILLING-CALIBER READING of this send — the same k̂-scaled outbound
// formula the preflight trigger uses (#1933 F1 / #2117 B) plus the image
// reserve — NOT the char-count upper bound. The bound was floored in
// because the kernel's tool-result truncation loop once consumed the arm
// as its remaining-depth input (#604 rescue); since the #1492/#1839 reader
// quarantine NO processTurn feed passes an estimate-grade arm into any
// band (preflight runs noEmergencyTruncate), so the bound's only live
// effect was phantom mass: a 1.7–4× over-reading persisted as the session
// baseline, inflating preflight budgets, output headroom and displays
// until the next usage report landed. A reading may miss LOW (chars/4
// undercounts dense JSON/code, #553) — acceptable because estimate-grade
// values are quarantined from every decision floor, and k̂ closes most of
// the gap where route+model provenance matches.
const kFactor = currentCalibrationFactor(prepared.session.stats, prepared.session.metadata?.lastModel);
const armRaw = textEstimate + overheadEstimate;
const armText = applyEstimateCalibration(armRaw, kFactor, prepared.session.stats.calibratedEstimateOrigin, normalizeUpstreamOrigin(billingUpstream));
const armEstimate = Math.round(armText + imageTokens);
return { textEstimate, overheadEstimate, imageTokens, payloadEstimate: textEstimate + overheadEstimate + imageTokens, armEstimate, armCalibrated: armText !== armRaw };
}

/** #2078: `parsed` carries the caller's pre-parsed send body so forward() does
Expand Down
14 changes: 13 additions & 1 deletion src/server/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { hostIdForLog, maskHeadersForLog, maskUrlForLog, maskUrlsInText } from "
import { bodyMightCarryImages, buildIncomingImageIndex, pruneRetrieveImgExports, refreshIncomingImageIndex, type IndexedImage } from "../image-restore.js";
import { durableMessageToolPaths, withDurableProtection } from "../durable-message-guards.js";
import { biliToolsDeclaredOnWire, countBiliToolUses, declaresMcpBiliToolsOnWire, evaluateSelfHealRound, pluginLaneDegraded, pluginLaneRestore } from "../session-self-heal.js";
import { acquireInFlight, getSession, hasProcessedState, markDirty, peekSession, releaseInFlight, storeEffectiveConfig, tickPostRebuildAnchor, withSessionLock, type Session } from "../session.js";
import { acquireInFlight, getSession, hasProcessedState, markDirty, peekSession, releaseInFlight, retireCrossModelBaselines, storeEffectiveConfig, tickPostRebuildAnchor, withSessionLock, type Session } from "../session.js";
import { buildCompressSystemPrompt } from "../compress-tool.js";
import { storeEffectiveSearchPlanAware } from "../decompress-shared.js";
import { externalSummaryEnabled } from "../external-summary-surface.js";
Expand Down Expand Up @@ -2339,6 +2339,18 @@ async function handleRequest(
// plugin sessions, so wire-mode panels fell back to a hardcoded 200K.
session.metadata.effectiveContextLimit = nativeWindow;
session.metadata.reservedContextLimit = reqConfig.modelContextLimit;
// #2683: a real model change between consecutive requests invalidates
// the billing-grade baseline measured under the previous model — retire
// it here, before prepare sizes the nudge and before forward() runs the
// preflight trigger. lastModel is only ever stamped with a known id, so
// an absent prev is "unknown", not a switch; same-model window moves
// keep the bill valid and never fire this.
if (reqModelId !== undefined && session.metadata.lastModel !== undefined && session.metadata.lastModel !== reqModelId) {
const retired = retireCrossModelBaselines(session);
if (retired > 0) {
log("warn", `[${session.id}] model switched ${session.metadata.lastModel} -> ${reqModelId} — retired cross-model input baseline (${retired} tokens billed under ${session.metadata.lastModel}); next usage report re-anchors (#2683)`);
}
}
// #955 runtime-info: record the model id + window source for this
// session so /__bili/plugin/status can show them pre-first-request and
// post-hoc forensics can tell which source sized the window.
Expand Down
Loading
Loading