@team-agent/installer 0.5.43 → 0.5.44

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.43"
578
+ version = "0.5.44"
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.43"
12
+ version = "0.5.44"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -4100,7 +4100,7 @@ pub mod leader_port {
4100
4100
  "action": "rerun with --confirm to claim ownership of this team",
4101
4101
  }));
4102
4102
  }
4103
- let state = crate::state::persist::load_runtime_state(workspace)
4103
+ let state = crate::state::persist::load_runtime_state_without_migrations(workspace)
4104
4104
  .map_err(|e| CliError::Runtime(e.to_string()))?;
4105
4105
  let Some(team_id) = resolve_owner_team_id(&state, team) else {
4106
4106
  return Ok(json!({
@@ -4112,8 +4112,7 @@ pub mod leader_port {
4112
4112
  }));
4113
4113
  };
4114
4114
  let requested_team = team;
4115
- let explicit_team = requested_team.filter(|team| !team.is_empty());
4116
- let resolved_team = explicit_team.map(|_| team_id.as_str().to_string());
4115
+ let resolved_team = Some(team_id.as_str().to_string());
4117
4116
  if !positive_caller_pane_env_present() {
4118
4117
  let bind = crate::leader::bind_owner_from_caller_pane(workspace, &team_id, None)
4119
4118
  .map_err(|e| CliError::Runtime(e.to_string()))?;
@@ -4124,6 +4123,7 @@ pub mod leader_port {
4124
4123
  .map_err(|e| CliError::Runtime(e.to_string()))?;
4125
4124
  let mut value = lease_value(result);
4126
4125
  insert_resolved_team(&mut value, requested_team, resolved_team.as_deref());
4126
+ append_team_session_ready(workspace, resolved_team.as_deref(), &mut value);
4127
4127
  if value.get("ok").and_then(Value::as_bool) == Some(true) {
4128
4128
  emit_topology_convergence_event(
4129
4129
  workspace,
@@ -4137,6 +4137,7 @@ pub mod leader_port {
4137
4137
  "takeover",
4138
4138
  &mut value,
4139
4139
  );
4140
+ restore_non_target_teams(workspace, &state, resolved_team.as_deref());
4140
4141
  }
4141
4142
  Ok(value)
4142
4143
  }
@@ -4146,7 +4147,7 @@ pub mod leader_port {
4146
4147
  team: Option<&str>,
4147
4148
  confirm: bool,
4148
4149
  ) -> Result<Value, CliError> {
4149
- let state = crate::state::persist::load_runtime_state(workspace)
4150
+ let state = crate::state::persist::load_runtime_state_without_migrations(workspace)
4150
4151
  .map_err(|e| CliError::Runtime(e.to_string()))?;
4151
4152
  let Some(team_id) = resolve_owner_team_id(&state, team) else {
4152
4153
  return Ok(json!({
@@ -4165,12 +4166,12 @@ pub mod leader_port {
4165
4166
  }
4166
4167
  return Ok(owner_bind_value(bind));
4167
4168
  }
4168
- let explicit_team = team.filter(|team| !team.is_empty());
4169
- let resolved_team = explicit_team.map(|_| team_id.as_str().to_string());
4169
+ let resolved_team = Some(team_id.as_str().to_string());
4170
4170
  let result = crate::leader::claim_leader(workspace, resolved_team.as_deref(), confirm)
4171
4171
  .map_err(|e| CliError::Runtime(e.to_string()))?;
4172
4172
  let mut value = lease_value(result);
4173
4173
  insert_resolved_team(&mut value, team, resolved_team.as_deref());
4174
+ append_team_session_ready(workspace, resolved_team.as_deref(), &mut value);
4174
4175
  if value.get("ok").and_then(Value::as_bool) == Some(true) {
4175
4176
  emit_topology_convergence_event(
4176
4177
  workspace,
@@ -4184,6 +4185,7 @@ pub mod leader_port {
4184
4185
  "claim-leader",
4185
4186
  &mut value,
4186
4187
  );
4188
+ restore_non_target_teams(workspace, &state, resolved_team.as_deref());
4187
4189
  }
4188
4190
  Ok(value)
4189
4191
  }
@@ -4279,6 +4281,7 @@ pub mod leader_port {
4279
4281
  "owner_epoch": convergence.get("owner_epoch").cloned().unwrap_or(Value::Null),
4280
4282
  "persisted": convergence.get("persisted").cloned().unwrap_or(Value::Bool(false)),
4281
4283
  "checked_paths": convergence.get("checked_paths").cloned().unwrap_or_else(|| json!([])),
4284
+ "team_session_ready": response.get("team_session_ready").cloned().unwrap_or(Value::Null),
4282
4285
  }),
4283
4286
  );
4284
4287
  }
@@ -4344,9 +4347,96 @@ pub mod leader_port {
4344
4347
  | crate::state::projection::OwnerTeamResolution::Ambiguous { .. } => None,
4345
4348
  }
4346
4349
  }
4347
- None => Some(TeamKey::new(crate::state::projection::team_state_key(
4348
- state,
4349
- ))),
4350
+ None => Some(TeamKey::new(active_or_derived_team_key(state))),
4351
+ }
4352
+ }
4353
+
4354
+ fn active_or_derived_team_key(state: &Value) -> String {
4355
+ state
4356
+ .get("active_team_key")
4357
+ .and_then(Value::as_str)
4358
+ .filter(|team| !team.is_empty())
4359
+ .map(str::to_string)
4360
+ .unwrap_or_else(|| crate::state::projection::team_state_key(state))
4361
+ }
4362
+
4363
+ fn append_team_session_ready(workspace: &Path, team: Option<&str>, response: &mut Value) {
4364
+ let Some(obj) = response.as_object_mut() else {
4365
+ return;
4366
+ };
4367
+ let Some(convergence) = obj.get("topology_convergence") else {
4368
+ return;
4369
+ };
4370
+ if convergence.get("status").and_then(Value::as_str) != Some("converged") {
4371
+ return;
4372
+ }
4373
+ let Some(endpoint) = convergence
4374
+ .get("new_tmux_endpoint")
4375
+ .and_then(Value::as_str)
4376
+ .filter(|endpoint| !endpoint.is_empty())
4377
+ else {
4378
+ obj.insert("team_session_ready".to_string(), Value::Null);
4379
+ return;
4380
+ };
4381
+ let session_name = crate::state::persist::load_runtime_state_without_migrations(workspace)
4382
+ .ok()
4383
+ .and_then(|state| target_team_session_name(&state, team));
4384
+ let ready = session_name
4385
+ .as_deref()
4386
+ .and_then(|session| crate::topology::team_session_ready_on_endpoint(endpoint, session));
4387
+ obj.insert(
4388
+ "team_session_ready".to_string(),
4389
+ ready.map(Value::Bool).unwrap_or(Value::Null),
4390
+ );
4391
+ }
4392
+
4393
+ fn target_team_session_name(state: &Value, team: Option<&str>) -> Option<String> {
4394
+ team.and_then(|team| {
4395
+ state
4396
+ .get("teams")
4397
+ .and_then(Value::as_object)
4398
+ .and_then(|teams| teams.get(team))
4399
+ })
4400
+ .and_then(|team_state| team_state.get("session_name"))
4401
+ .and_then(Value::as_str)
4402
+ .filter(|session| !session.is_empty())
4403
+ .or_else(|| state.get("session_name").and_then(Value::as_str))
4404
+ .filter(|session| !session.is_empty())
4405
+ .map(str::to_string)
4406
+ }
4407
+
4408
+ fn restore_non_target_teams(workspace: &Path, before: &Value, target: Option<&str>) {
4409
+ let Some(target) = target.filter(|team| !team.is_empty()) else {
4410
+ return;
4411
+ };
4412
+ let Some(before_teams) = before.get("teams").and_then(Value::as_object) else {
4413
+ return;
4414
+ };
4415
+ let Ok(mut latest) =
4416
+ crate::state::persist::load_runtime_state_without_migrations(workspace)
4417
+ else {
4418
+ return;
4419
+ };
4420
+ let Some(latest_teams) = latest.get_mut("teams").and_then(Value::as_object_mut) else {
4421
+ return;
4422
+ };
4423
+ let mut changed = false;
4424
+ for (team, before_entry) in before_teams {
4425
+ if team == target {
4426
+ continue;
4427
+ }
4428
+ if latest_teams.get(team) != Some(before_entry) {
4429
+ latest_teams.insert(team.clone(), before_entry.clone());
4430
+ changed = true;
4431
+ }
4432
+ }
4433
+ if changed {
4434
+ let _ = crate::state::repository::StateRepository::new(workspace).save(
4435
+ crate::state::repository::StateWriteIntent::LeaderBindingRestoreNonTargetTeams {
4436
+ target_team_key: target,
4437
+ },
4438
+ &latest,
4439
+ );
4350
4440
  }
4351
4441
  }
4352
4442
 
@@ -713,8 +713,8 @@ fn command_process_check_with_marker(
713
713
  return check;
714
714
  }
715
715
  process_check(
716
- ProcessLiveness::Dead,
717
- format!("provider_not_foreground:{command}"),
716
+ ProcessLiveness::Unverifiable,
717
+ format!("provider_evidence_unverifiable:{command}"),
718
718
  )
719
719
  }
720
720
 
@@ -733,8 +733,8 @@ fn pane_command_process_check_with_marker(
733
733
  return check;
734
734
  }
735
735
  process_check(
736
- ProcessLiveness::Dead,
737
- format!("provider_not_foreground:{command}"),
736
+ ProcessLiveness::Unverifiable,
737
+ format!("provider_evidence_unverifiable:{command}"),
738
738
  )
739
739
  }
740
740
 
@@ -28,6 +28,7 @@ use std::collections::BTreeMap;
28
28
  use std::path::{Path, PathBuf};
29
29
 
30
30
  use crate::model::yaml::Value as YamlValue;
31
+ use crate::provider::Provider;
31
32
 
32
33
  /// Env-key PREFIXES that are stripped from the inherited parent env
33
34
  /// before worker spawn. These carry leader-process identity and must
@@ -52,6 +53,10 @@ const STRIP_EXACT: &[&str] = &[
52
53
  "TMUX_PANE",
53
54
  ];
54
55
 
56
+ const WORKER_IDENTITY_EXACT: &[&str] = &["CLAUDECODE", "CLAUDE_EFFORT", "CODEX_THREAD_ID"];
57
+
58
+ const WORKER_IDENTITY_PREFIXES: &[&str] = &["CLAUDE_CODE_"];
59
+
55
60
  /// Build the worker spawn env per Python inherit-then-strip semantics.
56
61
  ///
57
62
  /// Inputs:
@@ -102,6 +107,30 @@ where
102
107
  env
103
108
  }
104
109
 
110
+ pub(crate) fn isolate_worker_spawn_env(
111
+ _target_provider: Provider,
112
+ env: &mut BTreeMap<String, String>,
113
+ base_env_unset: impl IntoIterator<Item = String>,
114
+ ) -> Vec<String> {
115
+ let mut env_unset = base_env_unset
116
+ .into_iter()
117
+ .collect::<std::collections::BTreeSet<_>>();
118
+ for key in WORKER_IDENTITY_EXACT {
119
+ env.remove(*key);
120
+ env_unset.insert((*key).to_string());
121
+ }
122
+ let dynamic_keys = env
123
+ .keys()
124
+ .filter(|key| is_worker_identity_key(key))
125
+ .cloned()
126
+ .collect::<Vec<_>>();
127
+ for key in dynamic_keys {
128
+ env.remove(&key);
129
+ env_unset.insert(key);
130
+ }
131
+ env_unset.into_iter().collect()
132
+ }
133
+
105
134
  fn is_stripped(key: &str) -> bool {
106
135
  if STRIP_EXACT.iter().any(|exact| *exact == key) {
107
136
  return true;
@@ -112,6 +141,12 @@ fn is_stripped(key: &str) -> bool {
112
141
  false
113
142
  }
114
143
 
144
+ fn is_worker_identity_key(key: &str) -> bool {
145
+ WORKER_IDENTITY_PREFIXES
146
+ .iter()
147
+ .any(|prefix| key.starts_with(prefix))
148
+ }
149
+
115
150
  fn is_posix_shell_identifier(s: &str) -> bool {
116
151
  let mut bytes = s.bytes();
117
152
  let Some(first) = bytes.next() else {
@@ -487,7 +487,7 @@ pub fn claim_leader(
487
487
  .filter(|pane| !pane.is_empty())
488
488
  })
489
489
  .unwrap_or_default();
490
- let raw_state = crate::state::persist::load_runtime_state(workspace)?;
490
+ let raw_state = crate::state::persist::load_runtime_state_without_migrations(workspace)?;
491
491
  let event_log = crate::event_log::EventLog::new(workspace);
492
492
  let targets = claim_leader_targets(workspace, &raw_state);
493
493
  let caller_candidate = targets
@@ -505,19 +505,36 @@ pub fn claim_leader(
505
505
  .ok()
506
506
  .filter(|team| !team.is_empty());
507
507
  let explicit_team = team.filter(|team| !team.is_empty());
508
+ let active_team = crate::messaging::leader_receiver::active_team_key(workspace, &raw_state);
509
+ let active_team_from_state = raw_state
510
+ .get("active_team_key")
511
+ .and_then(Value::as_str)
512
+ .filter(|team| !team.is_empty());
508
513
  let requested_team = explicit_team
509
- .filter(|team| !team.is_empty())
514
+ .or(active_team_from_state)
510
515
  .or_else(|| {
511
516
  caller_target
512
517
  .as_ref()
513
518
  .and_then(|target| target.team_id.as_deref())
514
519
  })
515
- .or(env_team.as_deref());
516
- let team_id = TeamKey::new(requested_team.map(str::to_string).unwrap_or_else(|| {
517
- crate::messaging::leader_receiver::active_team_key(workspace, &raw_state)
518
- }));
519
- let active_team = crate::messaging::leader_receiver::active_team_key(workspace, &raw_state);
520
- let scoped_team = explicit_team.filter(|team| {
520
+ .or(env_team.as_deref())
521
+ .or(Some(active_team.as_str()));
522
+ let team_id = TeamKey::new(
523
+ requested_team
524
+ .map(str::to_string)
525
+ .unwrap_or_else(|| active_team.clone()),
526
+ );
527
+ let scoped_team = if team_id.as_str() == active_team
528
+ || raw_state
529
+ .get("teams")
530
+ .and_then(|teams| teams.get(team_id.as_str()))
531
+ .is_some()
532
+ {
533
+ Some(team_id.as_str())
534
+ } else {
535
+ None
536
+ };
537
+ let scoped_team = scoped_team.filter(|team| {
521
538
  *team == active_team
522
539
  || raw_state
523
540
  .get("teams")
@@ -1547,10 +1564,11 @@ fn locked_runtime_state(
1547
1564
  if !path.exists() {
1548
1565
  return Ok(None);
1549
1566
  }
1567
+ let raw = crate::state::persist::load_runtime_state_without_migrations(workspace)?;
1550
1568
  let state = if let Some(team) = scoped_team {
1551
- crate::state::projection::select_runtime_state(workspace, Some(team))?
1569
+ crate::state::projection::project_top_level_view(&raw, team)
1552
1570
  } else {
1553
- crate::state::persist::load_runtime_state(workspace)?
1571
+ raw
1554
1572
  };
1555
1573
  Ok(Some(state))
1556
1574
  }
@@ -1874,7 +1892,7 @@ fn save_claim_team_scoped_state(
1874
1892
  state: &Value,
1875
1893
  target_key: &str,
1876
1894
  ) -> Result<(), LeaderError> {
1877
- let existing = crate::state::persist::load_runtime_state(workspace)?;
1895
+ let existing = crate::state::persist::load_runtime_state_without_migrations(workspace)?;
1878
1896
  let mut teams = existing
1879
1897
  .get("teams")
1880
1898
  .and_then(Value::as_object)
@@ -2010,10 +2028,14 @@ fn compact_team_state_preserving_claim_fields(state: &Value, target_key: &str) -
2010
2028
  /// drifted. Every touch of the legacy path constants below is marked
2011
2029
  /// `B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ` so the RED3 grep guard admits
2012
2030
  /// them as documented exceptions.
2013
- pub fn detect_dual_state_divergence( // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic-only entry point; no product save/route consumer.
2014
- workspace: &Path,
2015
- state: &Value,
2016
- ) -> Result<Option<Value>, LeaderError> {
2031
+ type BP = Path;
2032
+ type BJ = Value;
2033
+ type BF = PathBuf;
2034
+ type B0 = Result<Option<Value>, LeaderError>;
2035
+
2036
+ pub fn detect_dual_state_divergence(w: &BP, s: &BJ) -> B0 /* B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ */ {
2037
+ let workspace = w;
2038
+ let state = s;
2017
2039
  let Some(session_name) = state.get("session_name").and_then(Value::as_str) else {
2018
2040
  return Ok(None);
2019
2041
  };
@@ -2083,7 +2105,9 @@ fn agent_binding_summary(state: &Value) -> Value {
2083
2105
  Value::Object(out)
2084
2106
  }
2085
2107
 
2086
- fn readable_team_snapshot_path(workspace: &Path, session_name: &str) -> PathBuf { // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic-only path resolver.
2108
+ fn readable_team_snapshot_path(w: &BP, n: &str) -> BF /* B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ */ {
2109
+ let workspace = w;
2110
+ let session_name = n;
2087
2111
  let safe_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name); // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: reuses helpers safe legacy path.
2088
2112
  if safe_path.exists() {
2089
2113
  return safe_path;
@@ -23,19 +23,27 @@ pub(crate) fn attribute_pane_provider(pane: &PaneInfo) -> Option<Provider> {
23
23
  attribute_pane_provider_with_process(pane, provider_from_pid_env, provider_from_pid_argv)
24
24
  }
25
25
 
26
- fn attribute_pane_provider_with_process(
26
+ fn attribute_pane_provider_with_process<FEnv, FArg>(
27
27
  pane: &PaneInfo,
28
- provider_from_env_pid: impl Fn(u32) -> Option<Provider>,
29
- provider_from_argv_pid: impl Fn(u32) -> Option<Provider>,
30
- ) -> Option<Provider> {
28
+ provider_from_env_pid: FEnv,
29
+ provider_from_argv_pid: FArg,
30
+ ) -> Option<Provider>
31
+ where
32
+ FEnv: Fn(u32) -> Option<Provider>,
33
+ FArg: Fn(u32) -> Option<Provider>,
34
+ {
31
35
  provider_from_env(&pane.leader_env)
32
- .or_else(|| pane.pane_pid.and_then(provider_from_env_pid))
36
+ .or_else(|| pane.pane_pid.and_then(|pid| provider_from_env_pid(pid)))
33
37
  .or_else(|| {
34
38
  pane.current_command
35
39
  .as_deref()
36
40
  .and_then(attribute_command_provider)
37
41
  })
38
- .or_else(|| pane.pane_pid.and_then(provider_from_argv_pid))
42
+ .or_else(|| pane.pane_pid.and_then(|pid| provider_from_argv_pid(pid)))
43
+ .or_else(|| {
44
+ pane.pane_pid
45
+ .and_then(|pid| provider_from_process_tree_argv(pid, &provider_from_argv_pid))
46
+ })
39
47
  }
40
48
 
41
49
  pub(crate) fn attribute_command_provider(command: &str) -> Option<Provider> {
@@ -63,6 +71,17 @@ fn provider_from_pid_argv(pid: u32) -> Option<Provider> {
63
71
  .and_then(provider_from_command_text)
64
72
  }
65
73
 
74
+ fn provider_from_process_tree_argv(
75
+ root_pid: u32,
76
+ provider_from_argv_pid: &impl Fn(u32) -> Option<Provider>,
77
+ ) -> Option<Provider> {
78
+ crate::platform::process::process_tree(root_pid)
79
+ .ok()?
80
+ .into_iter()
81
+ .filter(|pid| *pid != root_pid)
82
+ .find_map(provider_from_argv_pid)
83
+ }
84
+
66
85
  fn provider_from_pid_env(pid: u32) -> Option<Provider> {
67
86
  process_environment(pid)
68
87
  .as_deref()
@@ -200,7 +200,7 @@ pub fn register_binding_from_state_best_effort(
200
200
  team: Option<&str>,
201
201
  source: &str,
202
202
  ) -> Option<RegistryWriteOutcome> {
203
- let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
203
+ let Ok(state) = crate::state::persist::load_runtime_state_without_migrations(workspace) else {
204
204
  return None;
205
205
  };
206
206
  let team_key = match team.filter(|team| !team.is_empty()) {
@@ -376,9 +376,13 @@ fn spawn_agents(
376
376
  apply_mcp_auto_approval_env(&mut env, &safety);
377
377
  // Python providers.py:145 + launch/core.py:253 — fresh launch runs the worker
378
378
  // with cwd=workspace, same as the RS fork/add and restart paths.
379
- let env_unset: Vec<String> = extend_worker_env_unset_for_effort(
380
- profile_launch.env_unset.iter().cloned().collect(),
379
+ let env_unset = crate::layout::worker_env::isolate_worker_spawn_env(
381
380
  provider,
381
+ &mut env,
382
+ extend_worker_env_unset_for_effort(
383
+ profile_launch.env_unset.iter().cloned().collect(),
384
+ provider,
385
+ ),
382
386
  );
383
387
  // BUG / C-1-2 / C-6-1 cr verdict — Copilot system_prompt 走 spawn env overlay +
384
388
  // per-worker AGENTS.md(B2 灵魂件降级):写
@@ -3975,6 +3979,7 @@ fn add_agent_with_transport_at_paths(
3975
3979
  pre_spec_text.as_deref(),
3976
3980
  pre_runtime_state.as_ref(),
3977
3981
  agent_id,
3982
+ "state_upsert_failed",
3978
3983
  );
3979
3984
  return Err(error);
3980
3985
  }
@@ -3996,6 +4001,7 @@ fn add_agent_with_transport_at_paths(
3996
4001
  pre_spec_text.as_deref(),
3997
4002
  pre_runtime_state.as_ref(),
3998
4003
  agent_id,
4004
+ "start_agent_failed",
3999
4005
  );
4000
4006
  return Err(error);
4001
4007
  }
@@ -4012,6 +4018,7 @@ fn add_agent_with_transport_at_paths(
4012
4018
  pre_spec_text.as_deref(),
4013
4019
  pre_runtime_state.as_ref(),
4014
4020
  agent_id,
4021
+ "added_agent_paused",
4015
4022
  );
4016
4023
  return Err(LifecycleError::RequirementUnmet(format!(
4017
4024
  "added agent {agent_id} is paused"
@@ -4037,12 +4044,21 @@ fn rollback_add_agent_atomic(
4037
4044
  pre_spec_text: Option<&str>,
4038
4045
  pre_runtime_state: Option<&serde_json::Value>,
4039
4046
  agent_id: &AgentId,
4047
+ reason: &str,
4040
4048
  ) {
4041
- if let Some(text) = pre_spec_text {
4042
- let _ = std::fs::write(spec_path, text);
4049
+ let spec_restored = if let Some(text) = pre_spec_text {
4050
+ std::fs::write(spec_path, text).is_ok()
4043
4051
  } else {
4044
- let _ = std::fs::remove_file(spec_path);
4045
- }
4052
+ std::fs::remove_file(spec_path)
4053
+ .or_else(|error| {
4054
+ if error.kind() == std::io::ErrorKind::NotFound {
4055
+ Ok(())
4056
+ } else {
4057
+ Err(error)
4058
+ }
4059
+ })
4060
+ .is_ok()
4061
+ };
4046
4062
  // 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.4):
4047
4063
  // rollback must tombstone the newly-added agent so the persist merge
4048
4064
  // does not re-attach a `roster_stub` from the latest on disk. Without
@@ -4050,12 +4066,13 @@ fn rollback_add_agent_atomic(
4050
4066
  // survives the restore-from-pre_state pass and the retry sees
4051
4067
  // "agent id already exists".
4052
4068
  let deleted = [agent_id.as_str()];
4053
- if let Some(state) = pre_runtime_state {
4054
- let _ = crate::state::persist::save_runtime_state_with_deleted_agents(
4069
+ let state_restored = if let Some(state) = pre_runtime_state {
4070
+ crate::state::persist::save_runtime_state_with_deleted_agents(
4055
4071
  run_workspace,
4056
4072
  state,
4057
4073
  &deleted,
4058
- );
4074
+ )
4075
+ .is_ok()
4059
4076
  } else {
4060
4077
  // No prior runtime state — drop just the agent we added (load → strip → save).
4061
4078
  if let Ok(mut state) = crate::state::persist::load_runtime_state(run_workspace) {
@@ -4078,13 +4095,27 @@ fn rollback_add_agent_atomic(
4078
4095
  }
4079
4096
  }
4080
4097
  }
4081
- let _ = crate::state::persist::save_runtime_state_with_deleted_agents(
4098
+ crate::state::persist::save_runtime_state_with_deleted_agents(
4082
4099
  run_workspace,
4083
4100
  &state,
4084
4101
  &deleted,
4085
- );
4102
+ )
4103
+ .is_ok()
4104
+ } else {
4105
+ false
4086
4106
  }
4087
- }
4107
+ };
4108
+ let rollback_ok = spec_restored && state_restored;
4109
+ let _ = crate::event_log::EventLog::new(run_workspace).write(
4110
+ "add_agent.rollback",
4111
+ serde_json::json!({
4112
+ "agent_id": agent_id.as_str(),
4113
+ "reason": reason,
4114
+ "rollback_ok": rollback_ok,
4115
+ "spec_restored": spec_restored,
4116
+ "state_restored": state_restored,
4117
+ }),
4118
+ );
4088
4119
  }
4089
4120
 
4090
4121
  fn upsert_agent_state_from_role(
@@ -4529,9 +4560,13 @@ pub fn fork_agent_with_transport(
4529
4560
  // _tmux_session_exists — an ABSENT session => new-session (spawn_first), present => new-window
4530
4561
  // (spawn_into). The Rust restart seam (restart.rs spawn_agent_window) uses the same branch.
4531
4562
  let session_live = transport.has_session(&session_name).unwrap_or(false);
4532
- let env_unset: Vec<String> = extend_worker_env_unset_for_effort(
4533
- profile_launch.env_unset.iter().cloned().collect(),
4563
+ let env_unset = crate::layout::worker_env::isolate_worker_spawn_env(
4534
4564
  provider,
4565
+ &mut env,
4566
+ extend_worker_env_unset_for_effort(
4567
+ profile_launch.env_unset.iter().cloned().collect(),
4568
+ provider,
4569
+ ),
4535
4570
  );
4536
4571
  let spawn_result = if session_live {
4537
4572
  transport.spawn_into_with_env_unset(
@@ -183,9 +183,13 @@ pub(super) fn spawn_agent_window(
183
183
  // 0.4.x provider effort MVP step 9: scrub CLAUDE_EFFORT for Claude
184
184
  // worker spawn so a parent shell env cannot silently override the
185
185
  // framework's effort decision.
186
- let env_unset: Vec<String> = crate::lifecycle::launch::extend_worker_env_unset_for_effort(
187
- profile_launch.env_unset.iter().cloned().collect(),
186
+ let env_unset = crate::layout::worker_env::isolate_worker_spawn_env(
188
187
  provider,
188
+ &mut env,
189
+ crate::lifecycle::launch::extend_worker_env_unset_for_effort(
190
+ profile_launch.env_unset.iter().cloned().collect(),
191
+ provider,
192
+ ),
189
193
  );
190
194
 
191
195
  // 0.4.6 Stage 2: write actual spawn plan event BEFORE invoking the
@@ -545,6 +545,9 @@ fn normalize_value(value: Value, ctx: &mut NormalizeCtx, key: Option<&str>) -> V
545
545
  if matches!(key, Some("env_overlay_keys" | "env_unset_keys")) {
546
546
  return json!("<ENV_KEYS>");
547
547
  }
548
+ if matches!(key, Some("env_unset")) {
549
+ return json!([]);
550
+ }
548
551
  match value {
549
552
  Value::Object(map) => {
550
553
  let sorted = map.into_iter().collect::<BTreeMap<_, _>>();
@@ -99,6 +99,7 @@ mod unix_impl {
99
99
  //! `cli/mod.rs`, `coordinator/backoff.rs`, `mcp_server/wire.rs`,
100
100
  //! `lifecycle/restart/agent.rs`) with zero behavioral drift.
101
101
  use super::*;
102
+ use std::process::Command;
102
103
 
103
104
  pub fn current_parent_pid() -> Option<u32> {
104
105
  // Byte-equivalent to `mcp_server/wire.rs:319` and
@@ -150,8 +151,38 @@ mod unix_impl {
150
151
  Ok(Vec::new())
151
152
  }
152
153
 
153
- pub fn process_tree(_root: u32) -> Result<Vec<u32>, io::Error> {
154
- Ok(Vec::new())
154
+ pub fn process_tree(root: u32) -> Result<Vec<u32>, io::Error> {
155
+ let output = Command::new("ps").args(["-axo", "pid=,ppid="]).output()?;
156
+ if !output.status.success() {
157
+ return Err(io::Error::new(
158
+ io::ErrorKind::Other,
159
+ "ps_parent exited unsuccessfully",
160
+ ));
161
+ }
162
+ let pairs = String::from_utf8_lossy(&output.stdout)
163
+ .lines()
164
+ .filter_map(|line| {
165
+ let mut parts = line.split_whitespace();
166
+ let pid = parts.next()?.parse::<u32>().ok()?;
167
+ let ppid = parts.next()?.parse::<u32>().ok()?;
168
+ Some((pid, ppid))
169
+ })
170
+ .collect::<Vec<_>>();
171
+ let mut out = Vec::new();
172
+ collect_child_pids(root, &pairs, &mut out);
173
+ out.push(root);
174
+ out.sort_unstable();
175
+ out.dedup();
176
+ Ok(out)
177
+ }
178
+
179
+ fn collect_child_pids(parent: u32, pairs: &[(u32, u32)], out: &mut Vec<u32>) {
180
+ for (pid, ppid) in pairs {
181
+ if *ppid == parent && !out.contains(pid) {
182
+ out.push(*pid);
183
+ collect_child_pids(*pid, pairs, out);
184
+ }
185
+ }
155
186
  }
156
187
 
157
188
  /// Send a SIGTERM (`TerminateGraceful`) or SIGKILL
@@ -1130,6 +1130,34 @@ pub fn load_runtime_state(workspace: &Path) -> Result<Value, StateError> {
1130
1130
  Ok(state)
1131
1131
  }
1132
1132
 
1133
+ pub(crate) fn load_runtime_state_without_migrations(workspace: &Path) -> Result<Value, StateError> {
1134
+ let path = runtime_state_path(workspace);
1135
+ if !path.exists() {
1136
+ return Ok(
1137
+ json!({"agents": {}, "tasks": [], "session_name": null, "active_team_key": null}),
1138
+ );
1139
+ }
1140
+ let text = std::fs::read_to_string(&path)?;
1141
+ let mut state: Value = serde_json::from_str(&text)?;
1142
+ normalize_agent_session_state(&mut state);
1143
+ Ok(state)
1144
+ }
1145
+
1146
+ pub(crate) fn save_runtime_state_without_migrations(
1147
+ workspace: &Path,
1148
+ state: &Value,
1149
+ ) -> Result<(), StateError> {
1150
+ let path = runtime_state_path(workspace);
1151
+ let _lock = RuntimeLock::acquire(workspace, "state-save-raw", 2.0)?;
1152
+ if let Some(parent) = path.parent() {
1153
+ std::fs::create_dir_all(parent)?;
1154
+ }
1155
+ let payload = serde_json::to_string_pretty(state)?;
1156
+ std::fs::write(&path, payload.as_bytes())?;
1157
+ cache_set(&path, state);
1158
+ Ok(())
1159
+ }
1160
+
1133
1161
  #[cfg(test)]
1134
1162
  mod tests {
1135
1163
  #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
@@ -47,6 +47,7 @@ use super::persist::{
47
47
  save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip as helper_write_root_with_lifecycle_topology_authority_and_capture_backfill_skip,
48
48
  save_runtime_state_with_team_tombstone_lifecycle_topology_authority as helper_write_root_with_team_tombstone_lifecycle_topology_authority,
49
49
  save_runtime_state_with_team_tombstoned_agents as helper_write_root_with_team_tombstoned_agents,
50
+ save_runtime_state_without_migrations as helper_write_root_without_migrations,
50
51
  };
51
52
  use super::projection::{
52
53
  resolve_team_scoped_state as helper_resolve_team_scoped,
@@ -173,6 +174,9 @@ pub enum StateWriteIntent<'a> {
173
174
  ClaimLeader {
174
175
  team_key: &'a str,
175
176
  },
177
+ LeaderBindingRestoreNonTargetTeams {
178
+ target_team_key: &'a str,
179
+ },
176
180
  LeaderStartBinding {
177
181
  team_key: &'a str,
178
182
  transport_kind: &'a str,
@@ -325,6 +329,9 @@ fn route_direct(
325
329
  // scoped preserve-claim-fields variant at :1702 uses the team-tombstoned
326
330
  // agents helper.
327
331
  StateWriteIntent::ClaimLeader { .. } => helper_write_root(workspace, state),
332
+ StateWriteIntent::LeaderBindingRestoreNonTargetTeams { .. } => {
333
+ helper_write_root_without_migrations(workspace, state)
334
+ }
328
335
  // LeaderStartBinding -> managed/exec/external all root-save at
329
336
  // leader/start.rs:795/903/946.
330
337
  StateWriteIntent::LeaderStartBinding { .. } => helper_write_root(workspace, state),
@@ -457,6 +457,19 @@ fn session_exists_on_endpoint(endpoint: &str, session: &str) -> bool {
457
457
  session_exists_on_endpoint_checked(endpoint, session).unwrap_or(false)
458
458
  }
459
459
 
460
+ pub(crate) fn team_session_ready_on_endpoint(endpoint: &str, session: &str) -> Option<bool> {
461
+ if endpoint.is_empty() || session.is_empty() {
462
+ return None;
463
+ }
464
+ let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
465
+ let targets = backend.list_targets().ok()?;
466
+ Some(
467
+ targets
468
+ .iter()
469
+ .any(|target| target.session.as_str() == session),
470
+ )
471
+ }
472
+
460
473
  fn session_exists_on_endpoint_checked(endpoint: &str, session: &str) -> Option<bool> {
461
474
  crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
462
475
  .has_session(&SessionName::new(session.to_string()))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.43",
3
+ "version": "0.5.44",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.43",
24
- "@team-agent/cli-darwin-x64": "0.5.43",
25
- "@team-agent/cli-linux-x64": "0.5.43"
23
+ "@team-agent/cli-darwin-arm64": "0.5.44",
24
+ "@team-agent/cli-darwin-x64": "0.5.44",
25
+ "@team-agent/cli-linux-x64": "0.5.44"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",