@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.
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.57"
578
+ version = "0.5.59"
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.57"
12
+ version = "0.5.59"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  use super::spec::{command_spec, CommandKind, CommandTier, ALL_DISPATCH_KINDS, COMMAND_SPECS};
5
5
  use super::*;
6
- use std::io::Write as _;
6
+ use std::io::{ErrorKind, Write as _};
7
7
 
8
8
  /// `emit`(`helpers.py:12-23`):`--json`→`json.dumps(indent=2, ensure_ascii=False, sort_keys=True)`;
9
9
  /// 否则 dict 逐键 `key: value`(嵌套 dict/list 内联 compact json,`ensure_ascii=False`)、非 dict 直接 print。
@@ -77,12 +77,40 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
77
77
  /// Print a handler's CmdResult to stdout (emit formats json/human), then surface its exit code.
78
78
  /// (parser.py: `print(emit(result, as_json))` then the ok→exit mapping.)
79
79
  fn emit_result(r: CmdResult) -> ExitCode {
80
+ let persisted_message_id = match &r.output {
81
+ CmdOutput::Json(value) => value
82
+ .get("message_id")
83
+ .and_then(Value::as_str)
84
+ .map(ToString::to_string),
85
+ _ => None,
86
+ };
80
87
  if let Some(text) = emit(&r.output, r.as_json) {
81
- println!("{text}");
88
+ if let Err(error) = write_stdout_line(&text) {
89
+ if let Some(message_id) = persisted_message_id {
90
+ let stderr = std::io::stderr();
91
+ let mut stderr = stderr.lock();
92
+ let _ = writeln!(
93
+ stderr,
94
+ "stdout unavailable after durable send; persisted_message_id={message_id}"
95
+ );
96
+ }
97
+ return if error.kind() == ErrorKind::BrokenPipe {
98
+ r.exit
99
+ } else {
100
+ ExitCode::Error
101
+ };
102
+ }
82
103
  }
83
104
  r.exit
84
105
  }
85
106
 
107
+ fn write_stdout_line(text: &str) -> std::io::Result<()> {
108
+ let stdout = std::io::stdout();
109
+ let mut stdout = stdout.lock();
110
+ stdout.write_all(text.as_bytes())?;
111
+ stdout.write_all(b"\n")
112
+ }
113
+
86
114
  fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliError> {
87
115
  let Some(spec) = command_spec(command) else {
88
116
  return Ok(emit_unknown_subcommand_usage(command));
@@ -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
  }
@@ -37,8 +37,8 @@ pub(super) fn maybe_enqueue_offline_leader_mailbox(
37
37
  Ok(s) => s,
38
38
  Err(_) => return Ok(None),
39
39
  };
40
- let team_alive = target_team_is_alive_for_mailbox(&state, &team_key);
41
- if !team_alive {
40
+ let attachment_history = target_team_mailbox_history(&state, &team_key);
41
+ if attachment_history == MailboxAttachmentHistory::NotEligible {
42
42
  return Ok(None);
43
43
  }
44
44
  let event_log = crate::event_log::EventLog::new(&target_workspace);
@@ -53,47 +53,90 @@ pub(super) fn maybe_enqueue_offline_leader_mailbox(
53
53
  )
54
54
  .map_err(|e| CliError::Runtime(e.to_string()))?;
55
55
  let message_id = outcome.message_id.clone().unwrap_or_else(|| "".to_string());
56
- Ok(Some(json!({
57
- "ok": true,
58
- "status": "queued_until_leader_attach",
59
- "message_status": "queued_until_leader_attach",
60
- "channel": "leader_mailbox",
61
- "delivered": false,
62
- "to_name": to_name,
63
- "target_workspace": target_workspace.display().to_string(),
64
- "team_key": team_key,
65
- "recipient": "leader",
66
- "leader_attached": false,
67
- "message_id": message_id,
68
- })))
56
+ let receipt = match attachment_history {
57
+ MailboxAttachmentHistory::NeverAttached => json!({
58
+ "ok": true,
59
+ "status": "deferred",
60
+ "deferred_reason": "never_attached",
61
+ "message_status": "queued_until_leader_attach",
62
+ "channel": "leader_mailbox",
63
+ "delivered": false,
64
+ "to_name": to_name,
65
+ "target_workspace": target_workspace.display().to_string(),
66
+ "team_key": team_key,
67
+ "recipient": "leader",
68
+ "leader_attached": false,
69
+ "message_id": message_id,
70
+ }),
71
+ MailboxAttachmentHistory::PreviouslyAttached => json!({
72
+ "ok": true,
73
+ "status": "queued_until_leader_attach",
74
+ "deferred_reason": "leader_currently_unattached",
75
+ "message_status": "queued_until_leader_attach",
76
+ "channel": "leader_mailbox",
77
+ "delivered": false,
78
+ "to_name": to_name,
79
+ "target_workspace": target_workspace.display().to_string(),
80
+ "team_key": team_key,
81
+ "recipient": "leader",
82
+ "leader_attached": false,
83
+ "message_id": message_id,
84
+ }),
85
+ MailboxAttachmentHistory::NotEligible => unreachable!("refused before persistence"),
86
+ };
87
+ Ok(Some(receipt))
88
+ }
89
+
90
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
91
+ enum MailboxAttachmentHistory {
92
+ PreviouslyAttached,
93
+ NeverAttached,
94
+ NotEligible,
69
95
  }
70
96
 
71
- /// Positive-source liveness heuristic per offline-mailbox-toname-design.md §4:
97
+ /// Positive-source mailbox eligibility per offline-mailbox-toname-design.md §4:
72
98
  /// - target workspace has state and the team key is present + not archived/down;
73
- /// - AND at least one live tmux fact — a persisted `session_name` OR any
74
- /// agent with a recorded pane on the recorded socket.
99
+ /// - AND a persisted session name establishes a durable replay target;
100
+ /// - a persisted receiver attachment timestamp distinguishes previously attached
101
+ /// teams from teams whose first successful leader attachment is still pending.
75
102
  ///
76
103
  /// We deliberately do NOT poll coordinator health here — enqueuing is
77
104
  /// safe even when the coordinator is transiently down; attach-leader
78
105
  /// itself replays via `requeue_blocked_leader_messages` regardless.
79
- pub(super) fn target_team_is_alive_for_mailbox(state: &Value, team_key: &str) -> bool {
106
+ fn target_team_mailbox_history(state: &Value, team_key: &str) -> MailboxAttachmentHistory {
80
107
  let team = state
81
108
  .get("teams")
82
109
  .and_then(|v| v.as_object())
83
110
  .and_then(|teams| teams.get(team_key));
84
111
  let Some(team) = team else {
85
- return false;
112
+ return MailboxAttachmentHistory::NotEligible;
86
113
  };
87
114
  let status = team
88
115
  .get("status")
89
116
  .and_then(|v| v.as_str())
90
117
  .unwrap_or("alive");
91
118
  if matches!(status, "archived" | "down" | "stopped") {
92
- return false;
119
+ return MailboxAttachmentHistory::NotEligible;
93
120
  }
94
- // A recorded session_name is enough — target's coordinator/attach
95
- // path will re-verify tmux presence when the replay fires.
96
- team.get("session_name")
121
+ let has_session = team
122
+ .get("session_name")
97
123
  .and_then(|v| v.as_str())
98
- .is_some_and(|s| !s.is_empty())
124
+ .is_some_and(|s| !s.is_empty());
125
+ if !has_session {
126
+ return MailboxAttachmentHistory::NotEligible;
127
+ }
128
+ let previously_attached = team
129
+ .get("leader_receiver")
130
+ .and_then(Value::as_object)
131
+ .is_some_and(|receiver| {
132
+ receiver
133
+ .get("attached_at")
134
+ .and_then(Value::as_str)
135
+ .is_some_and(|value| !value.is_empty())
136
+ });
137
+ if previously_attached {
138
+ MailboxAttachmentHistory::PreviouslyAttached
139
+ } else {
140
+ MailboxAttachmentHistory::NeverAttached
141
+ }
99
142
  }
@@ -155,7 +155,7 @@ pub(crate) struct CommandSpec {
155
155
  #[rustfmt::skip]
156
156
  pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
157
157
  CommandSpec { name: "quick-start", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::QuickStart), summary: "start or attach a team from TEAM.md", usage: "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
158
- CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for a logical recipient", usage: "usage: team-agent send TO MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--presentation-sink leader|casefile|silent --message-class CLASS [--case-id CASE]] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
158
+ CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for an in-team short name or fully-qualified logical recipient", usage: "usage: team-agent send TO MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--presentation-sink leader|casefile|silent --message-class CLASS [--case-id CASE]] [--json]\nTO forms are co-equal: an in-team short name (for example `team-agent send reviewer \"Review\"`) or `<workspace>::<team>/<agent>`.", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
159
159
  CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
160
160
  CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
161
161
  CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
@@ -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
  }
@@ -172,6 +172,110 @@ pub(super) fn finalize_fork_state(input: ForkFinalizeInput<'_>) -> Result<(), Li
172
172
  .map_err(|error| LifecycleError::StatePersist(error.to_string()))
173
173
  }
174
174
 
175
+ pub(super) struct ForkPendingFinalizeInput<'a> {
176
+ pub workspace: &'a Path,
177
+ pub team_key: &'a str,
178
+ pub source_agent_id: &'a AgentId,
179
+ pub agent_id: &'a AgentId,
180
+ pub spec_agent: &'a Value,
181
+ pub safety: &'a DangerousApproval,
182
+ pub plan: &'a crate::provider::CommandPlan,
183
+ pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
184
+ pub spawn: &'a crate::transport::SpawnResult,
185
+ pub profile_dir: &'a Path,
186
+ pub dynamic_role_file: &'a Path,
187
+ pub pending: &'a crate::provider::session::PendingContextFork,
188
+ pub spawn_epoch: u64,
189
+ }
190
+
191
+ pub(super) fn finalize_pending_fork_state(
192
+ input: ForkPendingFinalizeInput<'_>,
193
+ ) -> Result<(), LifecycleError> {
194
+ let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
195
+ workspace: input.workspace,
196
+ operation: "fork-agent-pending",
197
+ team: Some(input.team_key),
198
+ agent_id: Some(input.agent_id),
199
+ })?;
200
+ let mut next_state = crate::state::selector::resolve_active_team(
201
+ input.workspace,
202
+ Some(input.team_key),
203
+ crate::state::selector::SelectorMode::RequireSpec,
204
+ )
205
+ .map_err(|error| LifecycleError::TeamSelect(error.to_string()))?
206
+ .state;
207
+ upsert_pending_forked_agent_state(
208
+ &mut next_state,
209
+ input.source_agent_id,
210
+ input.agent_id,
211
+ input.spec_agent,
212
+ input.safety,
213
+ input.plan,
214
+ input.profile_launch,
215
+ input.spawn,
216
+ Some(input.profile_dir),
217
+ input.dynamic_role_file,
218
+ input.pending,
219
+ input.spawn_epoch,
220
+ )?;
221
+ maybe_fail_fork_after_spawn("save_runtime_state")?;
222
+ crate::state::repository::StateRepository::new(input.workspace)
223
+ .save(
224
+ crate::state::repository::StateWriteIntent::ForkAgent {
225
+ team_key: input.team_key,
226
+ agent_id: input.agent_id.as_str(),
227
+ },
228
+ &next_state,
229
+ )
230
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))
231
+ }
232
+
233
+ pub(crate) fn finalize_pending_fork_capture(
234
+ agent: &mut serde_json::Map<String, serde_json::Value>,
235
+ captured: &crate::provider::CapturedSession,
236
+ ) -> bool {
237
+ let Some(session_id) = captured.session_id.as_ref() else {
238
+ return false;
239
+ };
240
+ let Some(rollout_path) = captured.rollout_path.as_ref() else {
241
+ return false;
242
+ };
243
+ if agent
244
+ .get("fork_source_session_id")
245
+ .and_then(serde_json::Value::as_str)
246
+ == Some(session_id.as_str())
247
+ {
248
+ return false;
249
+ }
250
+ agent.insert(
251
+ "session_id".to_string(),
252
+ serde_json::json!(session_id.as_str()),
253
+ );
254
+ agent.insert(
255
+ "rollout_path".to_string(),
256
+ serde_json::json!(rollout_path.as_path().to_string_lossy()),
257
+ );
258
+ agent.insert(
259
+ "captured_at".to_string(),
260
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
261
+ );
262
+ agent.insert(
263
+ "captured_via".to_string(),
264
+ serde_json::to_value(captured.captured_via).unwrap_or(serde_json::Value::Null),
265
+ );
266
+ agent.insert(
267
+ "attribution_confidence".to_string(),
268
+ serde_json::to_value(captured.attribution_confidence).unwrap_or(serde_json::Value::Null),
269
+ );
270
+ agent.remove("_pending_session_id");
271
+ agent.remove("attribution_ambiguous");
272
+ agent.remove("fork_source_session_id");
273
+ agent.remove("pending_target_agent");
274
+ agent.remove("pending_grace_secs");
275
+ agent.insert("capture_state".to_string(), serde_json::json!("captured"));
276
+ true
277
+ }
278
+
175
279
  pub(super) fn verify_fork_registration(
176
280
  workspace: &Path,
177
281
  team_key: &str,