@team-agent/installer 0.5.57 → 0.5.59

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.
@@ -0,0 +1,109 @@
1
+ use super::*;
2
+
3
+ #[allow(clippy::too_many_arguments)]
4
+ pub(crate) fn upsert_pending_forked_agent_state(
5
+ state: &mut serde_json::Value,
6
+ source_agent_id: &AgentId,
7
+ as_agent_id: &AgentId,
8
+ spec_agent: &Value,
9
+ safety: &DangerousApproval,
10
+ plan: &crate::provider::CommandPlan,
11
+ profile_launch: &crate::provider::ProviderProfileLaunch,
12
+ spawn: &crate::transport::SpawnResult,
13
+ profile_dir: Option<&Path>,
14
+ dynamic_role_file: &Path,
15
+ pending: &crate::provider::session::PendingContextFork,
16
+ spawn_epoch: u64,
17
+ ) -> Result<(), LifecycleError> {
18
+ let root = state.as_object_mut().ok_or_else(|| {
19
+ LifecycleError::StatePersist("runtime state root is not an object".to_string())
20
+ })?;
21
+ let agents = root
22
+ .entry("agents".to_string())
23
+ .or_insert_with(|| serde_json::json!({}))
24
+ .as_object_mut()
25
+ .ok_or_else(|| {
26
+ LifecycleError::StatePersist("runtime state agents is not an object".to_string())
27
+ })?;
28
+ let mut entry = serde_json::Map::new();
29
+ entry.insert("status".to_string(), serde_json::json!("running"));
30
+ entry.insert(
31
+ "capture_state".to_string(),
32
+ serde_json::json!("pending_context_fork"),
33
+ );
34
+ entry.insert(
35
+ "agent_id".to_string(),
36
+ serde_json::json!(as_agent_id.as_str()),
37
+ );
38
+ entry.insert(
39
+ "window".to_string(),
40
+ serde_json::json!(as_agent_id.as_str()),
41
+ );
42
+ entry.insert(
43
+ "forked_from".to_string(),
44
+ serde_json::json!(source_agent_id.as_str()),
45
+ );
46
+ entry.insert(
47
+ "fork_source_session_id".to_string(),
48
+ serde_json::json!(pending.source_session_id.as_str()),
49
+ );
50
+ entry.insert(
51
+ "pending_target_agent".to_string(),
52
+ serde_json::json!(pending.target_agent),
53
+ );
54
+ entry.insert(
55
+ "dynamic_role_file".to_string(),
56
+ serde_json::json!(dynamic_role_file.to_string_lossy().to_string()),
57
+ );
58
+ entry.insert(
59
+ "role_source_ownership".to_string(),
60
+ serde_json::json!("managed"),
61
+ );
62
+ entry.insert(
63
+ "spawn_cwd".to_string(),
64
+ serde_json::json!(pending
65
+ .scanner_context
66
+ .spawn_cwd
67
+ .to_string_lossy()
68
+ .to_string()),
69
+ );
70
+ entry.insert(
71
+ "pane_id".to_string(),
72
+ serde_json::json!(spawn.pane_id.as_str()),
73
+ );
74
+ entry.insert(
75
+ "spawned_at".to_string(),
76
+ serde_json::json!(pending.spawned_at),
77
+ );
78
+ entry.insert("spawn_epoch".to_string(), serde_json::json!(spawn_epoch));
79
+ if let Some(pid) = spawn.child_pid {
80
+ entry.insert("pane_pid".to_string(), serde_json::json!(pid));
81
+ }
82
+ for key in [
83
+ "provider",
84
+ "auth_mode",
85
+ "model",
86
+ "profile",
87
+ "role",
88
+ "effort",
89
+ ] {
90
+ if let Some(value) = spec_agent.get(key) {
91
+ entry.insert(key.to_string(), yaml_value_to_json(value));
92
+ }
93
+ }
94
+ if spec_agent.get("profile").is_some() {
95
+ if let Some(profile_dir) = profile_dir {
96
+ entry.insert(
97
+ "_profile_dir".to_string(),
98
+ serde_json::json!(profile_dir.to_string_lossy().to_string()),
99
+ );
100
+ }
101
+ }
102
+ persist_command_plan_state(&mut entry, plan, profile_launch);
103
+ persist_effective_approval_policy(&mut entry, safety);
104
+ agents.insert(
105
+ as_agent_id.as_str().to_string(),
106
+ serde_json::Value::Object(entry),
107
+ );
108
+ Ok(())
109
+ }
@@ -14,6 +14,10 @@ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest}
14
14
 
15
15
  use super::*;
16
16
 
17
+ #[path = "fork_pending.rs"]
18
+ mod fork_pending;
19
+ pub(super) use fork_pending::upsert_pending_forked_agent_state;
20
+
17
21
  pub(super) fn reserve_forked_agent_state(
18
22
  state: &mut serde_json::Value,
19
23
  source_agent_id: &AgentId,
@@ -134,6 +138,51 @@ pub(super) fn find_spec_agent<'a>(spec: &'a Value, agent_id: &AgentId) -> Option
134
138
  })
135
139
  }
136
140
 
141
+ pub(super) fn fork_spec_agent<'a>(spec: &'a Value, agent_id: &AgentId) -> Option<&'a Value> {
142
+ find_spec_agent(spec, agent_id)
143
+ }
144
+
145
+ pub(super) fn fork_source_tuple(
146
+ state: &serde_json::Value,
147
+ source_agent_id: &AgentId,
148
+ ) -> Result<(crate::provider::SessionId, PathBuf), LifecycleError> {
149
+ let source = state
150
+ .get("agents")
151
+ .and_then(|agents| agents.get(source_agent_id.as_str()))
152
+ .ok_or_else(|| {
153
+ LifecycleError::Provider(format!("unknown worker agent id: {source_agent_id}"))
154
+ })?;
155
+ let field = |name: &str| {
156
+ source
157
+ .get(name)
158
+ .and_then(serde_json::Value::as_str)
159
+ .filter(|value| !value.is_empty())
160
+ };
161
+ let (Some(session_id), Some(backing)) = (field("session_id"), field("rollout_path")) else {
162
+ return Err(LifecycleError::Provider(format!(
163
+ "cannot fork {source_agent_id}: source session backing is missing or incomplete \
164
+ (session_id+rollout_path+captured_at+captured_via required)"
165
+ )));
166
+ };
167
+ if field("captured_at").is_none() || field("captured_via").is_none() {
168
+ return Err(LifecycleError::Provider(format!(
169
+ "cannot fork {source_agent_id}: source session backing is missing or incomplete \
170
+ (session_id+rollout_path+captured_at+captured_via required)"
171
+ )));
172
+ }
173
+ let backing = PathBuf::from(backing);
174
+ if !backing.is_file() {
175
+ return Err(LifecycleError::Provider(format!(
176
+ "cannot fork {source_agent_id}: source session backing is not readable: {}",
177
+ backing.display()
178
+ )));
179
+ }
180
+ Ok((
181
+ crate::provider::SessionId::new(session_id.to_string()),
182
+ backing,
183
+ ))
184
+ }
185
+
137
186
  pub(super) fn append_forked_agent(
138
187
  spec: &Value,
139
188
  source_agent: &Value,
@@ -295,6 +295,7 @@ mod fork_state;
295
295
  pub(super) use fork_state::*;
296
296
 
297
297
  mod fork_finalize;
298
+ pub(crate) use fork_finalize::finalize_pending_fork_capture;
298
299
  pub(super) use fork_finalize::*;
299
300
 
300
301
  mod role_source;
@@ -664,6 +664,14 @@ pub struct ForkAgentReport {
664
664
  pub new_agent_id: AgentId,
665
665
  pub env: AgentActionEnvelope,
666
666
  pub session_id: Option<SessionId>,
667
+ pub backing_state: ForkBackingState,
668
+ }
669
+
670
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
671
+ #[serde(rename_all = "snake_case")]
672
+ pub enum ForkBackingState {
673
+ Verified,
674
+ PendingContextFork,
667
675
  }
668
676
 
669
677
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -130,6 +130,7 @@ pub(crate) fn fork_agent(
130
130
  "state_file": report.env.state_file.to_string_lossy().to_string(),
131
131
  "coordinator_started": report.env.coordinator_started,
132
132
  "session_id": report.session_id.as_ref().map(|session| session.as_str()),
133
+ "backing_state": report.backing_state,
133
134
  })),
134
135
  })
135
136
  }
@@ -336,9 +336,20 @@ pub(crate) fn normalize_tests(value: Option<&Value>) -> Vec<NormalizedTest> {
336
336
  .or_else(|| obj.get("name"))
337
337
  .or_else(|| obj.get("test"))
338
338
  .and_then(text_of_value)?;
339
+ let status =
340
+ match normalize_token(obj.get("status").and_then(Value::as_str)).as_str() {
341
+ "executed" => {
342
+ if obj.get("exit_code").and_then(Value::as_i64) == Some(0) {
343
+ TestStatus::Passed
344
+ } else {
345
+ TestStatus::Failed
346
+ }
347
+ }
348
+ _ => normalize_test_status(obj.get("status").and_then(Value::as_str)),
349
+ };
339
350
  Some(NormalizedTest {
340
351
  command,
341
- status: normalize_test_status(obj.get("status").and_then(Value::as_str)),
352
+ status,
342
353
  detail: obj
343
354
  .get("detail")
344
355
  .or_else(|| obj.get("output"))
@@ -346,6 +357,7 @@ pub(crate) fn normalize_tests(value: Option<&Value>) -> Vec<NormalizedTest> {
346
357
  .or_else(|| obj.get("stderr"))
347
358
  .or_else(|| obj.get("summary"))
348
359
  .or_else(|| obj.get("message"))
360
+ .or_else(|| obj.get("log_path"))
349
361
  .and_then(text_of_value),
350
362
  })
351
363
  }
@@ -358,6 +370,46 @@ pub(crate) fn normalize_tests(value: Option<&Value>) -> Vec<NormalizedTest> {
358
370
  .collect()
359
371
  }
360
372
 
373
+ pub(crate) fn validate_test_evidence_schema(value: Option<&Value>) -> Result<(), String> {
374
+ const ALLOWED: &str = r#"allowed schema: {"status":"executed","command":string,"exit_code":integer,"log_path":string}"#;
375
+ for (index, item) in items_from_value(value).iter().enumerate() {
376
+ let Value::Object(obj) = item else {
377
+ continue;
378
+ };
379
+ let status = normalize_token(obj.get("status").and_then(Value::as_str));
380
+ if status == "executed" {
381
+ let valid = obj.get("command").and_then(Value::as_str).is_some()
382
+ && obj.get("exit_code").and_then(Value::as_i64).is_some()
383
+ && obj.get("log_path").and_then(Value::as_str).is_some();
384
+ if !valid {
385
+ return Err(format!(
386
+ "unsupported_test_evidence_schema at tests[{index}]; {ALLOWED}"
387
+ ));
388
+ }
389
+ } else if !status.is_empty()
390
+ && !matches!(
391
+ status.as_str(),
392
+ "passed"
393
+ | "pass"
394
+ | "ok"
395
+ | "success"
396
+ | "failed"
397
+ | "fail"
398
+ | "error"
399
+ | "skipped"
400
+ | "skip"
401
+ | "not_run"
402
+ | "notrun"
403
+ )
404
+ {
405
+ return Err(format!(
406
+ "unsupported_test_evidence_schema at tests[{index}]; {ALLOWED}"
407
+ ));
408
+ }
409
+ }
410
+ Ok(())
411
+ }
412
+
361
413
  pub(crate) fn normalize_risks(value: Option<&Value>) -> Vec<NormalizedRisk> {
362
414
  items_from_value(value)
363
415
  .iter()
@@ -27,7 +27,7 @@ use super::helpers::{
27
27
  };
28
28
  use super::normalize::{
29
29
  compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
30
- report_result_integrity_warnings,
30
+ report_result_integrity_warnings, validate_test_evidence_schema,
31
31
  };
32
32
  use super::types::{
33
33
  Scope, SendOutcome, ToolError, ToolErrorReason, ToolOk, ToolResult, VisiblePeers,
@@ -522,6 +522,13 @@ impl TeamOrchestratorTools {
522
522
  {
523
523
  self.note_unknown_result_status(&raw);
524
524
  }
525
+ if let Err(error) = validate_test_evidence_schema(base.get("tests")) {
526
+ return Err(ToolError::new(
527
+ ToolErrorReason::InvalidToolArguments,
528
+ error,
529
+ "UnsupportedTestEvidenceSchema",
530
+ ));
531
+ }
525
532
  let normalized = normalize_report_envelope(&base);
526
533
  if let Some(error) = normalized.presentation_error.as_deref() {
527
534
  return Err(ToolError::new(
@@ -469,13 +469,25 @@ fn classify_pending_capture_state(
469
469
  const TRIGGER_GRACE_SECS: i64 = 15;
470
470
  let past_spawn_grace = elapsed_since_spawn >= SPAWN_GRACE_SECS;
471
471
  let past_trigger_grace = has_trigger && elapsed_since_trigger >= TRIGGER_GRACE_SECS;
472
+ let fork_pending_expired = agent_obj.get("capture_state").and_then(Value::as_str)
473
+ == Some("pending_context_fork")
474
+ && crate::provider::session::transition_pending_context_fork(
475
+ has_trigger,
476
+ past_spawn_grace && past_trigger_grace,
477
+ )
478
+ .is_some();
472
479
  // transcript_missing requires BOTH: a trigger occurred AND we've
473
480
  // waited past the spawn grace (so we're not blamed for slow startup).
474
481
  // candidate_count == 0 is the "no backing" signal; non-zero
475
482
  // candidates that didn't satisfy assignment go through the ambiguous
476
483
  // path above and don't reach here.
477
- if has_trigger && past_spawn_grace && past_trigger_grace && candidate_count == 0 {
484
+ if (fork_pending_expired || has_trigger && past_spawn_grace && past_trigger_grace)
485
+ && candidate_count == 0
486
+ {
478
487
  "transcript_missing"
488
+ } else if agent_obj.get("capture_state").and_then(Value::as_str) == Some("pending_context_fork")
489
+ {
490
+ "pending_context_fork"
479
491
  } else {
480
492
  "pending_first_turn"
481
493
  }
@@ -1143,6 +1155,9 @@ fn apply_captured_session(
1143
1155
  agent_obj: &mut serde_json::Map<String, Value>,
1144
1156
  captured: &CapturedSession,
1145
1157
  ) -> bool {
1158
+ 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);
1160
+ }
1146
1161
  let Some(session_id) = captured.session_id.as_ref() else {
1147
1162
  return false;
1148
1163
  };
@@ -1984,6 +1999,27 @@ mod u1_tests {
1984
1999
  );
1985
2000
  }
1986
2001
 
2002
+ #[test]
2003
+ fn pending_context_fork_keeps_typed_state_until_trigger_grace_expires() {
2004
+ let old = (chrono::Utc::now() - chrono::Duration::seconds(60)).to_rfc3339();
2005
+ let mut agent = serde_json::Map::new();
2006
+ agent.insert("spawned_at".to_string(), serde_json::json!(old));
2007
+ agent.insert(
2008
+ "capture_state".to_string(),
2009
+ serde_json::json!("pending_context_fork"),
2010
+ );
2011
+ assert_eq!(
2012
+ super::classify_pending_capture_state(&agent, 0),
2013
+ "pending_context_fork"
2014
+ );
2015
+ let trigger = (chrono::Utc::now() - chrono::Duration::seconds(20)).to_rfc3339();
2016
+ agent.insert("first_send_at".to_string(), serde_json::json!(trigger));
2017
+ assert_eq!(
2018
+ super::classify_pending_capture_state(&agent, 0),
2019
+ "transcript_missing"
2020
+ );
2021
+ }
2022
+
1987
2023
  #[test]
1988
2024
  fn canonical_pending_capture_preserves_legacy_missing_boundary() {
1989
2025
  let agent = serde_json::json!({
@@ -0,0 +1,85 @@
1
+ use super::*;
2
+
3
+ pub(super) fn verify_claude_fork(
4
+ provider: Provider,
5
+ source_session_id: &SessionId,
6
+ plan: &CommandPlan,
7
+ before: &ContextBackingSnapshot,
8
+ expected_backing_path: Option<&Path>,
9
+ spawn_cwd: &Path,
10
+ deadline: Duration,
11
+ ) -> Result<ContextForkProof, ContextForkTermination> {
12
+ let expected = plan.expected_session_id.as_ref().ok_or_else(|| {
13
+ ProviderError::CaptureFailed(
14
+ "context_fork_unverified: Claude plan has no expected session id".to_string(),
15
+ )
16
+ })?;
17
+ let path = expected_backing_path.ok_or_else(|| {
18
+ ProviderError::CaptureFailed(
19
+ "context_fork_unverified: Claude plan has no exact snapshot backing".to_string(),
20
+ )
21
+ })?;
22
+ let expected_name = format!("{}.jsonl", expected.as_str());
23
+ if path.file_name().and_then(|name| name.to_str()) != Some(expected_name.as_str()) {
24
+ return Err(ProviderError::CaptureFailed(format!(
25
+ "context_fork_unverified: Claude snapshot backing does not match expected session {}",
26
+ expected.as_str()
27
+ ))
28
+ .into());
29
+ }
30
+ let started = std::time::Instant::now();
31
+ loop {
32
+ if let Some(stamp) = file_stamp(path) {
33
+ let changed = before.files.get(path).is_none_or(|old| *old != stamp);
34
+ let observed_matches =
35
+ session_id_from_jsonl(path).is_none_or(|observed| observed == expected.as_str());
36
+ if changed && readable_jsonl(path) && observed_matches && expected != source_session_id
37
+ {
38
+ return Ok(ContextForkProof {
39
+ provider,
40
+ source_session_id: source_session_id.clone(),
41
+ new_session_id: expected.clone(),
42
+ backing_path: path.to_path_buf(),
43
+ captured_via: "context_fork_verified".to_string(),
44
+ attribution_confidence: "high".to_string(),
45
+ managed_backing_root: None,
46
+ });
47
+ }
48
+ }
49
+ if let Some(provider_path) = std::env::var_os("HOME")
50
+ .map(PathBuf::from)
51
+ .and_then(|home| {
52
+ crate::provider::session_scan::claude::projects_dir_for_cwd(&home, spawn_cwd)
53
+ })
54
+ .map(|root| root.join(&expected_name))
55
+ {
56
+ let observed_matches = session_id_from_jsonl(&provider_path)
57
+ .is_some_and(|observed| observed == expected.as_str());
58
+ let changed = file_stamp(&provider_path)
59
+ .is_some_and(|stamp| before.files.get(&provider_path) != Some(&stamp));
60
+ if changed
61
+ && readable_jsonl(&provider_path)
62
+ && observed_matches
63
+ && expected != source_session_id
64
+ {
65
+ return Ok(ContextForkProof {
66
+ provider,
67
+ source_session_id: source_session_id.clone(),
68
+ new_session_id: expected.clone(),
69
+ backing_path: provider_path,
70
+ captured_via: "context_fork_verified".to_string(),
71
+ attribution_confidence: "high".to_string(),
72
+ managed_backing_root: None,
73
+ });
74
+ }
75
+ }
76
+ if started.elapsed() >= deadline {
77
+ break;
78
+ }
79
+ std::thread::sleep(Duration::from_millis(50));
80
+ }
81
+ Err(ContextForkTermination::Timeout {
82
+ provider,
83
+ deadline_ms: deadline.as_millis(),
84
+ })
85
+ }
@@ -0,0 +1,65 @@
1
+ use super::*;
2
+
3
+ pub(super) fn verify_codex_fork(
4
+ source_session_id: &SessionId,
5
+ plan: &CommandPlan,
6
+ before: &ContextBackingSnapshot,
7
+ agent_id: &str,
8
+ spawn_cwd: &Path,
9
+ spawned_at: &str,
10
+ deadline: Duration,
11
+ ) -> Result<ContextForkProof, ContextForkTermination> {
12
+ let context = crate::provider::session_scan::CaptureSessionContext {
13
+ agent_id: agent_id.to_string(),
14
+ spawn_cwd: spawn_cwd.to_path_buf(),
15
+ pane_id: None,
16
+ pane_pid: None,
17
+ spawned_at: Some(spawned_at.to_string()),
18
+ expected_session_id: plan.expected_session_id.clone(),
19
+ provider_projects_root: plan.provider_projects_root.clone(),
20
+ };
21
+ let excluded = outcome::source_exclusions(before, source_session_id);
22
+ let started = std::time::Instant::now();
23
+ loop {
24
+ let current = jsonl_files(&before.root);
25
+ for candidate in
26
+ crate::provider::session_scan::scan_session_candidates_once(Provider::Codex, &context)?
27
+ {
28
+ let Some(path) = candidate.captured.rollout_path.as_ref() else {
29
+ continue;
30
+ };
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 {
36
+ continue;
37
+ }
38
+ let Some(new_session_id) = candidate.captured.session_id else {
39
+ continue;
40
+ };
41
+ if excluded.contains(new_session_id.as_str())
42
+ || excluded.contains(&path.as_path().to_string_lossy().to_string())
43
+ {
44
+ continue;
45
+ }
46
+ return Ok(ContextForkProof {
47
+ provider: Provider::Codex,
48
+ source_session_id: source_session_id.clone(),
49
+ new_session_id,
50
+ backing_path: path.as_path().to_path_buf(),
51
+ captured_via: "context_fork_verified".to_string(),
52
+ attribution_confidence: "high".to_string(),
53
+ managed_backing_root: None,
54
+ });
55
+ }
56
+ if started.elapsed() >= deadline {
57
+ break;
58
+ }
59
+ std::thread::sleep(Duration::from_millis(50));
60
+ }
61
+ Err(ContextForkTermination::Timeout {
62
+ provider: Provider::Codex,
63
+ deadline_ms: deadline.as_millis(),
64
+ })
65
+ }
@@ -0,0 +1,138 @@
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
+
17
+ #[derive(Debug, Clone)]
18
+ pub(crate) struct PendingContextFork {
19
+ pub source_session_id: SessionId,
20
+ pub target_agent: String,
21
+ pub spawned_at: String,
22
+ pub scanner_context: crate::provider::session_scan::CaptureSessionContext,
23
+ }
24
+
25
+ #[derive(Debug)]
26
+ pub(crate) enum ContextForkOutcome {
27
+ Verified(ContextForkProof),
28
+ Pending(PendingContextFork),
29
+ Rejected(ProviderError),
30
+ }
31
+
32
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
33
+ pub(crate) enum ContextForkPendingFailure {
34
+ TranscriptMissing,
35
+ }
36
+
37
+ pub(crate) fn transition_pending_context_fork(
38
+ triggered: bool,
39
+ grace_expired: bool,
40
+ ) -> Option<ContextForkPendingFailure> {
41
+ (triggered && grace_expired).then_some(ContextForkPendingFailure::TranscriptMissing)
42
+ }
43
+
44
+ pub(crate) fn observe_context_fork(
45
+ provider: Provider,
46
+ source_session_id: &SessionId,
47
+ plan: &CommandPlan,
48
+ before: &ContextBackingSnapshot,
49
+ expected_backing_path: Option<&Path>,
50
+ agent_id: &str,
51
+ spawn_cwd: &Path,
52
+ spawned_at: &str,
53
+ deadline: Duration,
54
+ ) -> ContextForkOutcome {
55
+ match verify_context_fork(
56
+ provider,
57
+ source_session_id,
58
+ plan,
59
+ before,
60
+ expected_backing_path,
61
+ agent_id,
62
+ spawn_cwd,
63
+ spawned_at,
64
+ deadline,
65
+ ) {
66
+ Ok(proof) => ContextForkOutcome::Verified(proof),
67
+ Err(ContextForkTermination::Timeout { .. }) => {
68
+ ContextForkOutcome::Pending(PendingContextFork {
69
+ source_session_id: source_session_id.clone(),
70
+ target_agent: agent_id.to_string(),
71
+ spawned_at: spawned_at.to_string(),
72
+ scanner_context: crate::provider::session_scan::CaptureSessionContext {
73
+ agent_id: agent_id.to_string(),
74
+ spawn_cwd: spawn_cwd.to_path_buf(),
75
+ pane_id: None,
76
+ pane_pid: None,
77
+ spawned_at: Some(spawned_at.to_string()),
78
+ expected_session_id: plan.expected_session_id.clone(),
79
+ provider_projects_root: plan.provider_projects_root.clone(),
80
+ },
81
+ })
82
+ }
83
+ Err(ContextForkTermination::Rejected(error)) => ContextForkOutcome::Rejected(error),
84
+ }
85
+ }
86
+
87
+ #[cfg(test)]
88
+ mod tests {
89
+ use super::*;
90
+
91
+ #[test]
92
+ fn convergence_deadlines_are_provider_specific() {
93
+ assert_eq!(
94
+ context_fork_convergence_deadline(Provider::Claude),
95
+ Duration::from_secs(45)
96
+ );
97
+ assert_eq!(
98
+ context_fork_convergence_deadline(Provider::Codex),
99
+ Duration::from_secs(10)
100
+ );
101
+ assert_eq!(
102
+ context_fork_convergence_deadline(Provider::Copilot),
103
+ Duration::from_secs(5)
104
+ );
105
+ }
106
+
107
+ #[test]
108
+ fn codex_without_new_backing_is_typed_pending() {
109
+ let root =
110
+ std::env::temp_dir().join(format!("ta-codex-fork-pending-{}", std::process::id()));
111
+ let _ = std::fs::remove_dir_all(&root);
112
+ std::fs::create_dir_all(&root).unwrap();
113
+ let plan = CommandPlan {
114
+ argv: Vec::new(),
115
+ expected_session_id: None,
116
+ provider_projects_root: Some(root.clone()),
117
+ managed_mcp_config: false,
118
+ };
119
+ let before = ContextBackingSnapshot::capture(Provider::Codex, &plan);
120
+ let outcome = observe_context_fork(
121
+ Provider::Codex,
122
+ &SessionId::new("source-session"),
123
+ &plan,
124
+ &before,
125
+ None,
126
+ "fork",
127
+ &root,
128
+ "2026-07-24T00:00:00Z",
129
+ Duration::ZERO,
130
+ );
131
+ let ContextForkOutcome::Pending(pending) = outcome else {
132
+ panic!("missing backing must remain a typed pending fork")
133
+ };
134
+ assert_eq!(pending.source_session_id.as_str(), "source-session");
135
+ assert_eq!(pending.target_agent, "fork");
136
+ let _ = std::fs::remove_dir_all(&root);
137
+ }
138
+ }