Skip to content

fix(tui): scope checkpoint recognition by era - #103

Open
asto18089 wants to merge 6 commits into
pinvou3-cleanfrom
fix/594-checkpoint-era-recognition
Open

asto18089 wants to merge 6 commits into
pinvou3-cleanfrom
fix/594-checkpoint-era-recognition

Conversation

@asto18089

@asto18089 asto18089 commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes the recognition-family residuals tracked in Pinvou/pinvou-agent#594 (follow-ups disclosed as out of scope in #74/#76 and again in #88). The parent issue closes through the pinvou-agent gitlink PR that pins the merge commit of this PR — the fix reaches pinvou-agent main only then.

Problem

is_compaction_checkpoint_message matches any user turn whose text merely starts with the summary header — the boundary the restore lane accepted because pre-provenance saves committed the bare summary. Restore now prefers the provenance-stamped carrier, but the rest of the recognition family still ran the loose predicate, so in a history that carries a stamped carrier a user turn that pastes the full header is misread as a checkpoint:

  1. Recompaction drops the turn verbatim. is_plain_user_text excludes it, so the last-round slice anchors earlier and bound_last_round filters the turn out of the replacement; validate_last_round_coverage cannot catch it because it classifies user prompts through the same loose predicate.
  2. Coupling trap: validate_survival_contract counted receipts with the same loose predicate, so fixing (1) alone would have rejected every such replacement as "prior summaries were duplicated".
  3. /edit skips the turn: is_runtime_owned_user_message classified it engine-owned, so the cut point landed on an older message.
  4. Retention never carried it: retained_user_messages skipped it, so the turn survived only as summary prose.

The restore-time topology repair (restored_topology_anchor) located a sidecar's summary with the same loose scan — a pasted turn before the stamped summary anchored the sidecar a round too early.

Fix

Recognition is era-scoped through a new history_recognition::CheckpointEra, following the structural (not session-age) framing #88 introduced: a history that contains a provenance-stamped carrier postdates stamping, so only the stamped form is its checkpoint and a pasted bare-header turn is user content everywhere the family classifies — recompaction slicing (last_round_start/last_round_range), replacement retention (retained_user_messages) and bounding (bound_last_round), coverage validation, receipt counting, /edit targeting (edit_last_turn_target), anchor placement (compaction_anchor), and restore repair. Histories without a stamped carrier predate the stamp, where a pasted header is indistinguishable from the saved bare carrier, so the loose form keeps applying — legacy recognition is unchanged and now pinned explicitly.

The survival receipt count reads the replacement in its own era: a modern replacement that retains a pasted turn still validates with exactly one stamped receipt, while hand-built/legacy replacements keep the loose count the fixture matrix pins. The dead compaction::is_compaction_summary_text (zero callers repo-wide) is deleted along with the stale "explicitly out of scope here" doc sentences, and SURVIVAL_CONTRACT.md states the era rule.

Deliberately unchanged: request-time wire relocation (client/chat.rs) already reads only the stamped form; restore_compaction_checkpoint's prefer-stamped-then-loose carrier swap (its own documented boundary); the loose existence checks (inspect_compaction_keep, last_round_kept_count), where a stamped carrier also matches the loose form so both eras answer identically.

Verification

  • cargo fmt --check; clippy -p codewhale-tui --lib clean. (Note: the floating stable 1.99 toolchain now fires chunks_exact_to_as_chunks / print_stderr / redundant_closure on the pristine base test targets (shell_dispatcher.rs:762, core/engine/tests.rs:20141, :25056) — pre-existing, untouched here.)
  • cargo test -p codewhale-tui --lib: 12025 passed / 0 failed (under RUST_MIN_STACK=16777216; one unrelated remote_control test flaked once under parallel load and passes in isolation).
  • Mutation check: collapsing CheckpointEra::is_checkpoint to the loose form turns exactly the five new pins red and leaves the pre-existing suite green:
    • forkguard_pasted_header_turn_survives_recompaction_post_provenance (residual 1+2)
    • legacy_history_still_replaces_a_bare_header_turn (legacy boundary stays put under the fix; red only if someone tightens legacy)
    • forkguard_retention_carries_a_pasted_header_turn_only_post_provenance (residual 4)
    • edit_target_reads_a_pasted_header_turn_post_provenance (residual 3)
    • forkguard_restore_anchors_a_trailing_sidecar_to_the_stamped_summary_not_a_pasted_turn (restore repair) + the two new language-invariant fixture cases modern-pasted-header-turn-kept / modern-pasted-header-turn-dropped.

Pinvou/pinvou-agent#594

No-Issue: fixes Pinvou/pinvou-agent#594, which lives in the parent repo; the gitlink PR there carries the closing reference.

Fix round (review follow-ups, commits b589114 / 8c8e09d / fc9dd2d)

Fresh review found no P0/P1. Three should-fixes and the doc drift are fixed on top, rebased onto pinvou3-clean @ 3118c36:

  • Era routing completed. replace_agent_topology_checkpoint detected a trailing summary stamped-only, so a pre-provenance history ending in its bare carrier appended the topology sidecar user → user right behind it. The check now reads the era (compaction_anchor_in_era shares one derivation); a post-provenance history keeps appending after a pasted-header turn, which is the latest real user input.
  • Receipt floor raised for post-provenance originals. A hand-built stampless replacement used to pass vacuously (the retained pasted turn counted loosely as the receipt). A modern original now demands the stamped carrier — the engine stamps every replacement it builds, and the stamp is the only form request rewriting relocates.
  • Mirror validator repaired. validate_survival_contract.mjs carried a substring-only checkpoint check, so the new modern matrix cases broke it (base: 9 ok → head: exit 1). It now mirrors the loose/stamped block shapes and the era, including the raised receipt bar: ok 11 survival-contract fixtures.
  • Host-side enabler exported. user_turn_prompt_indices (history-aware form of is_user_turn_prompt) is re-exported so host turn numbering / rewind cut points can share the era-aware predicate instead of the legacy single-message one.
  • Docs aligned with the structural framing fix(tui): recognize restore carriers by provenance (replaces #74) #76/fix(compaction): restore without a checkpoint must not delete history #88 established: the era signal is the history's content, not the session's age — one-way and destructible (/edit or a host rewind can truncate away the only stamped carrier), foreign imports stay legacy, typed input cannot forge the two-block form. SURVIVAL_CONTRACT.md states the raised receipt bar and correct attribution; edit_last_turn_target discloses the truncate-away-the-carrier boundary.

Verification: cargo fmt --check clean; clippy -p codewhale-tui --lib 0 warnings; cargo test -p codewhale-tui --lib 12100 passed / 0 failed (RUST_MIN_STACK=16777216); mutation re-check — collapsing is_checkpoint to the loose form turns exactly the era-dependent pins red (now 9, including the new slicing/sidecar/indices pins and the raised receipt floor via the duplicated-summary path) and leaves legacy_history_still_replaces_a_bare_header_turn green.

R2 fresh-review round (commits a6dbb6f / 26e94fd)

A second from-scratch review (11 subagents across completeness, era semantics, receipt-floor writers, mirror fidelity, claim-vs-issue fidelity, per-module correctness, smuggling, performance, test adequacy, and an adversarial red-team pass) confirmed the fix round: zero P0/P1. Two commits land its findings:

  • Mirror coverage witness scoped by era. validate_survival_contract.mjs witnessed replacement texts through a bare userTextOf, while the Rust floor witnesses through the era-scoped user_prompt_text_of on both sides — the required-set side was era-scoped, the witness side was not, so a post-provenance rewrite could drop a last-round turn whose text is the bare marker sentence and still pass the mirror (the carrier's joined text starts with exactly that marker). The witness now reads through the same era-scoped plain-text predicate, and a new matrix case modern-dropped-turn-not-witnessed-by-a-carrier-text (expect fail) pins the rule: Rust rejects it, the era-blind witness accepted it. The validator header also discloses that its plain-text classification stays scoped to the fixture matrix. node validate_survival_contract.mjs → ok 12 survival-contract fixtures.
  • Doc accuracy. is_generated_compaction_checkpoint said a hand-forged stamp "flips recognition for that one message" — the flip is history-wide (of_history keys off any stamped carrier); CheckpointEra now also covers the spliced-mix case (genuine bare carrier + stamped carrier reads post-provenance, the bare carrier reclassifies as content); user_turn_prompt_indices now discloses the Unsupported-latest-turn divergence from edit_last_turn_target (the list ends at the newest editable turn while /edit refuses at Unsupported).

Verification: cargo fmt --check clean; clippy -p codewhale-tui --lib 0 warnings; cargo test -p codewhale-tui --lib 12100 passed / 0 failed (RUST_MIN_STACK=16777216); mutation re-check from the R2 review — collapsing CheckpointEra::is_checkpoint to the loose form turns exactly the 9 era-dependent pins red and leaves legacy_history_still_replaces_a_bare_header_turn green.

The restore lane already prefers the provenance-stamped carrier (#76, #88),
but the rest of the recognition family still ran the loose predicate
`is_compaction_checkpoint_message`, which matches any user turn whose text
merely starts with the summary header. In a history that carries the stamped
carrier that misread has four effects (Pinvou/pinvou-agent#594):
recompaction anchors the last round earlier and `bound_last_round` filters
the pasted turn out of the replacement — verbatim loss the coverage floor
cannot catch, because it classifies user prompts through the same loose
predicate; the survival contract counts the retained turn as a duplicated
summary, so fixing the drop alone would have made every such replacement
fail closed; `/edit` classifies the turn engine-owned and truncates at an
older message; and retention never carries the turn verbatim. The
restore-time topology repair located a sidecar's summary with the same loose
scan, anchoring the sidecar to a pasted turn instead of its summary.

Recognition is now era-scoped through `history_recognition::CheckpointEra`:
a history that contains a stamped carrier postdates stamping, so only the
stamped form is its checkpoint and a pasted bare-header turn is user content
in recompaction slicing, replacement retention and bounding, coverage and
receipt validation, `/edit` targeting, anchor placement, and restore
repair. Histories without a stamped carrier predate the stamp — there the
pasted header is indistinguishable from the saved bare carrier, so the
loose form keeps applying; the legacy behavior is now pinned explicitly in
the Rust tests and the fixture matrix. The receipt count reads the
replacement in its own era, so a modern replacement that retains a pasted
turn still validates with exactly one receipt. The dead
`compaction::is_compaction_summary_text` (zero callers repo-wide) is
deleted along with the stale "out of scope here" doc sentences.

Verified: cargo fmt --check; clippy -p codewhale-tui --lib clean (the
floating stable 1.99 chunks_exact/eprintln/redundant-closure lints fire on
the pristine base test targets too); cargo test -p codewhale-tui --lib
12025 passed / 0 failed under RUST_MIN_STACK=16777216 (one unrelated
remote_control test flaked once under parallel load and passes in
isolation); mutation check — collapsing is_checkpoint to the loose form
turns exactly the five new pins red (pasted-turn recompaction survival,
retention, /edit targeting, sidecar anchor, fixture matrix) and leaves the
pre-existing suite green.

Pinvou/pinvou-agent#594

Signed-off-by: asto <asto18089@126.com>
The era-scoping round left two classifications outside CheckpointEra and
documented the signal too strongly:

- `replace_agent_topology_checkpoint` detected a trailing compaction
  summary with the stamped predicate only, so a pre-provenance history
  ending in its bare carrier appended the topology sidecar straight after
  the summary — the `user → user` shape the placement fix exists to
  prevent. The check now reads the history's era, and the anchor lookup
  shares one derivation (`compaction_anchor_in_era`).
- The survival receipt floor read the replacement in its own era, so a
  hand-built stampless replacement of a post-provenance history passed
  vacuously: the retained pasted turn counted loosely as the missing
  receipt. A post-provenance original now demands the stamped carrier —
  the engine stamps every replacement it builds, and the stamp is the
  only form request rewriting relocates.
- Docs: the era signal is the history's content, not the session's age —
  one-way and destructible (`/edit` or a host rewind can truncate away
  the only stamped carrier), foreign imports stay legacy, and typed
  input cannot forge the two-block form. SURVIVAL_CONTRACT.md states the
  raised receipt bar and stops attributing recompaction/retention//edit
  survival to the receipt count alone; `edit_last_turn_target` discloses
  the truncate-away-the-carrier boundary.

Pins: the raised receipt floor, era-aware round slicing and kept-count,
the legacy bare-carrier sidecar anchor, and the era-aware turn indices;
the /edit pin joins the forkguard_ convention. Mutation check —
collapsing `is_checkpoint` to the loose form turns exactly the
era-dependent pins red and leaves the legacy boundary pin green.

Pinvou/pinvou-agent#594

Signed-off-by: asto <asto18089@126.com>
…cript

validate_survival_contract.mjs read the same fixture matrix with a
substring-only checkpoint check, so the modern pasted-header cases the
previous commit added broke it: the stamped carrier's text was demanded
as surviving user content and the kept case failed closed. The script now
mirrors the Rust shapes structurally — loose is a header-prefixed first
text block with an empty or provenance-marker rest, stamped is exactly
[text, marker] — derives the era from the history, scopes plain-user
coverage and receipts through it, and raises the receipt bar for
post-provenance originals like `validate_survival_contract`. All 11
fixtures pass again under `node validate_survival_contract.mjs`.

Pinvou/pinvou-agent#594

Signed-off-by: asto <asto18089@126.com>
The era model made the engine's history classifications era-aware but
kept the machinery `pub(crate)`, so the host app's turn numbering and
rewind cut points — which hold the full history in scope — could not
follow and stayed on the legacy per-message predicate. Export
`user_turn_prompt_indices`, the history-aware form of
`is_user_turn_prompt`: one era derivation per history, indices of every
editable user turn. Rewind numbering, turn counts, and `/edit` targeting
can then read one list and stay consistent in post-provenance histories.
`is_user_turn_prompt` stays for single-message callers with no history
in sight.

Pinvou/pinvou-agent#594

Signed-off-by: asto <asto18089@126.com>
@asto18089
asto18089 force-pushed the fix/594-checkpoint-era-recognition branch from c57b6a0 to fc9dd2d Compare October 13, 2026 03:39
@asto18089

Copy link
Copy Markdown
Collaborator Author

Fix round pushed after a fresh from-scratch review (8 subagent lanes over the whole recognition family + hand verification; zero P0/P1 found). Three should-fixes, the doc drift, and one host-side enabler, in three commits on a rebased branch (pinvou3-clean @ 3118c36; patch-id of the original commit is unchanged by the rebase):

  1. b589114 — route the remaining checkpoint classifications through the era:
    • replace_agent_topology_checkpoint was the one known-history classifier still outside CheckpointEra (stamped-only trailing-summary check): a pre-provenance history ending in its bare carrier got the topology sidecar appended user → user right behind the summary. Era-aware now; the anchor lookup shares a single derivation via compaction_anchor_in_era. Legacy-only behavior change, covered by a new pin (forkguard_sidecar_anchors_at_a_trailing_bare_carrier_only_pre_provenance).
    • The survival receipt floor now demands the stamped carrier when the original is post-provenance, closing the vacuous pass where a hand-built stampless replacement was satisfied by the retained pasted turn counting loosely. Engine-built replacements are unaffected (they always stamp).
    • Docs moved to the structural framing: the era signal is the history's content, not the session's age — one-way and destructible via /edit/rewind truncation, foreign imports stay legacy, typed input cannot forge the two-block form (only a hand-edited session file can, flipping one message's classification). SURVIVAL_CONTRACT.md also stops attributing recompaction/retention//edit survival to the receipt count alone.
    • New pins: raised receipt floor, era-aware last_round_start/last_round_kept_count on a pasted turn, the bare-carrier sidecar anchor; the /edit pin joined the forkguard_ convention.
  2. 8c8e09d — validate_survival_contract.mjs mirrored the matrix with a substring-only check, so the modern cases added here broke it (verified: base 9 ok → head exit 1). It now mirrors the loose/stamped block structures and the era, including the raised receipt bar; node validate_survival_contract.mjs → ok 11 survival-contract fixtures.
  3. fc9dd2d — export user_turn_prompt_indices (history-aware is_user_turn_prompt): the host app holds the full history when numbering turns / picking rewind cut points, and per-message legacy classification misses exactly the pasted turns this PR makes visible. Follow-up for the paired parent PR: migrate rewind.rs (indices + undo count) and code_checkpoints/turns.rs (count_user_turns; note count_user_turns_in_json must deserialize the whole array before counting once — per-message single-slice calls would defeat the era).

Verification: cargo fmt --check clean; clippy -p codewhale-tui --lib 0 warnings; cargo test -p codewhale-tui --lib 12100 passed / 0 failed (+4 pins); mutation re-check — collapsing is_checkpoint to the loose form turns exactly the 9 era-dependent pins red and leaves legacy_history_still_replaces_a_bare_header_turn green.

R2 fresh review found the mirror validator's coverage loop witnesses
replacement texts through a bare userTextOf, while the Rust floor
witnesses through the era-scoped user_prompt_text_of on both sides
(compaction/last_round.rs). The required-set side was era-scoped in
8c8e09d; the witness side was not, so a post-provenance rewrite could
drop a last-round turn whose text is the bare marker sentence and still
pass the mirror: the replacement carrier's joined text starts with
exactly that marker. The matrix never exercised this, so the split was
invisible.

Witness through the same era-scoped plain-text predicate, and pin the
rule with a new matrix case modern-dropped-turn-not-witnessed-by-
a-carrier-text (expect fail): Rust already rejects it, the era-blind
witness accepted it, and the scoped witness rejects it again. The
header now also discloses that the mirror's plain-text classification
stays scoped to the fixture matrix (it skips the rest of Rust's
classify_user_turn_prompt_in_era conjuncts, which the fixtures never
contain).

Verified: node validate_survival_contract.mjs -> ok 12; a probe build
with the era-blind witness restored fails exactly on the new case;
cargo test -p codewhale-tui --lib fixture_matrix green.

Signed-off-by: asto <asto18089@126.com>
R2 fresh review follow-ups, comment-only:

- is_generated_compaction_checkpoint claimed a hand-forged stamp "flips
  recognition for that one message"; of_history keys off any stamped
  carrier, so the flip is history-wide: every other bare-header turn
  reclassifies as content, while the forged message itself reads as the
  checkpoint (deleted by restore, skipped by retention and bounding,
  unprotected by the coverage floor). Say that.
- CheckpointEra covered foreign imports but not the spliced-mix case: a
  history holding both a genuine pre-stamp bare carrier and a stamped
  carrier reads post-provenance, so the old bare carrier reclassifies as
  user content (retained, editable, round-anchoring).
- user_turn_prompt_indices promised consistency with what /edit targets
  without disclosing the Unsupported-latest-turn divergence: the list
  ends at the newest editable turn while /edit refuses at Unsupported.
  Point rewind cut points at edit_last_turn_target for that case.

Signed-off-by: asto <asto18089@126.com>

@JensenChen28 JensenChen28 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

复核 exact head 26e94fd8。我沿 checkpoint 识别族逐项核对了 era 推导在 recompaction slicing、retention、coverage/receipt 校验、/edit target、topology anchor 与 restore repair 中的使用;现代 history 只识别 stamped carrier,legacy history 保持 loose 兼容,replacement receipt floor 也不会再被粘贴的裸 header 冒充。JS mirror 与 Rust 规则一致,未发现漏接的分类路径或新的数据丢失边界。

本地 node crates/tui/src/compaction/validate_survival_contract.mjs 返回 ok 12 survival-contract fixtures,git diff --check 通过;exact-head check、CodeQL、DCO、Gitleaks 均成功。批准。

@JensenChen28 JensenChen28 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the history-era changes against 3118c36 across round slicing, retention, survival validation, edit targeting, and topology-anchor placement. I found no blocking defect in those paths. The stamped-history rule preserves pasted headers while the documented legacy ambiguity remains explicit.

Independent local validation: all 12 survival-contract fixtures passed. A small Rust executable using the exact recognition module with minimal message types also passed legacy/stamped, pasted/quoted, mixed-content, role, and truncated-history cases. These focused checks are not a full Rust TUI integration run. All required checks pass for this head.

Parent integration note: Pinvou currently still uses the single-message is_user_turn_prompt predicate in features/code_checkpoints/turns.rs and features/sessions/rewind.rs. When adopting this foundation head, migrate history-wide numbering/counting to user_turn_prompt_indices and retain edit_last_turn_target for safe edit selection; exporting the new helper alone does not update those host callers.

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.

Loose checkpoint-predicate residuals beyond restore: recompaction drop, /edit targeting, retention

2 participants