@@ -3963,12 +3963,19 @@ fn apply_once(
39633963 }
39643964 timings.transition_detection += elapsed_ms(transition_post_detection_started_at);
39653965 let transition_newly_consumed = !newly_consumed_transition_classes.is_empty();
3966+ let mut committed_transition_classes = consumed_transition_classes.clone();
3967+ committed_transition_classes.extend(newly_consumed_transition_classes);
3968+ // A HARD rebuild reconstructs the frozen-unit vector from data-plane survivors, bypassing
3969+ // durability handling. Reinsert the full consumed union afterward so an unrelated fold cannot
3970+ // make an already-healed class eligible for another renderer transition.
3971+ if transition_newly_consumed
3972+ || (matches!(plan, PassPlan::Hard | PassPlan::MigrateHard)
3973+ && !committed_transition_classes.is_empty())
3974+ {
3975+ preserve_transition_consumed_marker(&mut core, &committed_transition_classes);
3976+ }
39663977 let transition_committed = transition_renderer_active(&core) || transition_newly_consumed;
39673978 if transition_newly_consumed {
3968- let mut committed_transition_classes = consumed_transition_classes.clone();
3969- committed_transition_classes.extend(newly_consumed_transition_classes);
3970- preserve_transition_consumed_marker(&mut core, &committed_transition_classes);
3971-
39723979 // Record consumption after any bust that renders an affected shape. If the shape first
39733980 // appeared during a pass that was already invalidating the cache for another reason, its
39743981 // changed bytes rode that invalidation and replay must not trigger a second one.
@@ -11235,6 +11242,47 @@ pub(crate) mod tests {
1123511242 unit
1123611243 }
1123711244
11245+ fn unstamped_opencode_tool_pair(mid: &str, ordinal: u64, call_id: &str) -> CkIngressMessage {
11246+ let native_message = json!({
11247+ "absolute_ordinal": ordinal,
11248+ "info": { "id": mid, "role": "assistant" },
11249+ "parts": [{
11250+ "type": "tool",
11251+ "tool": "read",
11252+ "callID": call_id,
11253+ "state": {
11254+ "status": "completed",
11255+ "input": { "path": "pair.txt" },
11256+ "output": "historical output"
11257+ }
11258+ }]
11259+ });
11260+ let decoded = crate::codec::decode_opencode(&[native_message]);
11261+ let mut projected = decoded.messages.into_iter().next().unwrap();
11262+ projected.ck.content = projected
11263+ .ck
11264+ .content
11265+ .into_iter()
11266+ .map(|block| ck_wire::CkWireBlock::bare(block.kind))
11267+ .collect();
11268+ projected.ck.mark_modified();
11269+ projected
11270+ }
11271+
11272+ fn stored_transition_marker_classes(
11273+ store: &McStore,
11274+ session_id: &str,
11275+ ) -> BTreeSet<RendererTransitionClass> {
11276+ let loaded = store.load(session_id).unwrap();
11277+ let marker = loaded
11278+ .core
11279+ .frozen_units
11280+ .iter()
11281+ .find(|unit| unit.key == TRANSITION_CONSUMED_KEY)
11282+ .expect("renderer transition marker must be durable");
11283+ serde_json::from_str(&marker.frozen_payload).unwrap()
11284+ }
11285+
1123811286 #[test]
1123911287 fn served_fingerprint_records_cold_start_and_attributes_middle_changes() {
1124011288 let dir = tempfile::tempdir().unwrap();
@@ -23700,6 +23748,145 @@ pub(crate) mod tests {
2370023748 );
2370123749 }
2370223750
23751+ #[test]
23752+ fn consumed_transition_marker_survives_unrelated_hard_and_store_restart() {
23753+ let dir = tempfile::tempdir().unwrap();
23754+ let session = "transition-hard-survivor";
23755+ let messages = vec![
23756+ unstamped_opencode_tool_pair("survivor-pair", 1, "survivor-call"),
23757+ item("survivor-tail", 2, "stable tail"),
23758+ ];
23759+ let initial_store = store(dir.path());
23760+ let request = active_opencode_req(session, "cfg0", messages.clone());
23761+ assert_eq!(run(&initial_store, &request, &spine()).action, "HARD");
23762+ append_historical_frozen_reductions(
23763+ &initial_store,
23764+ session,
23765+ &[
23766+ red_unit("survivor-pair#0", "skeleton", "historical call shell"),
23767+ red_unit("survivor-pair#1", "drop", "historical result shell"),
23768+ ],
23769+ );
23770+
23771+ let transition = run(&initial_store, &request, &spine());
23772+ assert_eq!(transition.action, "HARD");
23773+ assert_eq!(
23774+ transition.materialize_reason.as_deref(),
23775+ Some("renderer_transition")
23776+ );
23777+ let expected = BTreeSet::from([RendererTransitionClass::UnmatchedPair]);
23778+ assert_eq!(
23779+ stored_transition_marker_classes(&initial_store, session),
23780+ expected
23781+ );
23782+
23783+ let unrelated_request = active_opencode_req(session, "cfg1", messages);
23784+ let unrelated = run(&initial_store, &unrelated_request, &spine());
23785+ assert_eq!(unrelated.action, "HARD");
23786+ assert_eq!(
23787+ unrelated.materialize_reason.as_deref(),
23788+ Some("epoch_change")
23789+ );
23790+ assert_eq!(
23791+ stored_transition_marker_classes(&initial_store, session),
23792+ expected
23793+ );
23794+
23795+ drop(initial_store);
23796+ let reopened = store(dir.path());
23797+ assert_eq!(
23798+ stored_transition_marker_classes(&reopened, session),
23799+ expected
23800+ );
23801+ let replay = run(&reopened, &unrelated_request, &spine());
23802+ assert_eq!(replay.action, "SOFT+");
23803+ assert_ne!(
23804+ replay.materialize_reason.as_deref(),
23805+ Some("renderer_transition")
23806+ );
23807+ assert_eq!(
23808+ stored_transition_marker_classes(&reopened, session),
23809+ expected
23810+ );
23811+ }
23812+
23813+ #[test]
23814+ fn consumed_class_does_not_mask_a_new_renderer_transition_class() {
23815+ let dir = tempfile::tempdir().unwrap();
23816+ let store = store(dir.path());
23817+ let session = "transition-new-class";
23818+ let messages = vec![
23819+ unstamped_opencode_tool_pair("new-class-pair", 1, "new-class-unmatched"),
23820+ reasoning_tool_shell_message("new-class-reasoning", 2, "new-class-poisoned", false),
23821+ tool_result(
23822+ "new-class-result",
23823+ 3,
23824+ "new-class-poisoned",
23825+ "historical tool output",
23826+ ),
23827+ item("new-class-tail", 4, "stable tail"),
23828+ ];
23829+ let request = active_opencode_req(session, "cfg0", messages);
23830+ assert_eq!(run(&store, &request, &spine()).action, "HARD");
23831+ append_historical_frozen_reductions(
23832+ &store,
23833+ session,
23834+ &[
23835+ red_unit("new-class-pair#0", "skeleton", "historical call shell"),
23836+ red_unit("new-class-pair#1", "drop", "historical result shell"),
23837+ ],
23838+ );
23839+
23840+ let unmatched_transition = run(&store, &request, &spine());
23841+ assert_eq!(unmatched_transition.action, "HARD");
23842+ assert_eq!(
23843+ unmatched_transition.materialize_reason.as_deref(),
23844+ Some("renderer_transition")
23845+ );
23846+ let unmatched_only = BTreeSet::from([RendererTransitionClass::UnmatchedPair]);
23847+ assert_eq!(
23848+ stored_transition_marker_classes(&store, session),
23849+ unmatched_only,
23850+ "classes without consumed shapes must not be pre-recorded"
23851+ );
23852+
23853+ append_historical_frozen_reductions(
23854+ &store,
23855+ session,
23856+ &[
23857+ red_unit(
23858+ "new-class-reasoning#1",
23859+ "skeleton",
23860+ "historical reasoning call shell",
23861+ ),
23862+ red_unit(
23863+ "new-class-result#0",
23864+ "drop",
23865+ "historical reasoning result shell",
23866+ ),
23867+ ],
23868+ );
23869+ let new_class_transition = run(&store, &request, &spine());
23870+ assert_eq!(new_class_transition.action, "HARD");
23871+ assert_eq!(
23872+ new_class_transition.materialize_reason.as_deref(),
23873+ Some("renderer_transition")
23874+ );
23875+ let expected = BTreeSet::from([
23876+ RendererTransitionClass::PoisonedReasoning,
23877+ RendererTransitionClass::UnmatchedPair,
23878+ ]);
23879+ assert_eq!(stored_transition_marker_classes(&store, session), expected);
23880+
23881+ let replay = run(&store, &request, &spine());
23882+ assert_eq!(replay.action, "SOFT+");
23883+ assert_ne!(
23884+ replay.materialize_reason.as_deref(),
23885+ Some("renderer_transition")
23886+ );
23887+ assert_eq!(stored_transition_marker_classes(&store, session), expected);
23888+ }
23889+
2370323890 #[test]
2370423891 fn unmatched_native_pair_is_unique_before_transition_and_replays_after_salted_bust() {
2370523892 let native_message = json!({
0 commit comments