@team-agent/installer 0.3.6 → 0.3.8

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.
Files changed (45) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +52 -7
  4. package/crates/team-agent/src/cli/diagnose.rs +9 -0
  5. package/crates/team-agent/src/cli/emit.rs +175 -0
  6. package/crates/team-agent/src/cli/mod.rs +455 -63
  7. package/crates/team-agent/src/cli/status_port.rs +62 -0
  8. package/crates/team-agent/src/cli/tests/base.rs +9 -4
  9. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +83 -1
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/run_delegation.rs +10 -2
  12. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +86 -21
  13. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +76 -0
  14. package/crates/team-agent/src/cli/types.rs +3 -2
  15. package/crates/team-agent/src/compiler.rs +73 -50
  16. package/crates/team-agent/src/coordinator/tick.rs +108 -20
  17. package/crates/team-agent/src/db/migration.rs +17 -1
  18. package/crates/team-agent/src/leader/owner_bind.rs +59 -20
  19. package/crates/team-agent/src/lifecycle/launch.rs +378 -56
  20. package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
  21. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +91 -12
  22. package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
  23. package/crates/team-agent/src/lifecycle/tests/core.rs +238 -3
  24. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +257 -7
  25. package/crates/team-agent/src/lifecycle/types.rs +2 -0
  26. package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
  27. package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
  28. package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
  29. package/crates/team-agent/src/mcp_server/tools.rs +25 -1
  30. package/crates/team-agent/src/mcp_server/wire.rs +11 -1
  31. package/crates/team-agent/src/model/paths.rs +7 -0
  32. package/crates/team-agent/src/model/spec.rs +23 -1
  33. package/crates/team-agent/src/packaging/install.rs +42 -4
  34. package/crates/team-agent/src/packaging/tests.rs +91 -14
  35. package/crates/team-agent/src/packaging/types.rs +13 -1
  36. package/crates/team-agent/src/provider/adapter.rs +381 -15
  37. package/crates/team-agent/src/state/identity.rs +29 -0
  38. package/crates/team-agent/src/state/selector.rs +48 -14
  39. package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
  40. package/crates/team-agent/src/tmux_backend.rs +104 -9
  41. package/crates/team-agent/src/transport/test_support.rs +57 -4
  42. package/crates/team-agent/src/transport.rs +13 -0
  43. package/npm/install.mjs +31 -35
  44. package/package.json +4 -4
  45. package/skills/team-agent/SKILL.md +82 -5
@@ -139,7 +139,20 @@ fn spawn_agents(
139
139
  safety: &DangerousApproval,
140
140
  transport: &dyn Transport,
141
141
  ) -> Result<Vec<StartedAgent>, LifecycleError> {
142
- let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
142
+ // E5 解耦:team_dir(角色定义 + profiles 所在)≠ spec_path.parent()(spec 已迁出到 .team/runtime)
143
+ // 优先取 state.team_dir(角色目录),回落 spec_path.parent()(legacy 同目录布局)。
144
+ let team_dir_buf = crate::state::persist::load_runtime_state(workspace)
145
+ .ok()
146
+ .and_then(|state| {
147
+ state
148
+ .get("team_dir")
149
+ .and_then(serde_json::Value::as_str)
150
+ .filter(|s| !s.is_empty())
151
+ .map(PathBuf::from)
152
+ });
153
+ let team_dir = team_dir_buf
154
+ .as_deref()
155
+ .unwrap_or_else(|| spec_path.parent().unwrap_or_else(|| Path::new(".")));
143
156
  let runtime_fast = matches!(
144
157
  spec.get("runtime").and_then(|v| v.get("fast")),
145
158
  Some(Value::Bool(true))
@@ -313,6 +326,28 @@ fn spawn_agents(
313
326
  );
314
327
  }
315
328
  }
329
+ // E6 层1 实证3 + 诊断留痕:落最终 worker argv(spawn 前的真实形态)。
330
+ // 任何"--session-id 预定 UUID 没生效"必须能从 events.jsonl 回答:argv 里到底有没有它。
331
+ // 抽出 --session-id 值单列,方便和盘上 ~/.claude/projects/<cwd> 实际落的 UUID 对账。
332
+ {
333
+ let session_id_in_argv = plan
334
+ .argv
335
+ .iter()
336
+ .position(|a| a == "--session-id")
337
+ .and_then(|i| plan.argv.get(i + 1))
338
+ .cloned();
339
+ let event_log = crate::event_log::EventLog::new(workspace);
340
+ let _ = event_log.write(
341
+ "provider.worker.spawn_argv",
342
+ serde_json::json!({
343
+ "agent_id": agent_id_raw,
344
+ "provider": provider,
345
+ "argv": plan.argv,
346
+ "session_id_in_argv": session_id_in_argv,
347
+ "expected_session_id": plan.expected_session_id.as_ref().map(|s| s.as_str()),
348
+ }),
349
+ );
350
+ }
316
351
  let spawn = if started.is_empty() {
317
352
  transport.spawn_first_with_env_unset(
318
353
  session_name,
@@ -402,7 +437,14 @@ fn persist_spawn_agent_state(
402
437
  .map(|agent| agent.agent_id.as_str().to_string())
403
438
  .collect();
404
439
  let pane_pids_by_agent = pane_pids_by_started_agent(transport, started);
405
- let profile_dir = spec_path.parent().unwrap_or(workspace).join("profiles");
440
+ // E5 解耦:profiles 随**角色定义**(team_dir),不随 spec(已迁出到 .team/runtime)
441
+ // 优先 state.team_dir(角色目录),回落 spec_path.parent()(legacy 同目录布局)。
442
+ let profile_dir = state
443
+ .get("team_dir")
444
+ .and_then(serde_json::Value::as_str)
445
+ .filter(|s| !s.is_empty())
446
+ .map(|dir| Path::new(dir).join("profiles"))
447
+ .unwrap_or_else(|| spec_path.parent().unwrap_or(workspace).join("profiles"));
406
448
  let mut agents = serde_json::Map::new();
407
449
  let mut spawn_index = 0_u32;
408
450
  for agent in spec_agent_values(spec) {
@@ -720,6 +762,171 @@ fn env_nonempty(key: &str) -> bool {
720
762
  .is_some_and(|value| !value.is_empty())
721
763
  }
722
764
 
765
+ /// B-7 / 036b — TEAM_AGENT_LEADER_PANE_ID 主动路径 fail-fast helper。
766
+ /// 入口形态(N38 三行式):
767
+ /// error : `TEAM_AGENT_LEADER_PANE_ID points at a dead/absent pane: %<id>`
768
+ /// action : `unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id`
769
+ /// log : `TEAM_AGENT_LEADER_PANE_ID=%<id>`
770
+ /// env 未设(或空)→ Ok(())。
771
+ /// env 设了但 pane 可判定为 Dead/Absent → Err(RequirementUnmet)。
772
+ /// 真实 tmux 后端跨所有现存 tmux socket server 探测:TEAM_AGENT_LEADER_PANE_ID 是用户
773
+ /// override 指针,不归属当前 team socket。
774
+ /// probe 返 Unknown 不挡(被动路径降级):本主动路径只对【显式 Dead/Absent】fail-fast,
775
+ /// MUST-17 不过度设计 / unset 走 pass-through(b7_unset_leader_pane_env_passes_through 守)。
776
+ pub(crate) fn validate_active_leader_pane_env(
777
+ transport: &dyn Transport,
778
+ ) -> Result<(), LifecycleError> {
779
+ validate_active_leader_pane_env_with_workspaces(transport, &[])
780
+ }
781
+
782
+ pub(crate) fn validate_active_leader_pane_env_with_workspace(
783
+ transport: &dyn Transport,
784
+ workspace: Option<&Path>,
785
+ ) -> Result<(), LifecycleError> {
786
+ let workspaces = workspace.into_iter().collect::<Vec<_>>();
787
+ validate_active_leader_pane_env_with_workspaces(transport, &workspaces)
788
+ }
789
+
790
+ pub(crate) fn validate_active_leader_pane_env_with_workspaces(
791
+ transport: &dyn Transport,
792
+ workspaces: &[&Path],
793
+ ) -> Result<(), LifecycleError> {
794
+ let pane_id_raw = match std::env::var("TEAM_AGENT_LEADER_PANE_ID") {
795
+ Ok(v) if !v.is_empty() => v,
796
+ _ => return Ok(()),
797
+ };
798
+ let pane = crate::transport::PaneId::new(&pane_id_raw);
799
+ if !is_tmux_pane_id_format(&pane) {
800
+ write_invalid_leader_pane_env_warning(workspaces, &pane_id_raw);
801
+ return Ok(());
802
+ }
803
+ let failure = match leader_pane_env_state_for_validation(transport, &pane) {
804
+ LeaderPaneEnvState::Dead => Some("dead"),
805
+ LeaderPaneEnvState::Absent => Some("absent"),
806
+ LeaderPaneEnvState::Live | LeaderPaneEnvState::Unknown => None,
807
+ };
808
+ let Some(reason) = failure else {
809
+ return Ok(());
810
+ };
811
+ Err(LifecycleError::RequirementUnmet(format!(
812
+ "TEAM_AGENT_LEADER_PANE_ID points at a {reason} pane: {pane_id_raw}\n\
813
+ action: unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id\n\
814
+ log: TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}"
815
+ )))
816
+ }
817
+
818
+ fn write_invalid_leader_pane_env_warning(workspaces: &[&Path], pane_id_raw: &str) {
819
+ let message = "invalid pane id format, skipping validation";
820
+ let mut wrote = false;
821
+ let mut errors = Vec::new();
822
+ let mut seen = BTreeSet::new();
823
+ for workspace in workspaces {
824
+ let key = workspace.to_string_lossy().to_string();
825
+ if !seen.insert(key.clone()) {
826
+ continue;
827
+ }
828
+ match crate::event_log::EventLog::new(workspace).write(
829
+ "leader_pane_env.validation_warning",
830
+ serde_json::json!({
831
+ "env": "TEAM_AGENT_LEADER_PANE_ID",
832
+ "value": pane_id_raw,
833
+ "warning": message,
834
+ }),
835
+ ) {
836
+ Ok(_) => wrote = true,
837
+ Err(err) => errors.push(format!("{key}: {err}")),
838
+ }
839
+ }
840
+ if !wrote {
841
+ eprintln!("TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}: {message}");
842
+ if !errors.is_empty() {
843
+ eprintln!(
844
+ "TEAM_AGENT_LEADER_PANE_ID warning event write failed: {}",
845
+ errors.join("; ")
846
+ );
847
+ }
848
+ }
849
+ }
850
+
851
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
852
+ pub(crate) enum LeaderPaneEnvState {
853
+ Live,
854
+ Dead,
855
+ Absent,
856
+ Unknown,
857
+ }
858
+
859
+ fn leader_pane_env_state_for_validation(
860
+ transport: &dyn Transport,
861
+ pane: &crate::transport::PaneId,
862
+ ) -> LeaderPaneEnvState {
863
+ if !is_tmux_pane_id_format(pane) {
864
+ return LeaderPaneEnvState::Unknown;
865
+ }
866
+ if transport.probes_real_tmux_socket_roots() {
867
+ return active_leader_pane_state_across_tmux_sockets(pane);
868
+ }
869
+ active_leader_pane_state(transport, pane)
870
+ }
871
+
872
+ fn is_tmux_pane_id_format(pane: &crate::transport::PaneId) -> bool {
873
+ let pane = pane.as_str();
874
+ pane.len() > 1 && pane.starts_with('%') && pane[1..].chars().all(|ch| ch.is_ascii_digit())
875
+ }
876
+
877
+ fn active_leader_pane_state_across_tmux_sockets(
878
+ pane: &crate::transport::PaneId,
879
+ ) -> LeaderPaneEnvState {
880
+ let endpoints = crate::tmux_backend::tmux_socket_endpoints();
881
+ let transports = endpoints
882
+ .iter()
883
+ .map(|endpoint| crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint))
884
+ .collect::<Vec<_>>();
885
+ active_leader_pane_state_across_transports(
886
+ transports.iter().map(|transport| transport as &dyn Transport),
887
+ pane,
888
+ )
889
+ }
890
+
891
+ pub(crate) fn active_leader_pane_state_across_transports<'a>(
892
+ transports: impl IntoIterator<Item = &'a dyn Transport>,
893
+ pane: &crate::transport::PaneId,
894
+ ) -> LeaderPaneEnvState {
895
+ let mut found_absent = false;
896
+ let mut found_dead = false;
897
+ for transport in transports {
898
+ match active_leader_pane_state(transport, pane) {
899
+ LeaderPaneEnvState::Live => return LeaderPaneEnvState::Live,
900
+ LeaderPaneEnvState::Dead => found_dead = true,
901
+ LeaderPaneEnvState::Absent => found_absent = true,
902
+ LeaderPaneEnvState::Unknown => {}
903
+ }
904
+ }
905
+ if found_dead {
906
+ LeaderPaneEnvState::Dead
907
+ } else if found_absent {
908
+ LeaderPaneEnvState::Absent
909
+ } else {
910
+ LeaderPaneEnvState::Unknown
911
+ }
912
+ }
913
+
914
+ fn active_leader_pane_state(
915
+ transport: &dyn Transport,
916
+ pane: &crate::transport::PaneId,
917
+ ) -> LeaderPaneEnvState {
918
+ match transport.has_pane(pane) {
919
+ Ok(Some(true)) => return LeaderPaneEnvState::Live,
920
+ Ok(Some(false)) => return LeaderPaneEnvState::Absent,
921
+ Ok(None) | Err(_) => {}
922
+ }
923
+ match transport.liveness(pane) {
924
+ Ok(crate::transport::PaneLiveness::Live) => LeaderPaneEnvState::Live,
925
+ Ok(crate::transport::PaneLiveness::Dead) => LeaderPaneEnvState::Dead,
926
+ Ok(crate::transport::PaneLiveness::Unknown) | Err(_) => LeaderPaneEnvState::Unknown,
927
+ }
928
+ }
929
+
723
930
  fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
724
931
  let Some(owner) = unbound_launched_owner(launched, launched_key) else {
725
932
  return;
@@ -1809,6 +2016,14 @@ pub fn quick_start_with_transport_in_workspace(
1809
2016
  team_id: Option<&str>,
1810
2017
  transport: &dyn Transport,
1811
2018
  ) -> Result<QuickStartReport, LifecycleError> {
2019
+ // B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
2020
+ // 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
2021
+ // owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
2022
+ // 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
2023
+ // 或修 env)/log(env var 名)。
2024
+ let team_workspace = team_workspace(agents_dir);
2025
+ let warning_workspaces = [workspace, team_workspace.as_path()];
2026
+ validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
1812
2027
  if !agents_dir.exists() {
1813
2028
  return Err(LifecycleError::Compile(format!(
1814
2029
  "agents dir not found: {}",
@@ -1844,18 +2059,40 @@ pub fn quick_start_with_transport_in_workspace(
1844
2059
  .as_deref()
1845
2060
  .is_none_or(|team| runtime_state_has_quick_start_team(&state, team))
1846
2061
  {
2062
+ let session_name = state
2063
+ .get("session_name")
2064
+ .and_then(serde_json::Value::as_str)
2065
+ .filter(|s| !s.is_empty())
2066
+ .map(SessionName::new);
2067
+ let attach_commands = session_name
2068
+ .as_ref()
2069
+ .map(|session| {
2070
+ let windows = quick_start_attach_window_names(&state);
2071
+ crate::tmux_backend::attach_commands_for_windows(
2072
+ &workspace,
2073
+ session,
2074
+ windows.iter().map(String::as_str),
2075
+ )
2076
+ })
2077
+ .unwrap_or_default();
2078
+ let mut next_actions = vec![
2079
+ "run restart to resume the existing team or pass --fresh to replace it"
2080
+ .to_string(),
2081
+ ];
2082
+ if session_name.is_some() {
2083
+ if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
2084
+ next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(
2085
+ &workspace,
2086
+ ));
2087
+ }
2088
+ next_actions.extend(attach_commands.iter().cloned());
2089
+ }
1847
2090
  return Ok(QuickStartReport::ExistingRuntime {
1848
2091
  team: requested_team.clone(),
1849
- session_name: state
1850
- .get("session_name")
1851
- .and_then(serde_json::Value::as_str)
1852
- .filter(|s| !s.is_empty())
1853
- .map(SessionName::new),
2092
+ session_name,
1854
2093
  state_path: Some(state_path),
1855
- next_actions: vec![
1856
- "run restart to resume the existing team or pass --fresh to replace it"
1857
- .to_string(),
1858
- ],
2094
+ next_actions,
2095
+ attach_commands,
1859
2096
  });
1860
2097
  }
1861
2098
  }
@@ -1869,13 +2106,14 @@ pub fn quick_start_with_transport_in_workspace(
1869
2106
  override_spec_session_name(&mut spec, &format!("team-{requested}"));
1870
2107
  }
1871
2108
  let session_name = spec_session_name(&spec);
2109
+ // team_key 身份源 = team_dir(agents_dir).name(角色定义目录),不依赖 spec 落点。
1872
2110
  let state_team_key = explicit_team_key.clone().unwrap_or_else(|| {
1873
- let spec_path = agents_dir.join("team.spec.yaml");
1874
- runtime_team_key_for_spec(&spec_path, &spec, &session_name)
2111
+ runtime_team_key_for_spec(&agents_dir.join("team.spec.yaml"), &spec, &session_name)
1875
2112
  });
1876
- let spec_path = agents_dir.join("team.spec.yaml");
1877
- std::fs::write(&spec_path, yaml::dumps(&spec))
1878
- .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", spec_path.display())))?;
2113
+ // E5 spec 迁移:spec 写到 .team/runtime/<team_key>/(中间产物,绝不落用户目录 agents_dir)
2114
+ // Bug2:原子写(tmp+rename),避免半截 spec
2115
+ let spec_path = crate::model::paths::runtime_spec_path(&workspace, &state_team_key);
2116
+ write_spec_atomic(&spec_path, &spec)?;
1879
2117
  let _store = crate::message_store::MessageStore::open(&workspace)
1880
2118
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1881
2119
  let resolved_spec_path =
@@ -1931,6 +2169,9 @@ pub fn quick_start_with_transport_in_workspace(
1931
2169
  let mut next_actions = vec![format!(
1932
2170
  "team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
1933
2171
  )];
2172
+ if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
2173
+ next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(&workspace));
2174
+ }
1934
2175
  next_actions.extend(attach_commands.iter().cloned());
1935
2176
  let display_backend = state
1936
2177
  .get("display_backend")
@@ -1947,6 +2188,29 @@ pub fn quick_start_with_transport_in_workspace(
1947
2188
  })
1948
2189
  }
1949
2190
 
2191
+ fn quick_start_attach_window_names(state: &serde_json::Value) -> Vec<String> {
2192
+ let mut windows = state
2193
+ .get("agents")
2194
+ .and_then(serde_json::Value::as_object)
2195
+ .map(|agents| {
2196
+ agents
2197
+ .iter()
2198
+ .filter_map(|(agent_id, agent)| {
2199
+ agent
2200
+ .get("window")
2201
+ .and_then(serde_json::Value::as_str)
2202
+ .filter(|window| !window.is_empty())
2203
+ .map(str::to_string)
2204
+ .or_else(|| Some(agent_id.clone()))
2205
+ })
2206
+ .collect::<Vec<_>>()
2207
+ })
2208
+ .unwrap_or_default();
2209
+ windows.sort();
2210
+ windows.dedup();
2211
+ windows
2212
+ }
2213
+
1950
2214
  /// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
1951
2215
  /// runtime state. Reads `agents[*].status`; any non-`running` agent flips the
1952
2216
  /// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
@@ -2268,9 +2532,8 @@ pub fn add_agent(
2268
2532
  }
2269
2533
  Err(error) => return Err(LifecycleError::TeamSelect(error.to_string())),
2270
2534
  };
2271
- let team_dir = selected.spec_workspace.ok_or_else(|| {
2272
- LifecycleError::TeamSelect("active team spec workspace not found".to_string())
2273
- })?;
2535
+ // E5 §3:compile_team 要角色定义目录(team_dir),不是 spec 落点(spec_workspace=runtime)。
2536
+ let team_dir = selected.team_dir;
2274
2537
  add_agent_with_transport_at_paths(
2275
2538
  &selected.run_workspace,
2276
2539
  &team_dir,
@@ -2336,21 +2599,40 @@ fn add_agent_with_transport_at_paths(
2336
2599
  "agent id already exists: {agent_id}"
2337
2600
  )));
2338
2601
  }
2339
- let dynamic_role_file = materialize_added_role_file(team_dir, agent_id, role_file_path)?;
2340
- let spec = crate::compiler::compile_team(team_dir)
2602
+ // E5 Bug1:不再 copy role 文件进 <team_dir>/agents(自拷贝 O_TRUNC 截断反模式)
2603
+ // 就地读外部 role 文档编译,注入 base team spec agents/routing。role 文件留在原处。
2604
+ let mut spec = crate::compiler::compile_team(team_dir)
2341
2605
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2606
+ let workspace_s = spec
2607
+ .get("team")
2608
+ .and_then(|team| team.get("workspace"))
2609
+ .and_then(Value::as_str)
2610
+ .unwrap_or_else(|| team_dir.to_str().unwrap_or_default())
2611
+ .to_string();
2612
+ let team_meta = crate::compiler::read_front_matter(&team_dir.join("TEAM.md"))
2613
+ .map(|(meta, _)| meta)
2614
+ .unwrap_or(Value::Null);
2615
+ let compiled = crate::compiler::compile_role_agent(role_file_path, &team_meta, &workspace_s)
2616
+ .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2617
+ if compiled.id != agent_id.as_str() {
2618
+ return Err(LifecycleError::Compile(format!(
2619
+ "role file declares name '{}' but add-agent id is '{}'",
2620
+ compiled.id, agent_id
2621
+ )));
2622
+ }
2623
+ inject_agent_into_spec(&mut spec, compiled.agent, &compiled.id)?;
2342
2624
  let safety = effective_runtime_config(&spec)?;
2343
- let spec_path = team_dir.join("team.spec.yaml");
2344
- std::fs::write(&spec_path, yaml::dumps(&spec))
2345
- .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", spec_path.display())))?;
2346
- let (meta, _) = crate::compiler::read_front_matter(&dynamic_role_file)
2625
+ // E5 spec 迁移:重编译的 spec 原子写到 .team/runtime/<team_key>/(不落用户目录 team_dir)
2626
+ let spec_path = crate::model::paths::runtime_spec_path(run_workspace, &canonical_team_key);
2627
+ write_spec_atomic(&spec_path, &spec)?;
2628
+ let (meta, _) = crate::compiler::read_front_matter(role_file_path)
2347
2629
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2348
2630
  upsert_agent_state_from_role(
2349
2631
  run_workspace,
2350
2632
  &canonical_team_key,
2351
2633
  agent_id,
2352
2634
  &meta,
2353
- &dynamic_role_file,
2635
+ role_file_path,
2354
2636
  &safety,
2355
2637
  )?;
2356
2638
  let started = crate::lifecycle::restart::start_agent_at_paths(
@@ -2457,26 +2739,39 @@ fn upsert_agent_state_from_role(
2457
2739
  save_launched_team_state_for_key(workspace, &state, Some(canonical_team_key))
2458
2740
  }
2459
2741
 
2460
- fn materialize_added_role_file(
2461
- team_dir: &Path,
2462
- agent_id: &AgentId,
2463
- role_file_path: &Path,
2464
- ) -> Result<PathBuf, LifecycleError> {
2465
- let agents_dir = team_dir.join("agents");
2466
- std::fs::create_dir_all(&agents_dir)
2467
- .map_err(|e| LifecycleError::StatePersist(format!("create agents dir: {e}")))?;
2468
- let target = agents_dir.join(format!("{}.md", agent_id.as_str()));
2469
- if role_file_path == target {
2470
- return Ok(target);
2471
- }
2472
- std::fs::copy(role_file_path, &target).map_err(|e| {
2473
- LifecycleError::StatePersist(format!(
2474
- "copy role file {} -> {}: {e}",
2475
- role_file_path.display(),
2476
- target.display()
2477
- ))
2478
- })?;
2479
- Ok(target)
2742
+ /// E5 Bug1:把 add-agent 就地编译出的 agent 条目注入 base team spec(`agents` 列表 +
2743
+ /// `routing.rules` 加 `route-<id>`),复刻 [`compile_team`] 的路由规则形态。不落任何文件。
2744
+ fn inject_agent_into_spec(
2745
+ spec: &mut Value,
2746
+ agent: Value,
2747
+ agent_id: &str,
2748
+ ) -> Result<(), LifecycleError> {
2749
+ let Value::Map(pairs) = spec else {
2750
+ return Err(LifecycleError::Compile("spec is not a map".to_string()));
2751
+ };
2752
+ // agents 列表追加。
2753
+ match pairs.iter_mut().find(|(k, _)| k == "agents") {
2754
+ Some((_, Value::List(agents))) => agents.push(agent),
2755
+ _ => return Err(LifecycleError::Compile("spec.agents missing or not a list".to_string())),
2756
+ }
2757
+ // routing.rules 追加 route-<id>(与 compile_team 同形)
2758
+ if let Some((_, Value::Map(routing))) = pairs.iter_mut().find(|(k, _)| k == "routing") {
2759
+ if let Some((_, Value::List(rules))) = routing.iter_mut().find(|(k, _)| k == "rules") {
2760
+ rules.push(Value::Map(vec![
2761
+ ("id".to_string(), Value::Str(format!("route-{agent_id}"))),
2762
+ (
2763
+ "match".to_string(),
2764
+ Value::Map(vec![(
2765
+ "assignee".to_string(),
2766
+ Value::List(vec![Value::Str(agent_id.to_string())]),
2767
+ )]),
2768
+ ),
2769
+ ("assign_to".to_string(), Value::Str(agent_id.to_string())),
2770
+ ("priority".to_string(), Value::Int(10)),
2771
+ ]));
2772
+ }
2773
+ }
2774
+ Ok(())
2480
2775
  }
2481
2776
 
2482
2777
  /// `fork_agent(workspace, source_agent_id, as_agent_id, ...)`(`lifecycle/operations.py:284`)。
@@ -2523,15 +2818,18 @@ pub fn fork_agent_with_transport(
2523
2818
  crate::state::selector::SelectorMode::RequireSpec,
2524
2819
  )
2525
2820
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
2526
- let spec_workspace = selected.spec_workspace.ok_or_else(|| {
2527
- LifecycleError::TeamSelect("active team spec workspace not found".to_string())
2821
+ // E5 §3:team_dir(角色定义+profiles)恒用户目录。spec 读用 selector 解析的 spec_path
2822
+ // (读序 B:runtime 优先、legacy 回落),写恒走 runtime_spec_path(canonical 落点)
2823
+ let fork_team_dir = selected.team_dir.clone();
2824
+ let read_spec_path = selected.spec_path.clone().ok_or_else(|| {
2825
+ LifecycleError::TeamSelect("active team spec not found".to_string())
2528
2826
  })?;
2529
2827
  let workspace = selected.run_workspace;
2530
2828
  let state = selected.state;
2531
2829
  ensure_owner_allowed_for_state(&state, Some(source_agent_id))?;
2532
- let spec_path = spec_workspace.join("team.spec.yaml");
2533
- let text = std::fs::read_to_string(&spec_path)
2534
- .map_err(|e| LifecycleError::Compile(format!("{}: {e}", spec_path.display())))?;
2830
+ let spec_path = crate::model::paths::runtime_spec_path(&workspace, &selected.team_key);
2831
+ let text = std::fs::read_to_string(&read_spec_path)
2832
+ .map_err(|e| LifecycleError::Compile(format!("{}: {e}", read_spec_path.display())))?;
2535
2833
  let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
2536
2834
  if find_spec_agent(&spec, as_agent_id).is_some() || leader_id_matches(&spec, as_agent_id) {
2537
2835
  return Err(LifecycleError::RequirementUnmet(format!(
@@ -2571,10 +2869,12 @@ pub fn fork_agent_with_transport(
2571
2869
  )));
2572
2870
  }
2573
2871
  let new_spec = append_forked_agent(&spec, source_agent, source_agent_id, as_agent_id, label)?;
2574
- crate::model::spec::validate_spec(&new_spec, &spec_workspace)
2872
+ // validate 用角色定义目录的 team_workspace(校验 working_directory),非 spec 落点。
2873
+ let validate_ws = crate::model::paths::team_workspace(&fork_team_dir)
2874
+ .unwrap_or_else(|_| workspace.clone());
2875
+ crate::model::spec::validate_spec(&new_spec, &validate_ws)
2575
2876
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2576
- std::fs::write(&spec_path, yaml::dumps(&new_spec))
2577
- .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", spec_path.display())))?;
2877
+ write_spec_atomic(&spec_path, &new_spec)?;
2578
2878
  let new_agent = find_spec_agent(&new_spec, as_agent_id).ok_or_else(|| {
2579
2879
  LifecycleError::RequirementUnmet(format!("unknown worker agent id: {as_agent_id}"))
2580
2880
  })?;
@@ -2630,7 +2930,8 @@ pub fn fork_agent_with_transport(
2630
2930
  let _ = std::fs::write(&spec_path, text.as_bytes());
2631
2931
  e
2632
2932
  })?;
2633
- let profile_dir = spec_workspace.join("profiles");
2933
+ // E5 §3:profiles 随角色定义目录(team_dir),不随已迁出的 spec。
2934
+ let profile_dir = fork_team_dir.join("profiles");
2634
2935
  let profile_launch =
2635
2936
  crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
2636
2937
  &workspace,
@@ -3314,7 +3615,28 @@ fn yaml_value_to_json(value: &Value) -> serde_json::Value {
3314
3615
  /// `runtime` map and/or the `session_name` entry if absent. Used by quick-start to
3315
3616
  /// derive the tmux session from the REQUESTED team identity (CR-040/042) rather
3316
3617
  /// than the template's compiled-in name.
3317
- fn override_spec_session_name(spec: &mut Value, session_name: &str) {
3618
+ /// E5 Bug2(atomic 真修):原子写 runtime spec —— `<spec>.tmp-<pid>` rename 覆盖,
3619
+ /// 避免崩溃/并发留下半截 spec(plain fs::write 会 in-place truncate 后逐字节写)。
3620
+ /// rename 失败时清理 tmp,原 spec(若有)不动。
3621
+ pub(crate) fn write_spec_atomic(spec_path: &Path, spec: &Value) -> Result<(), LifecycleError> {
3622
+ if let Some(parent) = spec_path.parent() {
3623
+ std::fs::create_dir_all(parent)
3624
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
3625
+ }
3626
+ let tmp = spec_path.with_extension(format!("tmp-{}", std::process::id()));
3627
+ std::fs::write(&tmp, yaml::dumps(spec))
3628
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", tmp.display())))?;
3629
+ if let Err(e) = std::fs::rename(&tmp, spec_path) {
3630
+ let _ = std::fs::remove_file(&tmp);
3631
+ return Err(LifecycleError::StatePersist(format!(
3632
+ "{}: {e}",
3633
+ spec_path.display()
3634
+ )));
3635
+ }
3636
+ Ok(())
3637
+ }
3638
+
3639
+ pub(crate) fn override_spec_session_name(spec: &mut Value, session_name: &str) {
3318
3640
  let Value::Map(root) = spec else { return };
3319
3641
  let runtime_slot = root
3320
3642
  .iter_mut()
@@ -331,15 +331,10 @@ pub(crate) fn restart_required_missing_session_agent_ids(state: &serde_json::Val
331
331
  .get("status")
332
332
  .and_then(|value| value.as_str())
333
333
  .is_some_and(|status| status == "running");
334
- let has_live_pane_binding = agent
335
- .get("pane_id")
336
- .and_then(|value| value.as_str())
337
- .is_some_and(|pane| !pane.is_empty());
338
- let has_interaction_marker = agent
339
- .get("first_send_at")
340
- .and_then(|value| value.as_str())
341
- .is_some_and(|value| !value.is_empty());
342
- missing_session_id && is_running && (has_live_pane_binding || has_interaction_marker)
334
+ // E6 层2 (C2): required-missing 谓词只看 session_id 有无 + 是否在跑。
335
+ // pane 绑定 / first_send_at 在 gate 时刻天然可空(自启动 worker leader 从未发消息),
336
+ // 不能作判据 —— 否则真丢上下文的 null-session worker 被漏判,走静默 fresh。
337
+ missing_session_id && is_running
343
338
  })
344
339
  .collect::<Vec<_>>();
345
340
  missing.sort();