Skip to content

fix(fable): make the effort-marker trim tolerance reachable - #242

Open
iceteaSA wants to merge 2 commits into
cortexkit:mainfrom
iceteaSA:fix/effort-marker-trim-tolerance
Open

iceteaSA wants to merge 2 commits into
cortexkit:mainfrom
iceteaSA:fix/effort-marker-trim-tolerance

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Fixes the correlation failed: expected N, found M variant of #237 — the initiating failure. It produces a parts=0 contentless assistant, which lowers to nothing on the next request and so cannot separate the user records around it, widening the next merge into the variant #241 fixes. #241 stopped the amplification; this stops the ignition.

All four fleet-wide occurrences, every one found < expectedloss, never duplication:

2026-09-12 08:47:23  ses_f7ed4e49…  expected 1, found 0
2026-09-14 09:49:53  ses_019303cc…  expected 4, found 1
2026-09-14 17:48:54  ses_f7ed4e49…  expected 1, found 0
2026-09-15 09:29:08  ses_019303cc…  expected 2, found 1

Loss already has correct tolerance at effort-history.ts:658-688: the host trims a history prefix, survivors must be an exact suffix of the plan, consumed transitions fold into the baseline. Neither occurring shape reached it, for two unrelated reasons.

Shape A — total trim short-circuits the tolerance

effort-history.ts:570-576 threw unconditionally on expectedTransitions, ~90 lines before the tolerance. The result was an asymmetry with no justification: trim 3 of 4 markers and the suffix logic accepts it; trim the 4th as well and the same legitimate compaction becomes a 400.

A full trim is simply trimmedPrefix === expectedTransitions.length — every transition consumed. It now folds all of them into the baseline. The throw is retained for the genuinely untrusted case: markerCount > 0 with no resolvable plan.

Shape B — the plan an in-flight header referenced was overwritten

The tracker held one slot per (sessionId, messageId). When the host trims the history prefix between provider calls, the same current user message is re-marked with a shorter timeline and record() overwrites the slot — while a header generated from the previous plan is still in flight. resolveHeader re-encodes tracked plans and string-compares, so it matched nothing, expectedTransitions was null, and the exact path made any loss fatal.

Fixed with a bounded identity-keyed history map alongside the existing slot map, so a header can still resolve the plan it was minted from.

The mechanism was demonstrated, not assumed. Two competing candidates were ruled out with evidence: LRU eviction (the in-flight plan is the newest; record + markHeaders refresh recency) and clear() on the same message id (the transform returns a plan, not null, for a single Fable user). A probe also corrected the original hypothesis — a growing tool loop alone leaves the plan identical; the trim is what re-records it.

Verification

Five mutations, re-run independently of the implementer's report, tree restored to clean between each:

mutation result
restore the unconditional !hasCandidate throw folds a full trim into the last consumed transition effort when the plan resolvesexpected 2, found 0
fold to requestPlan.baseline instead of the last consumed transition same test — effort: "low" where "high" expected
resolveHeader back to scanning the single-slot map resolves a plan header after the same message is re-recorded with a trimmed timelineExpected -13 / Received +1
disable history eviction (while (false)) evicts the oldest plan history entry once the cap is exceededExpected: 4096, Received: 4097
evict the newest instead of the oldest same test reddens

The last two were not in the original brief. The fourth caught a real gap: the first implementation's history bound was correct but entirely unasserted — disabling eviction left all 16 tests green. That map is keyed by encoded plan, so it gains an entry per distinct plan — per trim per message — growing faster than the slot map beside it, in a long-lived process. Issue #230 is open on exactly that shape, and introducing a second instance of it inside this fix would have been a poor trade.

The fifth separates bounded from bounded correctly: evicting the newest keeps size at the cap, so a size-only assertion would pass while silently reintroducing Shape B — the newest plan is precisely the one an in-flight header needs. Both halves are asserted.

Gates: core 199/0 · opencode 1898/0 · pi 114/0 · typecheck clean.

Scope

Untouched: the suffix, digest, and scope checks (:665-686, :695-706). Non-suffix loss, reordering, mutation, and scope mismatch all stay fail-closed — this makes a designed tolerance reachable rather than adding a new one.

Two existing tests encoded the old throw (the unit full-trim assertion and the integration missingAllMarkers case) and now assert the folded success instead.

Stacking note: this branch is off main and deliberately avoids :611-628 and :724, which #241 changes, so the two do not conflict.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Makes the effort-marker trim tolerance reachable so legitimate host prefix trims no longer fail as 400 errors. Full trims with a resolvable plan now fold into the last consumed transition's baseline, and in-flight headers can still resolve a plan the tracker has since overwritten.

Bug Fixes

  • A full trim threw unconditionally before the tolerance; it now folds all consumed transitions into the baseline and proceeds unless no plan resolves.
  • The tracker kept one slot per (sessionId, messageId), so re-marking the same message with a trimmed timeline orphaned the header already in flight; an identity-keyed history map now preserves those plans.
  • Both maps are bounded, and eviction is asserted — disabling it leaves 16 tests green, so the cap is pinned by test.
  • Non-prefix loss, reordering, mutation, and scope mismatch still fail closed.
  • Two existing tests encoded the old throw and now assert the folded success instead.

Written for commit c3930f8. Summary will update on new commits.

Review in cubic

RetriggerConfidence Score: 4/5

The PR is not yet safe to merge because the unresolved complete-trim path can accept non-prefix marker loss and apply the wrong effort.

Findings

  1. P1 Total loss bypasses validation
Summary

This PR changes OpenCode’s Fable 5.1 effort-history handling:

  • Preserves overwritten request plans in a bounded history so in-flight headers remain resolvable.
  • Accepts complete marker-prefix trimming when the correlated plan resolves and folds consumed transitions into the request baseline.
  • Adds unit and integration coverage for full trims, overwritten plans, and history eviction.

The previous review’s unresolved concern remains: the zero-marker path treats any body paired with a resolvable plan as a complete prefix trim without correlating the retained body to the plan’s current boundary.

Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Message transform] --> B[Record effort plan]
  B --> C[Attach encoded plan header]
  C --> D[Downstream history trimming]
  D --> E[Resolve original plan from bounded history]
  E --> F{Markers remain?}
  F -->|Yes| G[Validate suffix and apply transitions]
  F -->|No| H[Fold all transitions into baseline]
Loading

Reviews (2) · Last reviewed commit: "test(opencode): pin the effort plan hist..."

@iceteaSA

Copy link
Copy Markdown
Contributor Author

Rebased onto main at v1.23.0, no conflicts.

Still valid. effort-history.ts is byte-identical between this PR's base and v1.23.0 — the !hasCandidate unconditional throw is untouched, and nothing upstream introduced the history map this PR adds:

MAX_TRACKED_EFFORT_PLAN_HISTORY on upstream/main : 0

Worth noting against v1.23.0's changelog, which says it now folds "effort changes removed by downstream prefix compaction into the retained baseline through a checksum-bound current-boundary anchor". That landed in 1.22.0 and is the tolerance at :658-688 this PR is about — it is the mechanism whose two unreachable paths this PR fixes, not a fix for them. Shape A still short-circuits it ~90 lines earlier on a total trim; Shape B still cannot reach it when the tracker's single slot per (sessionId, messageId) has been overwritten by a re-record.

Pre-existing failures are upstream's

Two failures on the full opencode suite, both reproducing on clean upstream/main with none of this PR's changes: vault reauth leaves the account absent and projects the reauth state (passes alone, fails in-suite) and starts a durable enrollment ceremony in Claustrum mode without enabling scoped serving (load-sensitive; failed one clean-main run, passed the next). Identical failing set with and without this branch. Matches #220.

Typecheck passes; core 295/0.

// A full trim is the `trimmedPrefix === expectedTransitions.length` case:
// every transition was consumed by the host's prefix trim, so fold them all
// into the baseline. Without a resolvable plan the request is untrusted and
// stays fail-closed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Total loss bypasses validation

If downstream processing removes every marker while retaining an earlier or otherwise non-suffix message, hasCandidate is false even though the body is not a valid complete prefix trim. This branch then folds every transition into the baseline and sends the request with the wrong effort instead of rejecting the non-prefix loss. Verify that the retained body ends at the correlated plan's current boundary before accepting a full trim.

Knowledge Base Used:

…apes

The trimmed-prefix tolerance at effort-history.ts:658-688 was unreachable in
the two shapes that actually occur, so a legitimate host prefix trim became a
400 and manufactured the contentless assistant that widens the next merge.

Shape A (expected N, found 0): the !hasCandidate path threw unconditionally on
markerCount !== 0, ~90 lines before the tolerance. A full trim with a
resolvable plan is the trimmedPrefix === expectedTransitions.length case, so
fold every consumed transition into the baseline and proceed. The throw stays
for the untrusted case (markerCount > 0 with no resolvable plan).

Shape B (expected N, found 1): the request carried a valid plan header but the
tracker could not resolve it back to a plan, so expectedTransitions was null
and the exact path made any loss fatal. The tracker held one slot per
(sessionId, messageId); when the host trims the history prefix between provider
calls the same current message is re-marked with a shorter timeline, and
record() overwrote the slot while a header generated from the previous plan was
still in flight. Keep a bounded identity-keyed history so resolveHeader can
still find the plan a header references.

Tests: full-trim fold asserts the folded baseline is the last consumed
transition's effort; full trim without a resolvable plan still throws; a
re-recorded trimmed timeline no longer loses the in-flight plan; non-prefix
loss still throws.
The identity-keyed history map is bounded but was unasserted, so a later
refactor could delete the eviction silently. Record past the cap and assert
both halves: the size stays at the cap, and insertion-order eviction drops the
oldest plan while the newest still resolves (the newest is the one an in-flight
header needs).
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