@team-agent/installer 0.5.59 → 0.5.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,8 @@ pub(crate) fn materialize_claude_fork(
30
30
  source_path: &Path,
31
31
  source_session_id: &SessionId,
32
32
  target_session_id: &SessionId,
33
+ source_agent_id: &str,
34
+ target_agent_id: &str,
33
35
  ) -> Result<ClaudeForkMaterialization, ProviderError> {
34
36
  let parent = source_path.parent().ok_or_else(|| {
35
37
  ProviderError::Io(format!(
@@ -46,7 +48,12 @@ pub(crate) fn materialize_claude_fork(
46
48
  }
47
49
  let source = std::fs::read_to_string(source_path)
48
50
  .map_err(|error| ProviderError::Io(error.to_string()))?;
49
- let rewritten = source.replace(source_session_id.as_str(), target_session_id.as_str());
51
+ let rewritten = source
52
+ .replace(source_session_id.as_str(), target_session_id.as_str())
53
+ .replace(
54
+ &format!("You are Team Agent worker `{source_agent_id}`"),
55
+ &format!("You are Team Agent worker `{target_agent_id}`"),
56
+ );
50
57
  validate_jsonl(&rewritten)?;
51
58
  let temp = parent.join(format!(
52
59
  ".{}.tmp-{}",
@@ -108,7 +115,8 @@ mod tests {
108
115
  std::fs::write(&source_path, &source).unwrap();
109
116
 
110
117
  let mut materialized =
111
- materialize_claude_fork(&source_path, &source_id, &target_id).unwrap();
118
+ materialize_claude_fork(&source_path, &source_id, &target_id, "source", "target")
119
+ .unwrap();
112
120
  let target_path = dir.join(format!("{}.jsonl", target_id.as_str()));
113
121
  let target = std::fs::read_to_string(&target_path).unwrap();
114
122
  assert!(!target.contains(source_id.as_str()));
@@ -29,6 +29,7 @@ pub struct CapturePassReport {
29
29
  /// (coordinator tick) emits a throttled
30
30
  /// `provider.session.transcript_missing` event for each entry.
31
31
  pub transcript_missing: Vec<TranscriptMissing>,
32
+ pub(crate) context_forks: Vec<crate::lifecycle::launch::ContextForkFinalized>,
32
33
  }
33
34
 
34
35
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -306,9 +307,12 @@ where
306
307
  // partial candidates (no session_id or no rollout_path). When
307
308
  // it returns false, the row stays unchanged; capture re-runs
308
309
  // on the next coordinator tick.
309
- if apply_captured_session(agent_obj, &candidate.captured) {
310
+ if let Some(applied) = apply_captured_session(agent_obj, &candidate.captured) {
310
311
  report.changed = true;
311
312
  report.assigned.push(item.agent_id);
313
+ if let AppliedCapture::ContextFork(context_fork) = applied {
314
+ report.context_forks.push(context_fork);
315
+ }
312
316
  }
313
317
  continue;
314
318
  }
@@ -676,6 +680,17 @@ where
676
680
  .and_then(Value::as_str)
677
681
  .filter(|value| !value.is_empty())
678
682
  .map(str::to_string);
683
+ let expected_session_id = agent
684
+ .get("_pending_session_id")
685
+ .and_then(Value::as_str)
686
+ .filter(|value| !value.is_empty())
687
+ .map(SessionId::new);
688
+ if matches!(provider, Provider::Codex)
689
+ && agent.get("capture_state").and_then(Value::as_str) == Some("pending_context_fork")
690
+ && expected_session_id.is_none()
691
+ {
692
+ return None;
693
+ }
679
694
  Some(PendingSessionCapture {
680
695
  agent_id: agent_id.to_string(),
681
696
  provider,
@@ -699,15 +714,7 @@ where
699
714
  .and_then(Value::as_u64)
700
715
  .and_then(|pid| u32::try_from(pid).ok()),
701
716
  spawned_at,
702
- expected_session_id: if matches!(provider, Provider::Codex) {
703
- None
704
- } else {
705
- agent
706
- .get("_pending_session_id")
707
- .and_then(Value::as_str)
708
- .filter(|value| !value.is_empty())
709
- .map(SessionId::new)
710
- },
717
+ expected_session_id,
711
718
  provider_projects_root: agent
712
719
  .get("claude_projects_root")
713
720
  .and_then(Value::as_str)
@@ -1144,6 +1151,11 @@ fn candidate_key(owner: &PendingSessionCapture, candidate: &CapturedSessionCandi
1144
1151
  .join("|")
1145
1152
  }
1146
1153
 
1154
+ enum AppliedCapture {
1155
+ Session,
1156
+ ContextFork(crate::lifecycle::launch::ContextForkFinalized),
1157
+ }
1158
+
1147
1159
  /// 0.4.6 tuple-atomic contract (audit §Capture 修改清单, line 111): writes
1148
1160
  /// the authoritative session tuple if and only if the candidate carries
1149
1161
  /// BOTH `session_id` and `rollout_path`. A partial candidate (e.g.
@@ -1154,15 +1166,16 @@ fn candidate_key(owner: &PendingSessionCapture, candidate: &CapturedSessionCandi
1154
1166
  fn apply_captured_session(
1155
1167
  agent_obj: &mut serde_json::Map<String, Value>,
1156
1168
  captured: &CapturedSession,
1157
- ) -> bool {
1169
+ ) -> Option<AppliedCapture> {
1158
1170
  if agent_obj.get("capture_state").and_then(Value::as_str) == Some("pending_context_fork") {
1159
- return crate::lifecycle::finalize_pending_fork_capture(agent_obj, captured);
1171
+ return crate::lifecycle::finalize_pending_fork_capture(agent_obj, captured)
1172
+ .map(AppliedCapture::ContextFork);
1160
1173
  }
1161
1174
  let Some(session_id) = captured.session_id.as_ref() else {
1162
- return false;
1175
+ return None;
1163
1176
  };
1164
1177
  let Some(rollout_path) = captured.rollout_path.as_ref() else {
1165
- return false;
1178
+ return None;
1166
1179
  };
1167
1180
  agent_obj.insert(
1168
1181
  "session_id".to_string(),
@@ -1192,7 +1205,7 @@ fn apply_captured_session(
1192
1205
  // diagnose/status observability.
1193
1206
  agent_obj.remove("_pending_session_id");
1194
1207
  agent_obj.insert("capture_state".to_string(), serde_json::json!("captured"));
1195
- true
1208
+ Some(AppliedCapture::Session)
1196
1209
  }
1197
1210
 
1198
1211
  fn claimed_provider_session_keys(
@@ -2621,7 +2634,7 @@ mod u1_tests {
2621
2634
  spawn_cwd: PathBuf::from("/tmp/cwd"),
2622
2635
  };
2623
2636
  let written = super::apply_captured_session(&mut agent, &captured);
2624
- assert!(written, "apply must return true for valid captured");
2637
+ assert!(written.is_some(), "apply must accept valid captured");
2625
2638
  assert!(
2626
2639
  agent.get("_pending_session_id").is_none(),
2627
2640
  "S1-CAPTURE-001: _pending_session_id must be removed after capture \
@@ -1,14 +1,319 @@
1
1
  use super::*;
2
+ use std::collections::BTreeSet;
3
+ use std::fs::{File, OpenOptions};
4
+ use std::io::{Read, Write};
5
+
6
+ #[derive(Debug)]
7
+ pub(crate) struct CodexForkMaterialization {
8
+ path: PathBuf,
9
+ keep: bool,
10
+ }
11
+
12
+ impl CodexForkMaterialization {
13
+ pub(crate) fn path(&self) -> &Path {
14
+ &self.path
15
+ }
16
+
17
+ fn mark_handoff(&mut self) {
18
+ self.keep = true;
19
+ }
20
+ }
21
+
22
+ impl crate::provider::adapter::ForkBackingMaterialization for CodexForkMaterialization {
23
+ fn path(&self) -> &Path {
24
+ self.path()
25
+ }
26
+
27
+ fn handoff(&mut self) {
28
+ self.mark_handoff();
29
+ }
30
+ }
31
+
32
+ impl Drop for CodexForkMaterialization {
33
+ fn drop(&mut self) {
34
+ if !self.keep {
35
+ let _ = std::fs::remove_file(&self.path);
36
+ }
37
+ }
38
+ }
39
+
40
+ pub(crate) fn materialize_codex_fork(
41
+ source_path: &Path,
42
+ source_session_id: &SessionId,
43
+ source_agent_id: &str,
44
+ target_agent_id: &str,
45
+ plan: &mut CommandPlan,
46
+ ) -> Result<CodexForkMaterialization, ProviderError> {
47
+ let target_session_id = SessionId::new(codex_session_v7());
48
+ let parent = source_path.parent().ok_or_else(|| {
49
+ ProviderError::Io(format!(
50
+ "codex source backing has no parent: {}",
51
+ source_path.display()
52
+ ))
53
+ })?;
54
+ let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S");
55
+ let target_path = parent.join(format!(
56
+ "rollout-{timestamp}-{}.jsonl",
57
+ target_session_id.as_str()
58
+ ));
59
+ let source = complete_source_snapshot(source_path)?;
60
+ let rewritten = rewrite_snapshot(
61
+ &source,
62
+ source_session_id,
63
+ &target_session_id,
64
+ source_agent_id,
65
+ target_agent_id,
66
+ )?;
67
+ publish_target_no_clobber(&target_path, rewritten.as_bytes())?;
68
+ if let Err(error) =
69
+ validate_materialized_target(&target_path, &target_session_id, target_agent_id)
70
+ {
71
+ let _ = std::fs::remove_file(&target_path);
72
+ return Err(error);
73
+ }
74
+ apply_resume_target(plan, source_session_id, &target_session_id)?;
75
+ Ok(CodexForkMaterialization {
76
+ path: target_path,
77
+ keep: false,
78
+ })
79
+ }
80
+
81
+ fn complete_source_snapshot(path: &Path) -> Result<String, ProviderError> {
82
+ let file = File::open(path).map_err(|error| ProviderError::Io(error.to_string()))?;
83
+ let visible_len = file
84
+ .metadata()
85
+ .map_err(|error| ProviderError::Io(error.to_string()))?
86
+ .len();
87
+ let mut bytes = Vec::new();
88
+ file.take(visible_len)
89
+ .read_to_end(&mut bytes)
90
+ .map_err(|error| ProviderError::Io(error.to_string()))?;
91
+ let Some(boundary) = bytes.iter().rposition(|byte| *byte == b'\n') else {
92
+ return Err(ProviderError::Io(
93
+ "codex fork source has no complete JSONL record".to_string(),
94
+ ));
95
+ };
96
+ bytes.truncate(boundary + 1);
97
+ String::from_utf8(bytes).map_err(|error| ProviderError::Io(error.to_string()))
98
+ }
99
+
100
+ fn rewrite_snapshot(
101
+ source: &str,
102
+ source_session_id: &SessionId,
103
+ target_session_id: &SessionId,
104
+ source_agent_id: &str,
105
+ target_agent_id: &str,
106
+ ) -> Result<String, ProviderError> {
107
+ let source_marker = format!("You are Team Agent worker `{source_agent_id}`");
108
+ let target_marker = format!("You are Team Agent worker `{target_agent_id}`");
109
+ let mut meta_count = 0_usize;
110
+ let mut marker_count = 0_usize;
111
+ let mut output = String::new();
112
+ for (index, line) in source.lines().enumerate() {
113
+ if line.trim().is_empty() {
114
+ continue;
115
+ }
116
+ let mut record = serde_json::from_str::<serde_json::Value>(line).map_err(|error| {
117
+ ProviderError::Io(format!(
118
+ "codex fork source has invalid JSONL at line {}: {error}",
119
+ index + 1
120
+ ))
121
+ })?;
122
+ if record.get("type").and_then(serde_json::Value::as_str) == Some("session_meta") {
123
+ let id = record
124
+ .get_mut("payload")
125
+ .and_then(serde_json::Value::as_object_mut)
126
+ .and_then(|payload| payload.get_mut("id"))
127
+ .and_then(|id| id.as_str())
128
+ .ok_or_else(|| {
129
+ ProviderError::Io(
130
+ "codex fork session_meta has no string payload.id".to_string(),
131
+ )
132
+ })?;
133
+ if id != source_session_id.as_str() {
134
+ return Err(ProviderError::Io(
135
+ "codex fork session_meta id does not match source".to_string(),
136
+ ));
137
+ }
138
+ record["payload"]["id"] =
139
+ serde_json::Value::String(target_session_id.as_str().to_string());
140
+ meta_count += 1;
141
+ }
142
+ marker_count += replace_exact_marker(&mut record, &source_marker, &target_marker);
143
+ output.push_str(
144
+ &serde_json::to_string(&record)
145
+ .map_err(|error| ProviderError::Io(error.to_string()))?,
146
+ );
147
+ output.push('\n');
148
+ }
149
+ if meta_count != 1 || marker_count != 1 {
150
+ return Err(ProviderError::Io(format!(
151
+ "codex fork source identity is ambiguous: session_meta={meta_count}, marker={marker_count}"
152
+ )));
153
+ }
154
+ Ok(output)
155
+ }
156
+
157
+ fn replace_exact_marker(value: &mut serde_json::Value, source: &str, target: &str) -> usize {
158
+ match value {
159
+ serde_json::Value::String(text) => {
160
+ let count = text.matches(source).count();
161
+ if count == 1 {
162
+ *text = text.replacen(source, target, 1);
163
+ }
164
+ count
165
+ }
166
+ serde_json::Value::Array(items) => items
167
+ .iter_mut()
168
+ .map(|item| replace_exact_marker(item, source, target))
169
+ .sum(),
170
+ serde_json::Value::Object(fields) => fields
171
+ .values_mut()
172
+ .map(|item| replace_exact_marker(item, source, target))
173
+ .sum(),
174
+ _ => 0,
175
+ }
176
+ }
177
+
178
+ fn publish_target_no_clobber(path: &Path, bytes: &[u8]) -> Result<(), ProviderError> {
179
+ let parent = path.parent().ok_or_else(|| {
180
+ ProviderError::Io(format!("codex target has no parent: {}", path.display()))
181
+ })?;
182
+ let temp = parent.join(format!(
183
+ ".{}.tmp-{}",
184
+ path.file_name()
185
+ .and_then(|name| name.to_str())
186
+ .unwrap_or("codex-fork"),
187
+ std::process::id()
188
+ ));
189
+ let result = (|| -> std::io::Result<()> {
190
+ let mut file = OpenOptions::new()
191
+ .write(true)
192
+ .create_new(true)
193
+ .open(&temp)?;
194
+ file.write_all(bytes)?;
195
+ file.sync_all()?;
196
+ std::fs::hard_link(&temp, path)?;
197
+ File::open(parent)?.sync_all()?;
198
+ std::fs::remove_file(&temp)?;
199
+ Ok(())
200
+ })();
201
+ if let Err(error) = result {
202
+ let _ = std::fs::remove_file(&temp);
203
+ return Err(ProviderError::Io(error.to_string()));
204
+ }
205
+ Ok(())
206
+ }
207
+
208
+ fn validate_materialized_target(
209
+ path: &Path,
210
+ session_id: &SessionId,
211
+ target_agent_id: &str,
212
+ ) -> Result<(), ProviderError> {
213
+ let text =
214
+ std::fs::read_to_string(path).map_err(|error| ProviderError::Io(error.to_string()))?;
215
+ let actual = session_id_from_jsonl(path);
216
+ let marker = format!("You are Team Agent worker `{target_agent_id}`");
217
+ if actual.as_deref() != Some(session_id.as_str())
218
+ || !path
219
+ .file_stem()
220
+ .and_then(|name| name.to_str())
221
+ .is_some_and(|name| name.ends_with(session_id.as_str()))
222
+ || text.matches(&marker).count() != 1
223
+ {
224
+ return Err(ProviderError::Io(
225
+ "codex fork target read-back identity mismatch".to_string(),
226
+ ));
227
+ }
228
+ Ok(())
229
+ }
230
+
231
+ fn apply_resume_target(
232
+ plan: &mut CommandPlan,
233
+ source_session_id: &SessionId,
234
+ target_session_id: &SessionId,
235
+ ) -> Result<(), ProviderError> {
236
+ if plan.argv.first().map(String::as_str) != Some("codex")
237
+ || plan.argv.get(1).map(String::as_str) != Some("fork")
238
+ || plan.argv.last().map(String::as_str) != Some(source_session_id.as_str())
239
+ {
240
+ return Err(ProviderError::Command(
241
+ "codex fork command shape cannot be converted to exact resume".to_string(),
242
+ ));
243
+ }
244
+ plan.argv[1] = "resume".to_string();
245
+ *plan.argv.last_mut().expect("validated non-empty argv") =
246
+ target_session_id.as_str().to_string();
247
+ plan.expected_session_id = Some(target_session_id.clone());
248
+ Ok(())
249
+ }
250
+
251
+ fn codex_session_v7() -> String {
252
+ use sha2::Digest;
253
+
254
+ static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
255
+
256
+ let millis = std::time::SystemTime::now()
257
+ .duration_since(std::time::UNIX_EPOCH)
258
+ .map_or(0, |duration| duration.as_millis() as u64);
259
+ let mut hasher = sha2::Sha256::new();
260
+ hasher.update(millis.to_le_bytes());
261
+ hasher.update(std::process::id().to_le_bytes());
262
+ hasher.update(
263
+ COUNTER
264
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
265
+ .to_le_bytes(),
266
+ );
267
+ let digest = hasher.finalize();
268
+ let mut bytes = [0_u8; 16];
269
+ bytes[..6].copy_from_slice(&millis.to_be_bytes()[2..]);
270
+ bytes[6..].copy_from_slice(&digest[..10]);
271
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
272
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
273
+ format!(
274
+ "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
275
+ u32::from_be_bytes(bytes[0..4].try_into().expect("fixed slice")),
276
+ u16::from_be_bytes(bytes[4..6].try_into().expect("fixed slice")),
277
+ u16::from_be_bytes(bytes[6..8].try_into().expect("fixed slice")),
278
+ u16::from_be_bytes(bytes[8..10].try_into().expect("fixed slice")),
279
+ u64::from_be_bytes([
280
+ 0, 0, bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
281
+ ])
282
+ )
283
+ }
2
284
 
3
285
  pub(super) fn verify_codex_fork(
4
286
  source_session_id: &SessionId,
5
287
  plan: &CommandPlan,
6
288
  before: &ContextBackingSnapshot,
289
+ expected_backing_path: Option<&Path>,
7
290
  agent_id: &str,
8
291
  spawn_cwd: &Path,
9
292
  spawned_at: &str,
10
293
  deadline: Duration,
11
294
  ) -> Result<ContextForkProof, ContextForkTermination> {
295
+ let Some(expected) = plan.expected_session_id.as_ref() else {
296
+ return verify_legacy_codex_fork(
297
+ source_session_id,
298
+ plan,
299
+ before,
300
+ agent_id,
301
+ spawn_cwd,
302
+ spawned_at,
303
+ deadline,
304
+ );
305
+ };
306
+ if expected == source_session_id {
307
+ return Err(ProviderError::CaptureFailed(
308
+ "context_fork_unverified: codex target session equals source".to_string(),
309
+ )
310
+ .into());
311
+ }
312
+ let expected_path = expected_backing_path.ok_or_else(|| {
313
+ ProviderError::CaptureFailed(
314
+ "context_fork_unverified: codex plan has no materialized target backing".to_string(),
315
+ )
316
+ })?;
12
317
  let context = crate::provider::session_scan::CaptureSessionContext {
13
318
  agent_id: agent_id.to_string(),
14
319
  spawn_cwd: spawn_cwd.to_path_buf(),
@@ -18,28 +323,25 @@ pub(super) fn verify_codex_fork(
18
323
  expected_session_id: plan.expected_session_id.clone(),
19
324
  provider_projects_root: plan.provider_projects_root.clone(),
20
325
  };
21
- let excluded = outcome::source_exclusions(before, source_session_id);
22
326
  let started = std::time::Instant::now();
23
327
  loop {
24
- let current = jsonl_files(&before.root);
25
328
  for candidate in
26
329
  crate::provider::session_scan::scan_session_candidates_once(Provider::Codex, &context)?
27
330
  {
28
331
  let Some(path) = candidate.captured.rollout_path.as_ref() else {
29
332
  continue;
30
333
  };
31
- let Some(stamp) = current.get(path.as_path()) else {
32
- continue;
33
- };
34
- let snapshot_changed = before.files.get(path.as_path()) != Some(stamp);
35
- if !snapshot_changed {
334
+ if path.as_path() != expected_path {
36
335
  continue;
37
336
  }
38
337
  let Some(new_session_id) = candidate.captured.session_id else {
39
338
  continue;
40
339
  };
41
- if excluded.contains(new_session_id.as_str())
42
- || excluded.contains(&path.as_path().to_string_lossy().to_string())
340
+ if &new_session_id != expected || &new_session_id == source_session_id {
341
+ continue;
342
+ }
343
+ if !candidate.positive_agent_id_match
344
+ || candidate.embedded_agent_id.as_deref() != Some(agent_id)
43
345
  {
44
346
  continue;
45
347
  }
@@ -63,3 +365,78 @@ pub(super) fn verify_codex_fork(
63
365
  deadline_ms: deadline.as_millis(),
64
366
  })
65
367
  }
368
+
369
+ fn verify_legacy_codex_fork(
370
+ source_session_id: &SessionId,
371
+ plan: &CommandPlan,
372
+ before: &ContextBackingSnapshot,
373
+ agent_id: &str,
374
+ spawn_cwd: &Path,
375
+ spawned_at: &str,
376
+ deadline: Duration,
377
+ ) -> Result<ContextForkProof, ContextForkTermination> {
378
+ let mut excluded_session_ids = BTreeSet::new();
379
+ excluded_session_ids.insert(source_session_id.as_str().to_string());
380
+ let mut excluded_backing_paths = BTreeSet::new();
381
+ for path in before.files.keys() {
382
+ if session_id_from_jsonl(path).as_deref() == Some(source_session_id.as_str()) {
383
+ excluded_backing_paths.insert(path.clone());
384
+ }
385
+ }
386
+ let context = crate::provider::session_scan::CaptureSessionContext {
387
+ agent_id: agent_id.to_string(),
388
+ spawn_cwd: spawn_cwd.to_path_buf(),
389
+ pane_id: None,
390
+ pane_pid: None,
391
+ spawned_at: Some(spawned_at.to_string()),
392
+ expected_session_id: None,
393
+ provider_projects_root: plan.provider_projects_root.clone(),
394
+ };
395
+ let started = std::time::Instant::now();
396
+ loop {
397
+ let current = jsonl_files(&provider_backing_root(Provider::Codex, plan));
398
+ let mut matches = Vec::new();
399
+ for candidate in
400
+ crate::provider::session_scan::scan_session_candidates_once(Provider::Codex, &context)?
401
+ {
402
+ let Some(path) = candidate.captured.rollout_path.as_ref() else {
403
+ continue;
404
+ };
405
+ if excluded_backing_paths.contains(path.as_path()) {
406
+ continue;
407
+ }
408
+ let Some(stamp) = current.get(path.as_path()) else {
409
+ continue;
410
+ };
411
+ if before.files.get(path.as_path()) == Some(stamp) {
412
+ continue;
413
+ }
414
+ let Some(session_id) = candidate.captured.session_id else {
415
+ continue;
416
+ };
417
+ if excluded_session_ids.contains(session_id.as_str()) {
418
+ continue;
419
+ }
420
+ matches.push((session_id, path.as_path().to_path_buf()));
421
+ }
422
+ if let [(new_session_id, backing_path)] = matches.as_slice() {
423
+ return Ok(ContextForkProof {
424
+ provider: Provider::Codex,
425
+ source_session_id: source_session_id.clone(),
426
+ new_session_id: new_session_id.clone(),
427
+ backing_path: backing_path.clone(),
428
+ captured_via: "context_fork_verified".to_string(),
429
+ attribution_confidence: "high".to_string(),
430
+ managed_backing_root: None,
431
+ });
432
+ }
433
+ if started.elapsed() >= deadline {
434
+ break;
435
+ }
436
+ std::thread::sleep(Duration::from_millis(50));
437
+ }
438
+ Err(ContextForkTermination::Timeout {
439
+ provider: Provider::Codex,
440
+ deadline_ms: deadline.as_millis(),
441
+ })
442
+ }
@@ -1,18 +1,4 @@
1
1
  use super::*;
2
- use std::collections::BTreeSet;
3
-
4
- pub(super) fn source_exclusions(
5
- before: &ContextBackingSnapshot,
6
- source_session_id: &SessionId,
7
- ) -> BTreeSet<String> {
8
- let mut excluded = BTreeSet::from([source_session_id.as_str().to_string()]);
9
- for path in before.files.keys() {
10
- if session_id_from_jsonl(path).as_deref() == Some(source_session_id.as_str()) {
11
- excluded.insert(path.to_string_lossy().to_string());
12
- }
13
- }
14
- excluded
15
- }
16
2
 
17
3
  #[derive(Debug, Clone)]
18
4
  pub(crate) struct PendingContextFork {
@@ -47,6 +33,7 @@ pub(crate) fn observe_context_fork(
47
33
  plan: &CommandPlan,
48
34
  before: &ContextBackingSnapshot,
49
35
  expected_backing_path: Option<&Path>,
36
+ source_agent_id: &str,
50
37
  agent_id: &str,
51
38
  spawn_cwd: &Path,
52
39
  spawned_at: &str,
@@ -58,6 +45,7 @@ pub(crate) fn observe_context_fork(
58
45
  plan,
59
46
  before,
60
47
  expected_backing_path,
48
+ source_agent_id,
61
49
  agent_id,
62
50
  spawn_cwd,
63
51
  spawned_at,
@@ -123,6 +111,7 @@ mod tests {
123
111
  &plan,
124
112
  &before,
125
113
  None,
114
+ "source",
126
115
  "fork",
127
116
  &root,
128
117
  "2026-07-24T00:00:00Z",
@@ -21,6 +21,7 @@ pub(crate) enum ContextForkTermination {
21
21
  mod claude;
22
22
  mod codex;
23
23
  mod outcome;
24
+ pub(crate) use codex::materialize_codex_fork;
24
25
  pub(crate) use outcome::{
25
26
  observe_context_fork, transition_pending_context_fork, ContextForkOutcome, PendingContextFork,
26
27
  };
@@ -47,7 +48,6 @@ pub struct ContextForkProof {
47
48
 
48
49
  #[derive(Debug, Clone)]
49
50
  pub(crate) struct ContextBackingSnapshot {
50
- root: PathBuf,
51
51
  files: BTreeMap<PathBuf, FileStamp>,
52
52
  }
53
53
 
@@ -61,7 +61,7 @@ impl ContextBackingSnapshot {
61
61
  pub(crate) fn capture(provider: Provider, plan: &CommandPlan) -> Self {
62
62
  let root = provider_backing_root(provider, plan);
63
63
  let files = jsonl_files(&root);
64
- Self { root, files }
64
+ Self { files }
65
65
  }
66
66
  }
67
67
 
@@ -71,6 +71,7 @@ pub(crate) fn verify_context_fork(
71
71
  plan: &CommandPlan,
72
72
  before: &ContextBackingSnapshot,
73
73
  expected_backing_path: Option<&Path>,
74
+ _source_agent_id: &str,
74
75
  agent_id: &str,
75
76
  spawn_cwd: &Path,
76
77
  spawned_at: &str,
@@ -84,6 +85,7 @@ pub(crate) fn verify_context_fork(
84
85
  source_session_id,
85
86
  plan,
86
87
  before,
88
+ expected_backing_path,
87
89
  agent_id,
88
90
  spawn_cwd,
89
91
  spawned_at,
@@ -298,6 +300,7 @@ mod tests {
298
300
  &plan,
299
301
  &before,
300
302
  None,
303
+ "source",
301
304
  "fork",
302
305
  &current_cwd,
303
306
  "2026-07-21T20:00:00+00:00",
@@ -344,6 +347,7 @@ mod tests {
344
347
  &plan,
345
348
  &before,
346
349
  Some(&expected_path),
350
+ "source",
347
351
  "fork",
348
352
  &root,
349
353
  "2026-07-22T00:00:00Z",
@@ -382,6 +386,7 @@ mod tests {
382
386
  &plan,
383
387
  &before,
384
388
  Some(&missing),
389
+ "source",
385
390
  "fork",
386
391
  &root,
387
392
  "2026-07-22T00:00:00Z",
@@ -10,8 +10,9 @@ pub mod resume;
10
10
 
11
11
  pub use context_fork::ContextForkProof;
12
12
  pub(crate) use context_fork::{
13
- context_fork_convergence_deadline, observe_context_fork, transition_pending_context_fork,
14
- ContextBackingSnapshot, ContextForkOutcome, PendingContextFork,
13
+ context_fork_convergence_deadline, materialize_codex_fork, observe_context_fork,
14
+ transition_pending_context_fork, ContextBackingSnapshot, ContextForkOutcome,
15
+ PendingContextFork,
15
16
  };
16
17
 
17
18
  pub use resume::{