@team-agent/installer 0.5.56 → 0.5.58

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.
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.56"
578
+ version = "0.5.58"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.56"
12
+ version = "0.5.58"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -2484,6 +2484,7 @@ pub mod lifecycle_port {
2484
2484
  "new_agent_id": report.new_agent_id.as_str(),
2485
2485
  "session_id": report.session_id.as_ref().map(|session| session.as_str()),
2486
2486
  "new_session_id": report.session_id.as_ref().map(|session| session.as_str()),
2487
+ "backing_state": report.backing_state,
2487
2488
  })),
2488
2489
  Err(e) => Ok(error_value(e)),
2489
2490
  }
@@ -52,8 +52,14 @@ pub enum MessageStoreError {
52
52
  Sqlite(#[from] rusqlite::Error),
53
53
  #[error("io: {0}")]
54
54
  Io(#[from] std::io::Error),
55
+ #[error("event log: {0}")]
56
+ EventLog(#[from] crate::event_log::EventLogError),
55
57
  #[error("delivery receipt missing for message: {0}")]
56
58
  DeliveryReceiptMissing(String),
59
+ #[error("invalid message transition from {from} to {to}")]
60
+ InvalidTransition { from: String, to: String },
61
+ #[error("submitted recovery refused: {0}")]
62
+ RecoveryRefused(&'static str),
57
63
  }
58
64
 
59
65
  /// Outcome of [`MessageStore::claim_leader_notification_delivery`]
@@ -88,19 +94,96 @@ pub enum MessageRowStatus {
88
94
  StoredOnly,
89
95
  QueuedUntilLeaderAttach,
90
96
  QueuedCoordinatorUnavailable,
97
+ QueuedPaneMissing,
98
+ TargetResolved,
99
+ SubmittedAwaitingReceipt,
100
+ SubmittedUnverified,
101
+ Delivered,
102
+ Acknowledged,
103
+ Consumed,
104
+ Failed,
105
+ BlockedLeaderUnbound,
106
+ BlockedWorkerPaneMissing,
91
107
  }
92
108
 
93
109
  impl MessageRowStatus {
110
+ pub const ALL: &[Self] = &[
111
+ Self::Accepted,
112
+ Self::StoredOnly,
113
+ Self::QueuedUntilLeaderAttach,
114
+ Self::QueuedCoordinatorUnavailable,
115
+ Self::QueuedPaneMissing,
116
+ Self::TargetResolved,
117
+ Self::SubmittedAwaitingReceipt,
118
+ Self::SubmittedUnverified,
119
+ Self::Delivered,
120
+ Self::Acknowledged,
121
+ Self::Consumed,
122
+ Self::Failed,
123
+ Self::BlockedLeaderUnbound,
124
+ Self::BlockedWorkerPaneMissing,
125
+ ];
126
+
94
127
  pub const fn as_str(self) -> &'static str {
95
128
  match self {
96
129
  Self::Accepted => "accepted",
97
130
  Self::StoredOnly => "stored_only",
98
131
  Self::QueuedUntilLeaderAttach => "queued_until_leader_attach",
99
132
  Self::QueuedCoordinatorUnavailable => "queued_coordinator_unavailable",
133
+ Self::QueuedPaneMissing => "queued_pane_missing",
134
+ Self::TargetResolved => "target_resolved",
135
+ Self::SubmittedAwaitingReceipt => "submitted_pending_acceptance",
136
+ Self::SubmittedUnverified => "submitted_unverified",
137
+ Self::Delivered => "delivered",
138
+ Self::Acknowledged => "acknowledged",
139
+ Self::Consumed => "consumed",
140
+ Self::Failed => "failed",
141
+ Self::BlockedLeaderUnbound => "blocked_leader_unbound",
142
+ Self::BlockedWorkerPaneMissing => "queued_pane_missing",
143
+ }
144
+ }
145
+ }
146
+
147
+ #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
148
+ pub struct BlockedLeaderRequeueCounts {
149
+ pub blocked_leader_unbound: usize,
150
+ pub queued_until_leader_attach: usize,
151
+ }
152
+
153
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
154
+ pub struct RecoveryBudget {
155
+ pub attempt_budget: u32,
156
+ pub max_age_seconds: u64,
157
+ pub owner_epoch: i64,
158
+ }
159
+
160
+ #[derive(Debug, Clone, PartialEq, Eq)]
161
+ pub struct PaneAvailable {
162
+ pub agent_id: String,
163
+ pub pane_id: String,
164
+ }
165
+
166
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
167
+ pub enum RedisplayKind {
168
+ FrameworkReplay,
169
+ ProviderRerender,
170
+ }
171
+
172
+ impl RedisplayKind {
173
+ pub const fn as_str(self) -> &'static str {
174
+ match self {
175
+ Self::FrameworkReplay => "framework_replay",
176
+ Self::ProviderRerender => "provider_rerender",
100
177
  }
101
178
  }
102
179
  }
103
180
 
181
+ impl BlockedLeaderRequeueCounts {
182
+ pub const fn total(self) -> usize {
183
+ self.blocked_leader_unbound + self.queued_until_leader_attach
184
+ }
185
+ }
186
+
104
187
  /// Fully resolved durable-message insert. Grammar, scope and transport data do
105
188
  /// not belong here; callers must resolve those before crossing this boundary.
106
189
  pub struct PersistMessageInput<'a> {
@@ -294,7 +377,7 @@ impl MessageStore {
294
377
  ) -> Result<(), MessageStoreError> {
295
378
  let conn = crate::db::schema::open_db(&self.path)?;
296
379
  let now = now_ts();
297
- conn.execute(
380
+ let changed = conn.execute(
298
381
  "update messages
299
382
  set status = case
300
383
  when status = 'acknowledged'
@@ -310,9 +393,55 @@ impl MessageStore {
310
393
  end,
311
394
  acknowledged_at = case when ?2 = 'acknowledged' then ?3 else acknowledged_at end,
312
395
  error = case when ?2 = 'delivered' then null else coalesce(?4, error) end
313
- where message_id = ?1",
396
+ where message_id = ?1
397
+ and status not in ('acknowledged', 'consumed', 'submitted_pending_acceptance')
398
+ and (status != 'delivered' or ?2 in ('delivered', 'acknowledged'))
399
+ and (?2 != 'acknowledged' or status = 'delivered')",
314
400
  params![message_id, status, now, error],
315
401
  )?;
402
+ if changed == 0 {
403
+ let prior: Option<String> = conn
404
+ .query_row(
405
+ "select status from messages where message_id = ?1",
406
+ params![message_id],
407
+ |row| row.get(0),
408
+ )
409
+ .optional()?;
410
+ let workspace = self
411
+ .path
412
+ .parent()
413
+ .and_then(Path::parent)
414
+ .and_then(Path::parent)
415
+ .unwrap_or(Path::new("."));
416
+ if status == MessageRowStatus::Acknowledged.as_str()
417
+ && prior.as_deref() != Some(MessageRowStatus::Delivered.as_str())
418
+ {
419
+ crate::event_log::EventLog::new(workspace).write(
420
+ "message.mark_refused",
421
+ serde_json::json!({
422
+ "message_id": message_id,
423
+ "prior_status": prior,
424
+ "requested_status": status,
425
+ "reason": "invalid_acknowledgement_transition",
426
+ }),
427
+ )?;
428
+ return Err(MessageStoreError::InvalidTransition {
429
+ from: prior.unwrap_or_else(|| "missing".to_string()),
430
+ to: status.to_string(),
431
+ });
432
+ }
433
+ let event_log = crate::event_log::EventLog::new(workspace);
434
+ event_log.write(
435
+ "message.mark_refused",
436
+ serde_json::json!({
437
+ "message_id": message_id,
438
+ "prior_status": prior,
439
+ "requested_status": status,
440
+ "reason": "protected_prior_state",
441
+ }),
442
+ )?;
443
+ return Ok(());
444
+ }
316
445
  Ok(())
317
446
  }
318
447
 
@@ -397,6 +526,155 @@ impl MessageStore {
397
526
  Ok(rows == 1)
398
527
  }
399
528
 
529
+ /// Read inbox rows through [`Self::inbox`]; recovery transitions below are
530
+ /// separate repository owners and are not claim eligibility.
531
+ pub fn requeue_blocked_leader_messages(
532
+ &self,
533
+ owner_team_id: &str,
534
+ ) -> Result<BlockedLeaderRequeueCounts, MessageStoreError> {
535
+ let mut conn = crate::db::schema::open_db(&self.path)?;
536
+ let tx = conn.transaction()?;
537
+ let now = now_ts();
538
+ let blocked_leader_unbound = tx.execute(
539
+ "update messages
540
+ set status = 'accepted', error = null, updated_at = ?1
541
+ where recipient = 'leader'
542
+ and owner_team_id = ?2
543
+ and status = ?3
544
+ and error = 'leader_not_attached'",
545
+ params![now, owner_team_id, MessageRowStatus::Failed.as_str()],
546
+ )?;
547
+ let queued_until_leader_attach = tx.execute(
548
+ "update messages
549
+ set status = 'accepted', error = null, updated_at = ?1
550
+ where recipient = 'leader'
551
+ and owner_team_id = ?2
552
+ and status = ?3",
553
+ params![
554
+ now,
555
+ owner_team_id,
556
+ MessageRowStatus::QueuedUntilLeaderAttach.as_str()
557
+ ],
558
+ )?;
559
+ tx.commit()?;
560
+ Ok(BlockedLeaderRequeueCounts {
561
+ blocked_leader_unbound,
562
+ queued_until_leader_attach,
563
+ })
564
+ }
565
+
566
+ pub fn recover_worker_pane_available(
567
+ &self,
568
+ agent_id: &str,
569
+ owner_team_id: Option<&str>,
570
+ ) -> Result<Vec<String>, MessageStoreError> {
571
+ let mut conn = crate::db::schema::open_db(&self.path)?;
572
+ let tx = conn.transaction()?;
573
+ let ids = {
574
+ let mut stmt = tx.prepare(
575
+ "select message_id from messages
576
+ where recipient = ?1
577
+ and status = ?2
578
+ and error = 'tmux_target_missing'
579
+ and (
580
+ (?3 is null and owner_team_id is null)
581
+ or owner_team_id = ?3
582
+ )
583
+ order by created_at, message_id",
584
+ )?;
585
+ let rows = stmt
586
+ .query_map(
587
+ params![
588
+ agent_id,
589
+ MessageRowStatus::QueuedPaneMissing.as_str(),
590
+ owner_team_id
591
+ ],
592
+ |row| row.get::<_, String>(0),
593
+ )?
594
+ .collect::<Result<Vec<_>, _>>()?;
595
+ rows
596
+ };
597
+ let now = now_ts();
598
+ for message_id in &ids {
599
+ tx.execute(
600
+ "update messages
601
+ set status = ?1, error = null, updated_at = ?2
602
+ where message_id = ?3",
603
+ params![MessageRowStatus::Accepted.as_str(), now, message_id],
604
+ )?;
605
+ }
606
+ tx.commit()?;
607
+ Ok(ids)
608
+ }
609
+
610
+ pub fn retry_submitted_explicit(
611
+ &self,
612
+ message_id: &str,
613
+ incident_id: &str,
614
+ operator_reason: &str,
615
+ budget: RecoveryBudget,
616
+ ) -> Result<bool, MessageStoreError> {
617
+ if incident_id.trim().is_empty() || operator_reason.trim().is_empty() {
618
+ return Err(MessageStoreError::RecoveryRefused(
619
+ "incident_id_and_operator_reason_required",
620
+ ));
621
+ }
622
+ if budget.attempt_budget == 0 || budget.max_age_seconds == 0 || budget.owner_epoch <= 0 {
623
+ return Err(MessageStoreError::RecoveryRefused(
624
+ "invalid_recovery_budget",
625
+ ));
626
+ }
627
+ let conn = crate::db::schema::open_db(&self.path)?;
628
+ let row: Option<(String, i64, String)> = conn
629
+ .query_row(
630
+ "select status, delivery_attempts, updated_at
631
+ from messages where message_id = ?1",
632
+ params![message_id],
633
+ |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
634
+ )
635
+ .optional()?;
636
+ let Some((status, attempts, updated_at)) = row else {
637
+ return Ok(false);
638
+ };
639
+ if status != MessageRowStatus::SubmittedAwaitingReceipt.as_str()
640
+ && status != MessageRowStatus::SubmittedUnverified.as_str()
641
+ {
642
+ return Err(MessageStoreError::RecoveryRefused(
643
+ "message_not_submitted_recoverable",
644
+ ));
645
+ }
646
+ if attempts >= i64::from(budget.attempt_budget) {
647
+ return Err(MessageStoreError::RecoveryRefused(
648
+ "attempt_budget_exhausted",
649
+ ));
650
+ }
651
+ let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_at)
652
+ .map_err(|_| MessageStoreError::RecoveryRefused("invalid_updated_at"))?;
653
+ let age = chrono::Utc::now()
654
+ .signed_duration_since(updated_at.with_timezone(&chrono::Utc))
655
+ .num_seconds();
656
+ if age < 0 || u64::try_from(age).unwrap_or(u64::MAX) > budget.max_age_seconds {
657
+ return Err(MessageStoreError::RecoveryRefused("message_too_old"));
658
+ }
659
+ let changed = conn.execute(
660
+ "update messages
661
+ set status = ?1, error = ?2, updated_at = ?3
662
+ where message_id = ?4
663
+ and status in (?5, ?6)
664
+ and delivery_attempts < ?7",
665
+ params![
666
+ MessageRowStatus::Accepted.as_str(),
667
+ format!("explicit_recovery:{incident_id}:{operator_reason}"),
668
+ now_ts(),
669
+ message_id,
670
+ MessageRowStatus::SubmittedAwaitingReceipt.as_str(),
671
+ MessageRowStatus::SubmittedUnverified.as_str(),
672
+ budget.attempt_budget
673
+ ],
674
+ )?;
675
+ Ok(changed == 1)
676
+ }
677
+
400
678
  /// Read inbox rows for an agent. This projection intentionally has no owner-team
401
679
  /// filter when the caller does not provide one: legacy/CLI inbox must surface
402
680
  /// NULL-owner messages stored for the agent.
@@ -863,6 +1141,7 @@ mod tests {
863
1141
  let mid = s
864
1142
  .create_message(Some("t"), "s", "r", "c", None, true, None)
865
1143
  .unwrap();
1144
+ s.mark(&mid, "delivered", None).unwrap();
866
1145
  s.mark(&mid, "acknowledged", None).unwrap();
867
1146
  assert_eq!(status_of(&read(&s), &mid), "acknowledged");
868
1147
  assert!(col_str(&read(&s), &mid, "acknowledged_at").is_some());
@@ -882,15 +1161,15 @@ mod tests {
882
1161
  }
883
1162
 
884
1163
  #[test]
885
- fn mark_acknowledged_then_failed_overwrites() {
886
- // 'failed' is NOT in the guarded delivery set → it overwrites acknowledged.
1164
+ fn mark_acknowledged_then_failed_stays_terminal() {
887
1165
  let s = store();
888
1166
  let mid = s
889
1167
  .create_message(Some("t"), "s", "r", "c", None, true, None)
890
1168
  .unwrap();
1169
+ s.mark(&mid, "delivered", None).unwrap();
891
1170
  s.mark(&mid, "acknowledged", None).unwrap();
892
1171
  s.mark(&mid, "failed", Some("x")).unwrap();
893
- assert_eq!(status_of(&read(&s), &mid), "failed");
1172
+ assert_eq!(status_of(&read(&s), &mid), "acknowledged");
894
1173
  }
895
1174
 
896
1175
  #[test]
@@ -0,0 +1,5 @@
1
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2
+ pub enum LeaderIncident {
3
+ LeaderAttached,
4
+ LeaderClaimed,
5
+ }
@@ -76,6 +76,7 @@ use crate::state::StateError;
76
76
 
77
77
  // ── submodules(by responsibility) ──────────────────────────────────────────
78
78
  mod helpers;
79
+ pub mod incident;
79
80
  pub mod inject;
80
81
  pub mod lease;
81
82
  pub mod owner_bind;
@@ -87,6 +88,7 @@ pub mod takeover;
87
88
  pub mod types;
88
89
 
89
90
  // ── RE-EXPORT INVARIANT:每个先前 root-visible 项原路径不变 ────────────────────
91
+ pub use incident::*;
90
92
  pub use inject::*;
91
93
  pub use lease::*;
92
94
  pub use owner_bind::*;
@@ -57,66 +57,13 @@ pub fn fork_agent_with_transport(
57
57
  let text = std::fs::read_to_string(&read_spec_path)
58
58
  .map_err(|e| LifecycleError::Compile(format!("{}: {e}", read_spec_path.display())))?;
59
59
  let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
60
- if find_spec_agent(&spec, as_agent_id).is_some() || leader_id_matches(&spec, as_agent_id) {
60
+ if fork_spec_agent(&spec, as_agent_id).is_some() || leader_id_matches(&spec, as_agent_id) {
61
61
  return Err(LifecycleError::RequirementUnmet(format!(
62
62
  "agent id already exists: {as_agent_id}"
63
63
  )));
64
64
  }
65
- let source_agent = find_spec_agent(&spec, source_agent_id).ok_or_else(|| {
66
- LifecycleError::RequirementUnmet(format!("unknown worker agent id: {source_agent_id}"))
67
- })?;
68
- // Fork requires the complete source tuple before treating session_id as
69
- // resumable truth; a scalar-only row has no confirmed backing.
70
- let source_agent_state = state
71
- .get("agents")
72
- .and_then(|v| v.get(source_agent_id.as_str()))
73
- .ok_or_else(|| {
74
- LifecycleError::Provider(format!(
75
- "cannot fork {source_agent_id}: source agent row not in state"
76
- ))
77
- })?;
78
- let tuple_field_ok = |field: &str| -> bool {
79
- source_agent_state
80
- .get(field)
81
- .and_then(|v| v.as_str())
82
- .is_some_and(|s| !s.is_empty())
83
- };
84
- let session_id_str = source_agent_state
85
- .get("session_id")
86
- .and_then(|v| v.as_str())
87
- .filter(|s| !s.is_empty());
88
- let rollout_path_str = source_agent_state
89
- .get("rollout_path")
90
- .and_then(|v| v.as_str())
91
- .filter(|s| !s.is_empty());
92
- if session_id_str.is_none()
93
- || rollout_path_str.is_none()
94
- || !tuple_field_ok("captured_at")
95
- || !tuple_field_ok("captured_via")
96
- {
97
- return Err(LifecycleError::Provider(format!(
98
- "cannot fork {source_agent_id}: source session backing is missing or incomplete \
99
- (session_id+rollout_path+captured_at+captured_via required)"
100
- )));
101
- }
102
- let Some(source_backing_raw) = rollout_path_str else {
103
- return Err(LifecycleError::Provider(format!(
104
- "cannot fork {source_agent_id}: source session backing is missing"
105
- )));
106
- };
107
- let source_backing = Path::new(source_backing_raw);
108
- if !source_backing.is_file() {
109
- return Err(LifecycleError::Provider(format!(
110
- "cannot fork {source_agent_id}: source session backing is not readable: {}",
111
- source_backing.display()
112
- )));
113
- }
114
- let Some(source_session_id) = session_id_str else {
115
- return Err(LifecycleError::Provider(format!(
116
- "cannot fork {source_agent_id}: source session id is missing"
117
- )));
118
- };
119
- let session_id = crate::provider::SessionId::new(source_session_id.to_string());
65
+ // Source existence authority: state.get("agents"), matching clone-agent.
66
+ let (session_id, source_backing) = fork_source_tuple(&state, source_agent_id)?;
120
67
  let session_name = state
121
68
  .get("session_name")
122
69
  .and_then(|v| v.as_str())
@@ -158,7 +105,7 @@ pub fn fork_agent_with_transport(
158
105
  crate::model::spec::validate_spec(&new_spec, &validate_ws)
159
106
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
160
107
  write_spec_atomic(&spec_path, &new_spec)?;
161
- let new_agent = find_spec_agent(&new_spec, as_agent_id).ok_or_else(|| {
108
+ let new_agent = fork_spec_agent(&new_spec, as_agent_id).ok_or_else(|| {
162
109
  LifecycleError::RequirementUnmet(format!("unknown worker agent id: {as_agent_id}"))
163
110
  })?;
164
111
  let provider = new_agent
@@ -296,13 +243,21 @@ pub fn fork_agent_with_transport(
296
243
  plan.provider_projects_root = source_backing.parent().map(Path::to_path_buf);
297
244
  }
298
245
  let window = WindowName::new(as_agent_id.as_str());
246
+ let mut claude_fork = prepare_claude_fork_backing(
247
+ provider,
248
+ &plan,
249
+ &source_backing,
250
+ &session_id,
251
+ )
252
+ .map_err(|error| {
253
+ let _ = std::fs::write(&spec_path, text.as_bytes());
254
+ cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
255
+ error
256
+ })?;
257
+ // The framework-created Claude snapshot is only fork input, not provider
258
+ // proof. Observe changes made after materialization so a spawn-only
259
+ // provider cannot turn the copied source backing into a Verified result.
299
260
  let backing_before = crate::provider::session::ContextBackingSnapshot::capture(provider, &plan);
300
- let mut claude_fork = prepare_claude_fork_backing(provider, &plan, source_backing, &session_id)
301
- .map_err(|error| {
302
- let _ = std::fs::write(&spec_path, text.as_bytes());
303
- cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
304
- error
305
- })?;
306
261
  let mut env =
307
262
  inherited_env_with_team_overrides(&workspace, as_agent_id.as_str(), Some(&fork_team));
308
263
  apply_profile_launch_env(&mut env, &profile_launch);
@@ -401,7 +356,7 @@ pub fn fork_agent_with_transport(
401
356
  })?;
402
357
  let convergence_deadline =
403
358
  crate::provider::session::context_fork_convergence_deadline(provider);
404
- let context_proof = match crate::provider::session::verify_context_fork(
359
+ let context_outcome = crate::provider::session::observe_context_fork(
405
360
  provider,
406
361
  &session_id,
407
362
  &plan,
@@ -411,9 +366,40 @@ pub fn fork_agent_with_transport(
411
366
  &workspace,
412
367
  &spawned_at,
413
368
  convergence_deadline,
414
- ) {
415
- Ok(proof) => proof,
416
- Err(error) => {
369
+ );
370
+ let context_proof = match context_outcome {
371
+ crate::provider::session::ContextForkOutcome::Verified(proof) => Some(proof),
372
+ crate::provider::session::ContextForkOutcome::Pending(pending) => {
373
+ if let Err(error) = finalize_pending_fork_state(ForkPendingFinalizeInput {
374
+ workspace: &workspace,
375
+ team_key: &fork_team,
376
+ source_agent_id,
377
+ agent_id: as_agent_id,
378
+ spec_agent: new_agent,
379
+ safety: &safety,
380
+ plan: &plan,
381
+ profile_launch: &profile_launch,
382
+ spawn: &spawn,
383
+ profile_dir: &profile_dir,
384
+ dynamic_role_file: materialized_role.path(),
385
+ pending: &pending,
386
+ spawn_epoch,
387
+ }) {
388
+ rollback_fork_after_spawn(
389
+ &workspace,
390
+ transport,
391
+ &session_name,
392
+ &window,
393
+ &mcp_config_path,
394
+ as_agent_id,
395
+ &profile_launch,
396
+ &fork_team,
397
+ );
398
+ return Err(error);
399
+ }
400
+ None
401
+ }
402
+ crate::provider::session::ContextForkOutcome::Rejected(error) => {
417
403
  rollback_fork_after_spawn(
418
404
  &workspace,
419
405
  transport,
@@ -427,33 +413,35 @@ pub fn fork_agent_with_transport(
427
413
  return Err(LifecycleError::Provider(error.to_string()));
428
414
  }
429
415
  };
430
- if let Err(error) = finalize_fork_state(ForkFinalizeInput {
431
- workspace: &workspace,
432
- team_key: &fork_team,
433
- source_agent_id,
434
- agent_id: as_agent_id,
435
- spec_agent: new_agent,
436
- safety: &safety,
437
- plan: &plan,
438
- profile_launch: &profile_launch,
439
- spawn: &spawn,
440
- profile_dir: &profile_dir,
441
- dynamic_role_file: materialized_role.path(),
442
- context_proof: &context_proof,
443
- spawned_at: &spawned_at,
444
- spawn_epoch,
445
- }) {
446
- rollback_fork_after_spawn(
447
- &workspace,
448
- transport,
449
- &session_name,
450
- &window,
451
- &mcp_config_path,
452
- as_agent_id,
453
- &profile_launch,
454
- &fork_team,
455
- );
456
- return Err(error);
416
+ if let Some(context_proof) = context_proof.as_ref() {
417
+ if let Err(error) = finalize_fork_state(ForkFinalizeInput {
418
+ workspace: &workspace,
419
+ team_key: &fork_team,
420
+ source_agent_id,
421
+ agent_id: as_agent_id,
422
+ spec_agent: new_agent,
423
+ safety: &safety,
424
+ plan: &plan,
425
+ profile_launch: &profile_launch,
426
+ spawn: &spawn,
427
+ profile_dir: &profile_dir,
428
+ dynamic_role_file: materialized_role.path(),
429
+ context_proof: &context_proof,
430
+ spawned_at: &spawned_at,
431
+ spawn_epoch,
432
+ }) {
433
+ rollback_fork_after_spawn(
434
+ &workspace,
435
+ transport,
436
+ &session_name,
437
+ &window,
438
+ &mcp_config_path,
439
+ as_agent_id,
440
+ &profile_launch,
441
+ &fork_team,
442
+ );
443
+ return Err(error);
444
+ }
457
445
  }
458
446
  if let Err(error) =
459
447
  verify_fork_registration(&workspace, &fork_team, as_agent_id, &spawn, &window)
@@ -487,6 +475,11 @@ pub fn fork_agent_with_transport(
487
475
  if let Some(materialized) = copilot_fork.as_mut() {
488
476
  materialized.keep();
489
477
  }
478
+ let backing_state = if context_proof.is_some() {
479
+ ForkBackingState::Verified
480
+ } else {
481
+ ForkBackingState::PendingContextFork
482
+ };
490
483
  Ok(ForkAgentReport {
491
484
  source_agent_id: source_agent_id.clone(),
492
485
  new_agent_id: as_agent_id.clone(),
@@ -495,6 +488,7 @@ pub fn fork_agent_with_transport(
495
488
  state_file: crate::state::persist::runtime_state_path(&workspace),
496
489
  coordinator_started,
497
490
  },
498
- session_id: Some(context_proof.new_session_id),
491
+ session_id: context_proof.map(|proof| proof.new_session_id),
492
+ backing_state,
499
493
  })
500
494
  }