@team-agent/installer 0.5.43 → 0.5.45

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.
@@ -55,8 +55,17 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
55
55
  "--to-name requires a non-empty message".to_string(),
56
56
  ));
57
57
  }
58
+ // 0.5.45 naming-addressing (design §3.1, RED-1): thread
59
+ // `--team` down to resolver so bare `--to-name agent --team T`
60
+ // scopes to `T` BEFORE workspace scanning. Qualified addresses
61
+ // (`team/agent`, `workspace::team/agent`) ignore the scope
62
+ // per §1 priority ladder.
58
63
  let (resolved, transport) =
59
- match crate::cli::named_address::resolve_name_for_cli(&args.workspace, to_name) {
64
+ match crate::cli::named_address::resolve_name_for_cli(
65
+ &args.workspace,
66
+ to_name,
67
+ args.team.as_deref(),
68
+ ) {
60
69
  Ok(resolved) => resolved,
61
70
  Err(error) => {
62
71
  // E6 (0.5.9 offline-mailbox-toname-design §3.1/§6.2): when
@@ -166,6 +175,15 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
166
175
  outcome = observe_initial_delivery_for_watch(&selected, &target, &outcome, &opts)?;
167
176
  }
168
177
  let mut value = delivery_outcome_json(&outcome, &target, &content, &opts);
178
+ // 0.5.45 naming-addressing (design §3.5, RED-2/RED-3 positional):
179
+ // when the refusal reason is `target_not_in_team` AND the target
180
+ // was a Single non-special short id, attach scope-safe
181
+ // suggestions ranked from `selected.state.agents` — never from
182
+ // raw workspace `teams` (design §3.5 & risk table). Zero DB
183
+ // write / zero inject: the request stays refused, only the JSON
184
+ // envelope gains `requested_name`/`suggested_name`/`candidates`
185
+ // and the human `Action` gains a "Did you mean" line.
186
+ attach_positional_typo_suggestions(&mut value, &target, &selected.state);
169
187
  append_loud_ensure_fields(&mut value, coordinator_ensure.as_ref());
170
188
  if opts.watch_result && initial_delivery_allows_watch(outcome.status) {
171
189
  if let Some(obj) = value.as_object_mut() {
@@ -773,6 +791,67 @@ fn watch_notice_json(target: &MessageTarget, opts: &SendOptions) -> Value {
773
791
  })
774
792
  }
775
793
 
794
+ /// 0.5.45 naming-addressing (design §3.5, RED-2/RED-3 positional):
795
+ /// after `messaging::send_message` refuses with `target_not_in_team`
796
+ /// for a Single non-special short id, attach scope-safe advisory
797
+ /// suggestions to the outbound JSON envelope. Candidate source =
798
+ /// selected team's projected `agents` map (never the raw workspace
799
+ /// `teams`) so sibling teams cannot leak. Zero DB write, zero inject
800
+ /// — the refusal exit code is unchanged.
801
+ fn attach_positional_typo_suggestions(
802
+ value: &mut Value,
803
+ target: &MessageTarget,
804
+ selected_state: &Value,
805
+ ) {
806
+ use crate::model::name_similarity::{rank, Candidate};
807
+ let requested = match target {
808
+ MessageTarget::Single(id) if id != "*" && id != "leader" => id.clone(),
809
+ _ => return,
810
+ };
811
+ let Some(obj) = value.as_object_mut() else {
812
+ return;
813
+ };
814
+ if obj.get("reason").and_then(Value::as_str) != Some("target_not_in_team") {
815
+ return;
816
+ }
817
+ let team_key = selected_state
818
+ .get("active_team_key")
819
+ .or_else(|| selected_state.get("team_key"))
820
+ .and_then(Value::as_str)
821
+ .unwrap_or("");
822
+ let candidates: Vec<Candidate<String>> = selected_state
823
+ .get("agents")
824
+ .and_then(Value::as_object)
825
+ .map(|agents| {
826
+ agents
827
+ .keys()
828
+ .map(|agent_id| Candidate {
829
+ match_key: agent_id.clone(),
830
+ stable_key: agent_id.clone(),
831
+ payload: agent_id.clone(),
832
+ })
833
+ .collect()
834
+ })
835
+ .unwrap_or_default();
836
+ let ranked = rank(&requested, &candidates);
837
+ let candidate_values: Vec<Value> = ranked
838
+ .iter()
839
+ .map(|agent_id| {
840
+ json!({
841
+ "name": agent_id,
842
+ "team_key": team_key,
843
+ "agent_id": agent_id,
844
+ "advisory": true,
845
+ })
846
+ })
847
+ .collect();
848
+ obj.insert("requested_name".to_string(), json!(requested));
849
+ if let Some(best) = ranked.first() {
850
+ obj.insert("suggested_name".to_string(), json!(best));
851
+ }
852
+ obj.insert("candidates".to_string(), Value::Array(candidate_values));
853
+ }
854
+
776
855
  fn delivery_outcome_json(
777
856
  outcome: &DeliveryOutcome,
778
857
  target: &MessageTarget,
@@ -973,6 +1052,25 @@ fn send_human_output(value: &Value) -> String {
973
1052
  parts.push(send_human_field(value, key));
974
1053
  }
975
1054
  }
1055
+ // 0.5.45 naming-addressing (design §3.4/§3.5, RED-3 positional):
1056
+ // when the refusal envelope carries a scope-safe suggestion,
1057
+ // surface it verbatim in human output so users can copy the
1058
+ // right short id. `requested_name` echoes the typo, `suggested_
1059
+ // name` is the copyable canonical.
1060
+ if let Some(requested) = value
1061
+ .get("requested_name")
1062
+ .and_then(Value::as_str)
1063
+ .filter(|s| !s.is_empty())
1064
+ {
1065
+ parts.push(format!("requested_name: {requested}"));
1066
+ }
1067
+ if let Some(suggested) = value
1068
+ .get("suggested_name")
1069
+ .and_then(Value::as_str)
1070
+ .filter(|s| !s.is_empty())
1071
+ {
1072
+ parts.push(format!("Did you mean `{suggested}`? suggested_name: {suggested}"));
1073
+ }
976
1074
  parts.join(" ")
977
1075
  }
978
1076
 
@@ -1250,8 +1348,13 @@ pub fn send_to_canonical_leader_target(
1250
1348
  // synthesized `<workspace>::<team_key>/leader` name so live inject +
1251
1349
  // mailbox both go through one code path.
1252
1350
  let to_name = format!("{}::{}/leader", entry.workspace.display(), entry.team_key);
1351
+ // 0.5.45 naming-addressing (design §3.1 / §4.1): internal
1352
+ // registry-to-E6 delegation MUST pass None for bare_team_scope.
1353
+ // The synthesized name above is a full `workspace::team/leader`
1354
+ // form, and the caller's `--team` flag (if any) must not
1355
+ // override the registry's authoritative team_key.
1253
1356
  let (resolved, transport) =
1254
- match crate::cli::named_address::resolve_name_for_cli(sender_workspace, &to_name) {
1357
+ match crate::cli::named_address::resolve_name_for_cli(sender_workspace, &to_name, None) {
1255
1358
  Ok(r) => r,
1256
1359
  Err(err) => {
1257
1360
  // Named-address refusal — surface it verbatim but tag as
@@ -153,7 +153,7 @@ pub(crate) struct CommandSpec {
153
153
  #[rustfmt::skip]
154
154
  pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
155
155
  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 },
156
- CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "send a message/task", usage: "usage: team-agent send TARGET MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] [--to-name NAME] [--pane PANE] [--task TASK] [--sender SENDER] [--watch-result] [--requires-ack|--no-ack] [--no-wait] [--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
156
+ CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "send a message/task", usage: "usage: team-agent send TARGET MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] [--to-name NAME | --to-name agent | --to-name team/agent | --to-name workspace::team/agent] [--pane PANE] [--task TASK] [--sender SENDER] [--watch-result] [--requires-ack|--no-ack] [--no-wait] [--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
157
157
  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 },
158
158
  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 },
159
159
  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 },
@@ -125,12 +125,15 @@ fn run_dispatches_send_to_handler_returns_ok() {
125
125
  }
126
126
 
127
127
  #[test]
128
- fn run_unknown_subcommand_is_usage() {
129
- // An unknown subcommand routes to argparse-style usage (exit 2), never a handler.
128
+ fn run_unknown_subcommand_is_error_not_usage() {
129
+ // 0.5.45 naming-addressing (RED-6): unknown subcommand exits 1
130
+ // (Error), aligned with the shared refusal shape used by
131
+ // `send`/`--to-name` typos. Pre-0.5.45 argparse-style Usage (2)
132
+ // was internal drift.
130
133
  assert_eq!(
131
134
  run(&["totally-not-a-subcommand".to_string()], Path::new(".")),
132
- ExitCode::Usage,
133
- "an unknown subcommand must map to ExitCode::Usage"
135
+ ExitCode::Error,
136
+ "unknown subcommand must map to ExitCode::Error (aligned with send/--to-name typo family)"
134
137
  );
135
138
  }
136
139
 
@@ -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<_, _>>();