Skip to content

Commit fa036d3

Browse files
authored
[2/3] core: persist world state in rollouts (#29835)
## Why `WorldState` currently remembers its model-visible diff baseline only in memory. That leaves no durable source for restoring the exact baseline after resume, fork, rollback, or compaction. This is the second PR in the WorldState persistence stack, built on #29833 and following #29249. It records durable state transitions; the next PR will replay them during rollout reconstruction. ## What - Add a `world_state` rollout item containing either a full snapshot or an RFC 7386 JSON Merge Patch. - Persist a full snapshot after initial context and after compaction establishes a new context window. - Persist non-empty patches when later sampling steps or turns advance the WorldState baseline. - Write model-visible history before its matching WorldState record, so an interrupted write can only cause a safe repeated update on replay. - Preserve WorldState records for full-history forks while excluding them from thread previews, metadata, and app-server history materialization. Older binaries read rollout lines independently, so they skip the unknown `world_state` records while retaining the rest of the thread. ## Testing - `just test -p codex-core snapshot_merge_patch_changes_and_removes_nested_values` - `just test -p codex-core world_state_baseline_deduplicates_until_history_is_replaced` - `just test -p codex-core deferred_executor_compaction_preserves_then_updates_environment_once` - `just test -p codex-protocol` - `just test -p codex-rollout` - `just test -p codex-state` - `just test -p codex-thread-store` - `just test -p codex-app-server-protocol`
1 parent 6db9372 commit fa036d3

22 files changed

Lines changed: 258 additions & 32 deletions

File tree

codex-rs/app-server-protocol/src/protocol/thread_history.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ impl ThreadHistoryBuilder {
388388
RolloutItem::InterAgentCommunication(_)
389389
| RolloutItem::InterAgentCommunicationMetadata { .. }
390390
| RolloutItem::TurnContext(_)
391+
| RolloutItem::WorldState(_)
391392
| RolloutItem::SessionMeta(_) => {}
392393
}
393394
}

codex-rs/core/src/agent/control/spawn.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item:
6363
// Full-history forks preserve the cached prompt prefix and can keep diffing
6464
// from the parent's durable baseline. Truncated forks drop part of that prompt,
6565
// so they must rebuild context on their first child turn.
66-
RolloutItem::TurnContext(_) => preserve_reference_context_item,
66+
RolloutItem::TurnContext(_) | RolloutItem::WorldState(_) => preserve_reference_context_item,
6767
RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true,
6868
}
6969
}

codex-rs/core/src/agent/control_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ async fn persisted_originator(thread: &CodexThread) -> String {
169169
| RolloutItem::InterAgentCommunicationMetadata { .. }
170170
| RolloutItem::EventMsg(_)
171171
| RolloutItem::Compacted(_)
172+
| RolloutItem::WorldState(_)
172173
| RolloutItem::TurnContext(_) => None,
173174
})
174175
.expect("session metadata should be persisted")

codex-rs/core/src/context/world_state/mod.rs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::context::ContextualUserFragment;
44
use indexmap::IndexMap;
55
use serde::Serialize;
66
use serde::de::DeserializeOwned;
7+
use serde_json::Map;
78
use serde_json::Value;
89
use std::collections::BTreeMap;
910
use std::fmt;
@@ -30,6 +31,13 @@ impl<S: WorldStateSection> ErasedWorldStateSection for S {
3031
}
3132
};
3233
remove_null_object_fields(&mut snapshot);
34+
if snapshot.is_null() {
35+
tracing::error!(
36+
section_id = S::ID,
37+
"world-state section snapshot cannot be null"
38+
);
39+
return None;
40+
}
3341
Some(snapshot)
3442
}
3543

@@ -54,7 +62,8 @@ impl<S: WorldStateSection> ErasedWorldStateSection for S {
5462
/// Implementations own how their current state is rendered relative to an
5563
/// earlier snapshot of the same section. `ID` is persisted in rollouts and
5664
/// must remain stable. `Snapshot` should contain only the comparison data
57-
/// needed to decide what the model must be told next.
65+
/// needed to decide what the model must be told next, and must not serialize
66+
/// to null because merge-patch nulls represent deletion.
5867
pub(crate) trait WorldStateSection: Send + Sync + 'static {
5968
const ID: &'static str;
6069
type Snapshot: DeserializeOwned + Serialize;
@@ -80,6 +89,19 @@ pub(crate) struct WorldStateSnapshot {
8089
sections: BTreeMap<String, Value>,
8190
}
8291

92+
impl WorldStateSnapshot {
93+
pub(crate) fn into_value(self) -> Value {
94+
Value::Object(self.sections.into_iter().collect())
95+
}
96+
97+
/// Returns the RFC 7386 merge patch that advances `previous` to `self`.
98+
pub(crate) fn merge_patch_from(&self, previous: &Self) -> Option<Value> {
99+
let previous = Value::Object(previous.sections.clone().into_iter().collect());
100+
let current = Value::Object(self.sections.clone().into_iter().collect());
101+
create_merge_patch(&previous, &current)
102+
}
103+
}
104+
83105
impl fmt::Debug for WorldState {
84106
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85107
f.debug_struct("WorldState")
@@ -139,6 +161,38 @@ fn remove_null_object_fields(value: &mut Value) {
139161
}
140162
}
141163

164+
fn create_merge_patch(previous: &Value, current: &Value) -> Option<Value> {
165+
if previous == current {
166+
return None;
167+
}
168+
169+
let Value::Object(current) = current else {
170+
return Some(current.clone());
171+
};
172+
let previous = previous.as_object();
173+
let mut patch = Map::new();
174+
175+
if let Some(previous) = previous {
176+
for key in previous.keys() {
177+
if !current.contains_key(key) {
178+
patch.insert(key.clone(), Value::Null);
179+
}
180+
}
181+
}
182+
183+
for (key, current_value) in current {
184+
let Some(previous_value) = previous.and_then(|previous| previous.get(key)) else {
185+
patch.insert(key.clone(), current_value.clone());
186+
continue;
187+
};
188+
if let Some(value_patch) = create_merge_patch(previous_value, current_value) {
189+
patch.insert(key.clone(), value_patch);
190+
}
191+
}
192+
193+
Some(Value::Object(patch))
194+
}
195+
142196
#[cfg(test)]
143197
#[path = "world_state_tests.rs"]
144198
mod tests;

codex-rs/core/src/context/world_state/world_state_tests.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,31 @@ fn duplicate_section_ids_are_rejected() {
118118

119119
world_state.add_section(DuplicateTestSection);
120120
}
121+
122+
#[test]
123+
fn snapshot_merge_patch_changes_and_removes_nested_values() {
124+
let previous = WorldStateSnapshot {
125+
sections: BTreeMap::from([
126+
(
127+
"kept".to_string(),
128+
json!({"same": true, "changed": "before", "removed": true}),
129+
),
130+
("removed_section".to_string(), json!({"value": true})),
131+
]),
132+
};
133+
let current = WorldStateSnapshot {
134+
sections: BTreeMap::from([(
135+
"kept".to_string(),
136+
json!({"same": true, "changed": "after"}),
137+
)]),
138+
};
139+
140+
assert_eq!(
141+
current.merge_patch_from(&previous),
142+
Some(json!({
143+
"kept": {"changed": "after", "removed": null},
144+
"removed_section": null,
145+
}))
146+
);
147+
assert_eq!(current.merge_patch_from(&current), None);
148+
}

codex-rs/core/src/context_manager/history.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use codex_protocol::protocol::InterAgentCommunication;
2020
use codex_protocol::protocol::TokenUsage;
2121
use codex_protocol::protocol::TokenUsageInfo;
2222
use codex_protocol::protocol::TurnContextItem;
23+
use codex_protocol::protocol::WorldStateItem;
2324
use codex_utils_cache::BlockingLruCache;
2425
use codex_utils_cache::sha1_digest;
2526
use codex_utils_output_truncation::TruncationPolicy;
@@ -87,13 +88,26 @@ impl ContextManager {
8788
pub(crate) fn update_world_state(
8889
&mut self,
8990
world_state: &WorldState,
90-
) -> Vec<Box<dyn ContextualUserFragment>> {
91-
let fragments = self.world_state_baseline.as_ref().map_or_else(
92-
|| world_state.render_full(),
93-
|previous| world_state.render_diff(previous),
91+
) -> (Vec<Box<dyn ContextualUserFragment>>, Option<WorldStateItem>) {
92+
let snapshot = world_state.snapshot();
93+
let (fragments, rollout_item) = self.world_state_baseline.as_ref().map_or_else(
94+
|| {
95+
(
96+
world_state.render_full(),
97+
Some(WorldStateItem::full(snapshot.clone().into_value())),
98+
)
99+
},
100+
|previous| {
101+
(
102+
world_state.render_diff(previous),
103+
snapshot
104+
.merge_patch_from(previous)
105+
.map(WorldStateItem::patch),
106+
)
107+
},
94108
);
95-
self.world_state_baseline = Some(world_state.snapshot());
96-
fragments
109+
self.world_state_baseline = Some(snapshot);
110+
(fragments, rollout_item)
97111
}
98112

99113
pub(crate) fn set_world_state_baseline(&mut self, snapshot: WorldStateSnapshot) {

codex-rs/core/src/context_manager/history_tests.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,19 @@ fn world_state_baseline_deduplicates_until_history_is_replaced() {
8686
};
8787
let mut history = ContextManager::new();
8888

89-
assert_eq!(1, history.update_world_state(&world_state()).len());
90-
assert!(history.update_world_state(&world_state()).is_empty());
89+
let (initial_fragments, initial_item) = history.update_world_state(&world_state());
90+
assert_eq!(1, initial_fragments.len());
91+
assert!(initial_item.is_some_and(|item| item.full));
92+
93+
let (unchanged_fragments, unchanged_item) = history.update_world_state(&world_state());
94+
assert!(unchanged_fragments.is_empty());
95+
assert_eq!(unchanged_item, None);
9196

9297
history.replace(Vec::new());
9398

94-
assert_eq!(1, history.update_world_state(&world_state()).len());
99+
let (replacement_fragments, replacement_item) = history.update_world_state(&world_state());
100+
assert_eq!(1, replacement_fragments.len());
101+
assert!(replacement_item.is_some_and(|item| item.full));
95102
}
96103

97104
fn user_msg(text: &str) -> ResponseItem {

codex-rs/core/src/session/mod.rs

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ use codex_protocol::protocol::TurnContextNetworkItem;
131131
use codex_protocol::protocol::TurnEnvironmentSelection;
132132
use codex_protocol::protocol::TurnEnvironmentSelections;
133133
use codex_protocol::protocol::W3cTraceContext;
134+
use codex_protocol::protocol::WorldStateItem;
134135
use codex_protocol::request_permissions::PermissionGrantScope;
135136
use codex_protocol::request_permissions::RequestPermissionProfile;
136137
use codex_protocol::request_permissions::RequestPermissionsArgs;
@@ -2794,8 +2795,14 @@ impl Session {
27942795
self.build_world_state_for_environments(turn_context, &step_context.environments)
27952796
.await,
27962797
);
2798+
// Derive the model update and persisted patch from the same two snapshots.
2799+
let previous_snapshot = previous_world_state.snapshot();
2800+
let world_state_snapshot = world_state.snapshot();
2801+
let world_state_item = world_state_snapshot
2802+
.merge_patch_from(&previous_snapshot)
2803+
.map(WorldStateItem::patch);
27972804
let items = crate::context_manager::updates::merge_contextual_fragments(
2798-
world_state.render_diff(&previous_world_state.snapshot()),
2805+
world_state.render_diff(&previous_snapshot),
27992806
);
28002807
if !items.is_empty() {
28012808
self.record_conversation_items(turn_context, &items).await;
@@ -2806,7 +2813,12 @@ impl Session {
28062813
.lock()
28072814
.await
28082815
.history
2809-
.set_world_state_baseline(world_state.snapshot());
2816+
.set_world_state_baseline(world_state_snapshot);
2817+
// Record the patch after the context it describes is present in model history.
2818+
if let Some(world_state_item) = world_state_item {
2819+
self.persist_rollout_items(&[RolloutItem::WorldState(world_state_item)])
2820+
.await;
2821+
}
28102822
world_state
28112823
}
28122824

@@ -2944,18 +2956,25 @@ impl Session {
29442956
replacement_history: Some(items.clone()),
29452957
..compacted_item
29462958
};
2959+
// Compaction starts a new history window, so its WorldState baseline must be full.
2960+
let mut world_state_item = None;
29472961
{
29482962
let mut state = self.state.lock().await;
29492963
state.replace_history(items, reference_context_item.clone());
29502964
if let Some(world_state) = world_state_baseline {
2951-
state
2952-
.history
2953-
.set_world_state_baseline(world_state.snapshot());
2965+
let snapshot = world_state.snapshot();
2966+
world_state_item = Some(WorldStateItem::full(snapshot.clone().into_value()));
2967+
state.history.set_world_state_baseline(snapshot);
29542968
}
29552969
}
29562970

29572971
self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)])
29582972
.await;
2973+
// Persist the baseline after the replacement history that established it.
2974+
if let Some(world_state_item) = world_state_item {
2975+
self.persist_rollout_items(&[RolloutItem::WorldState(world_state_item)])
2976+
.await;
2977+
}
29592978
if let Some(turn_context_item) = reference_context_item {
29602979
self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item)])
29612980
.await;
@@ -3525,44 +3544,63 @@ impl Session {
35253544
self.build_world_state_for_environments(turn_context, &turn_context.environments)
35263545
.await,
35273546
);
3528-
let mut context_items = if should_inject_full_context {
3547+
// Full initial context resets the baseline; later turns persist only its changes.
3548+
let (mut context_items, world_state_item) = if should_inject_full_context {
35293549
let context_items = self
35303550
.build_initial_context_with_world_state(turn_context, world_state.as_ref())
35313551
.await;
3552+
let snapshot = world_state.snapshot();
35323553
self.state
35333554
.lock()
35343555
.await
35353556
.history
3536-
.set_world_state_baseline(world_state.snapshot());
3537-
context_items
3557+
.set_world_state_baseline(snapshot.clone());
3558+
(
3559+
context_items,
3560+
Some(WorldStateItem::full(snapshot.into_value())),
3561+
)
35383562
} else {
35393563
// Steady-state path: append only built-in context diffs here; turn-scoped extension
35403564
// context is added below.
35413565
let mut context_items = self
35423566
.build_settings_update_items(reference_context_item.as_ref(), turn_context)
35433567
.await;
3544-
let world_state_items = {
3568+
let (world_state_items, world_state_item) = {
35453569
let mut state = self.state.lock().await;
3546-
crate::context_manager::updates::merge_contextual_fragments(
3547-
state.history.update_world_state(world_state.as_ref()),
3570+
let (fragments, rollout_item) =
3571+
state.history.update_world_state(world_state.as_ref());
3572+
(
3573+
crate::context_manager::updates::merge_contextual_fragments(fragments),
3574+
rollout_item,
35483575
)
35493576
};
35503577
context_items.extend(world_state_items);
3551-
context_items
3578+
(context_items, world_state_item)
35523579
};
35533580
if !should_inject_full_context && turn_context_changed {
35543581
context_items.extend(
35553582
self.build_turn_context_contribution_items(turn_context)
35563583
.await,
35573584
);
35583585
}
3559-
if !turn_context_changed && context_items.is_empty() {
3586+
// A snapshot can change without producing model-visible or TurnContext updates.
3587+
let only_world_state_changed = !turn_context_changed && context_items.is_empty();
3588+
if only_world_state_changed && world_state_item.is_none() {
35603589
return world_state;
35613590
}
35623591
if !context_items.is_empty() {
35633592
self.record_conversation_items(turn_context, &context_items)
35643593
.await;
35653594
}
3595+
// Persist state only after any model-visible context generated from it.
3596+
if let Some(world_state_item) = world_state_item {
3597+
self.persist_rollout_items(&[RolloutItem::WorldState(world_state_item)])
3598+
.await;
3599+
}
3600+
// A snapshot-only change does not require a duplicate TurnContext record.
3601+
if only_world_state_changed {
3602+
return world_state;
3603+
}
35663604
// Persist one `TurnContextItem` per real user turn so resume/lazy replay can recover the
35673605
// latest durable baseline even when this turn emitted no model-visible context diffs.
35683606
self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item.clone())])

codex-rs/core/src/session/rollout_reconstruction.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,10 @@ impl Session {
264264
active_segment.get_or_insert_with(ActiveReplaySegment::default);
265265
active_segment.counts_as_user_turn = true;
266266
}
267-
RolloutItem::InterAgentCommunicationMetadata { .. } => {}
268-
RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => {}
267+
RolloutItem::EventMsg(_)
268+
| RolloutItem::SessionMeta(_)
269+
| RolloutItem::InterAgentCommunicationMetadata { .. }
270+
| RolloutItem::WorldState(_) => {}
269271
}
270272

271273
if base_replacement_history.is_some()
@@ -351,6 +353,7 @@ impl Session {
351353
}
352354
RolloutItem::EventMsg(_)
353355
| RolloutItem::TurnContext(_)
356+
| RolloutItem::WorldState(_)
354357
| RolloutItem::SessionMeta(_) => {}
355358
}
356359
}

codex-rs/core/src/session/tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2744,6 +2744,7 @@ async fn start_new_context_window_assigns_and_persists_item_ids() {
27442744
| RolloutItem::InterAgentCommunication(_)
27452745
| RolloutItem::InterAgentCommunicationMetadata { .. }
27462746
| RolloutItem::TurnContext(_)
2747+
| RolloutItem::WorldState(_)
27472748
| RolloutItem::EventMsg(_) => None,
27482749
});
27492750
assert_eq!(
@@ -2802,6 +2803,7 @@ async fn record_initial_history_assigns_and_persists_id_for_forked_response_item
28022803
| RolloutItem::InterAgentCommunicationMetadata { .. }
28032804
| RolloutItem::Compacted(_)
28042805
| RolloutItem::TurnContext(_)
2806+
| RolloutItem::WorldState(_)
28052807
| RolloutItem::EventMsg(_) => None,
28062808
});
28072809
assert_eq!(persisted_item_id, Some(live_item_id.as_str()));

0 commit comments

Comments
 (0)