@team-agent/installer 0.3.6 → 0.3.7

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 (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/diagnose.rs +9 -0
  4. package/crates/team-agent/src/cli/emit.rs +63 -0
  5. package/crates/team-agent/src/cli/mod.rs +334 -35
  6. package/crates/team-agent/src/cli/status_port.rs +62 -0
  7. package/crates/team-agent/src/cli/tests/base.rs +9 -4
  8. package/crates/team-agent/src/cli/tests/run_delegation.rs +10 -2
  9. package/crates/team-agent/src/cli/types.rs +3 -2
  10. package/crates/team-agent/src/compiler.rs +73 -50
  11. package/crates/team-agent/src/coordinator/tick.rs +108 -20
  12. package/crates/team-agent/src/db/migration.rs +17 -1
  13. package/crates/team-agent/src/lifecycle/launch.rs +182 -47
  14. package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
  15. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +75 -2
  16. package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
  17. package/crates/team-agent/src/lifecycle/tests/core.rs +46 -3
  18. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +221 -7
  19. package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
  20. package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
  21. package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
  22. package/crates/team-agent/src/mcp_server/tools.rs +25 -1
  23. package/crates/team-agent/src/mcp_server/wire.rs +11 -1
  24. package/crates/team-agent/src/model/paths.rs +7 -0
  25. package/crates/team-agent/src/model/spec.rs +23 -1
  26. package/crates/team-agent/src/packaging/install.rs +42 -4
  27. package/crates/team-agent/src/packaging/tests.rs +91 -14
  28. package/crates/team-agent/src/packaging/types.rs +13 -1
  29. package/crates/team-agent/src/provider/adapter.rs +204 -0
  30. package/crates/team-agent/src/state/selector.rs +48 -14
  31. package/crates/team-agent/src/tmux_backend.rs +14 -2
  32. package/package.json +4 -4
  33. 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,34 @@ 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 设了但 transport.liveness(pane) 报 Dead → Err(RequirementUnmet)。
772
+ /// liveness 返 Unknown 不挡(被动路径降级):本主动路径只对【显式 Dead】fail-fast,
773
+ /// MUST-17 不过度设计 / unset 走 pass-through(b7_unset_leader_pane_env_passes_through 守)。
774
+ pub(crate) fn validate_active_leader_pane_env(
775
+ transport: &dyn Transport,
776
+ ) -> Result<(), LifecycleError> {
777
+ let pane_id_raw = match std::env::var("TEAM_AGENT_LEADER_PANE_ID") {
778
+ Ok(v) if !v.is_empty() => v,
779
+ _ => return Ok(()),
780
+ };
781
+ let probe = transport.liveness(&crate::transport::PaneId::new(&pane_id_raw));
782
+ let dead = matches!(probe, Ok(crate::transport::PaneLiveness::Dead));
783
+ if !dead {
784
+ return Ok(());
785
+ }
786
+ Err(LifecycleError::RequirementUnmet(format!(
787
+ "TEAM_AGENT_LEADER_PANE_ID points at a dead/absent pane: {pane_id_raw}\n\
788
+ action: unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id\n\
789
+ log: TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}"
790
+ )))
791
+ }
792
+
723
793
  fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
724
794
  let Some(owner) = unbound_launched_owner(launched, launched_key) else {
725
795
  return;
@@ -1809,6 +1879,12 @@ pub fn quick_start_with_transport_in_workspace(
1809
1879
  team_id: Option<&str>,
1810
1880
  transport: &dyn Transport,
1811
1881
  ) -> Result<QuickStartReport, LifecycleError> {
1882
+ // B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
1883
+ // 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
1884
+ // owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
1885
+ // 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
1886
+ // 或修 env)/log(env var 名)。
1887
+ validate_active_leader_pane_env(transport)?;
1812
1888
  if !agents_dir.exists() {
1813
1889
  return Err(LifecycleError::Compile(format!(
1814
1890
  "agents dir not found: {}",
@@ -1869,13 +1945,14 @@ pub fn quick_start_with_transport_in_workspace(
1869
1945
  override_spec_session_name(&mut spec, &format!("team-{requested}"));
1870
1946
  }
1871
1947
  let session_name = spec_session_name(&spec);
1948
+ // team_key 身份源 = team_dir(agents_dir).name(角色定义目录),不依赖 spec 落点。
1872
1949
  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)
1950
+ runtime_team_key_for_spec(&agents_dir.join("team.spec.yaml"), &spec, &session_name)
1875
1951
  });
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())))?;
1952
+ // E5 spec 迁移:spec 写到 .team/runtime/<team_key>/(中间产物,绝不落用户目录 agents_dir)
1953
+ // Bug2:原子写(tmp+rename),避免半截 spec
1954
+ let spec_path = crate::model::paths::runtime_spec_path(&workspace, &state_team_key);
1955
+ write_spec_atomic(&spec_path, &spec)?;
1879
1956
  let _store = crate::message_store::MessageStore::open(&workspace)
1880
1957
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1881
1958
  let resolved_spec_path =
@@ -2268,9 +2345,8 @@ pub fn add_agent(
2268
2345
  }
2269
2346
  Err(error) => return Err(LifecycleError::TeamSelect(error.to_string())),
2270
2347
  };
2271
- let team_dir = selected.spec_workspace.ok_or_else(|| {
2272
- LifecycleError::TeamSelect("active team spec workspace not found".to_string())
2273
- })?;
2348
+ // E5 §3:compile_team 要角色定义目录(team_dir),不是 spec 落点(spec_workspace=runtime)。
2349
+ let team_dir = selected.team_dir;
2274
2350
  add_agent_with_transport_at_paths(
2275
2351
  &selected.run_workspace,
2276
2352
  &team_dir,
@@ -2336,21 +2412,40 @@ fn add_agent_with_transport_at_paths(
2336
2412
  "agent id already exists: {agent_id}"
2337
2413
  )));
2338
2414
  }
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)
2415
+ // E5 Bug1:不再 copy role 文件进 <team_dir>/agents(自拷贝 O_TRUNC 截断反模式)
2416
+ // 就地读外部 role 文档编译,注入 base team spec agents/routing。role 文件留在原处。
2417
+ let mut spec = crate::compiler::compile_team(team_dir)
2418
+ .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2419
+ let workspace_s = spec
2420
+ .get("team")
2421
+ .and_then(|team| team.get("workspace"))
2422
+ .and_then(Value::as_str)
2423
+ .unwrap_or_else(|| team_dir.to_str().unwrap_or_default())
2424
+ .to_string();
2425
+ let team_meta = crate::compiler::read_front_matter(&team_dir.join("TEAM.md"))
2426
+ .map(|(meta, _)| meta)
2427
+ .unwrap_or(Value::Null);
2428
+ let compiled = crate::compiler::compile_role_agent(role_file_path, &team_meta, &workspace_s)
2341
2429
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2430
+ if compiled.id != agent_id.as_str() {
2431
+ return Err(LifecycleError::Compile(format!(
2432
+ "role file declares name '{}' but add-agent id is '{}'",
2433
+ compiled.id, agent_id
2434
+ )));
2435
+ }
2436
+ inject_agent_into_spec(&mut spec, compiled.agent, &compiled.id)?;
2342
2437
  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)
2438
+ // E5 spec 迁移:重编译的 spec 原子写到 .team/runtime/<team_key>/(不落用户目录 team_dir)
2439
+ let spec_path = crate::model::paths::runtime_spec_path(run_workspace, &canonical_team_key);
2440
+ write_spec_atomic(&spec_path, &spec)?;
2441
+ let (meta, _) = crate::compiler::read_front_matter(role_file_path)
2347
2442
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2348
2443
  upsert_agent_state_from_role(
2349
2444
  run_workspace,
2350
2445
  &canonical_team_key,
2351
2446
  agent_id,
2352
2447
  &meta,
2353
- &dynamic_role_file,
2448
+ role_file_path,
2354
2449
  &safety,
2355
2450
  )?;
2356
2451
  let started = crate::lifecycle::restart::start_agent_at_paths(
@@ -2457,26 +2552,39 @@ fn upsert_agent_state_from_role(
2457
2552
  save_launched_team_state_for_key(workspace, &state, Some(canonical_team_key))
2458
2553
  }
2459
2554
 
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)
2555
+ /// E5 Bug1:把 add-agent 就地编译出的 agent 条目注入 base team spec(`agents` 列表 +
2556
+ /// `routing.rules` 加 `route-<id>`),复刻 [`compile_team`] 的路由规则形态。不落任何文件。
2557
+ fn inject_agent_into_spec(
2558
+ spec: &mut Value,
2559
+ agent: Value,
2560
+ agent_id: &str,
2561
+ ) -> Result<(), LifecycleError> {
2562
+ let Value::Map(pairs) = spec else {
2563
+ return Err(LifecycleError::Compile("spec is not a map".to_string()));
2564
+ };
2565
+ // agents 列表追加。
2566
+ match pairs.iter_mut().find(|(k, _)| k == "agents") {
2567
+ Some((_, Value::List(agents))) => agents.push(agent),
2568
+ _ => return Err(LifecycleError::Compile("spec.agents missing or not a list".to_string())),
2569
+ }
2570
+ // routing.rules 追加 route-<id>(与 compile_team 同形)
2571
+ if let Some((_, Value::Map(routing))) = pairs.iter_mut().find(|(k, _)| k == "routing") {
2572
+ if let Some((_, Value::List(rules))) = routing.iter_mut().find(|(k, _)| k == "rules") {
2573
+ rules.push(Value::Map(vec![
2574
+ ("id".to_string(), Value::Str(format!("route-{agent_id}"))),
2575
+ (
2576
+ "match".to_string(),
2577
+ Value::Map(vec![(
2578
+ "assignee".to_string(),
2579
+ Value::List(vec![Value::Str(agent_id.to_string())]),
2580
+ )]),
2581
+ ),
2582
+ ("assign_to".to_string(), Value::Str(agent_id.to_string())),
2583
+ ("priority".to_string(), Value::Int(10)),
2584
+ ]));
2585
+ }
2586
+ }
2587
+ Ok(())
2480
2588
  }
2481
2589
 
2482
2590
  /// `fork_agent(workspace, source_agent_id, as_agent_id, ...)`(`lifecycle/operations.py:284`)。
@@ -2523,15 +2631,18 @@ pub fn fork_agent_with_transport(
2523
2631
  crate::state::selector::SelectorMode::RequireSpec,
2524
2632
  )
2525
2633
  .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())
2634
+ // E5 §3:team_dir(角色定义+profiles)恒用户目录。spec 读用 selector 解析的 spec_path
2635
+ // (读序 B:runtime 优先、legacy 回落),写恒走 runtime_spec_path(canonical 落点)
2636
+ let fork_team_dir = selected.team_dir.clone();
2637
+ let read_spec_path = selected.spec_path.clone().ok_or_else(|| {
2638
+ LifecycleError::TeamSelect("active team spec not found".to_string())
2528
2639
  })?;
2529
2640
  let workspace = selected.run_workspace;
2530
2641
  let state = selected.state;
2531
2642
  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())))?;
2643
+ let spec_path = crate::model::paths::runtime_spec_path(&workspace, &selected.team_key);
2644
+ let text = std::fs::read_to_string(&read_spec_path)
2645
+ .map_err(|e| LifecycleError::Compile(format!("{}: {e}", read_spec_path.display())))?;
2535
2646
  let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
2536
2647
  if find_spec_agent(&spec, as_agent_id).is_some() || leader_id_matches(&spec, as_agent_id) {
2537
2648
  return Err(LifecycleError::RequirementUnmet(format!(
@@ -2571,10 +2682,12 @@ pub fn fork_agent_with_transport(
2571
2682
  )));
2572
2683
  }
2573
2684
  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)
2685
+ // validate 用角色定义目录的 team_workspace(校验 working_directory),非 spec 落点。
2686
+ let validate_ws = crate::model::paths::team_workspace(&fork_team_dir)
2687
+ .unwrap_or_else(|_| workspace.clone());
2688
+ crate::model::spec::validate_spec(&new_spec, &validate_ws)
2575
2689
  .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())))?;
2690
+ write_spec_atomic(&spec_path, &new_spec)?;
2578
2691
  let new_agent = find_spec_agent(&new_spec, as_agent_id).ok_or_else(|| {
2579
2692
  LifecycleError::RequirementUnmet(format!("unknown worker agent id: {as_agent_id}"))
2580
2693
  })?;
@@ -2630,7 +2743,8 @@ pub fn fork_agent_with_transport(
2630
2743
  let _ = std::fs::write(&spec_path, text.as_bytes());
2631
2744
  e
2632
2745
  })?;
2633
- let profile_dir = spec_workspace.join("profiles");
2746
+ // E5 §3:profiles 随角色定义目录(team_dir),不随已迁出的 spec。
2747
+ let profile_dir = fork_team_dir.join("profiles");
2634
2748
  let profile_launch =
2635
2749
  crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
2636
2750
  &workspace,
@@ -3314,7 +3428,28 @@ fn yaml_value_to_json(value: &Value) -> serde_json::Value {
3314
3428
  /// `runtime` map and/or the `session_name` entry if absent. Used by quick-start to
3315
3429
  /// derive the tmux session from the REQUESTED team identity (CR-040/042) rather
3316
3430
  /// than the template's compiled-in name.
3317
- fn override_spec_session_name(spec: &mut Value, session_name: &str) {
3431
+ /// E5 Bug2(atomic 真修):原子写 runtime spec —— `<spec>.tmp-<pid>` rename 覆盖,
3432
+ /// 避免崩溃/并发留下半截 spec(plain fs::write 会 in-place truncate 后逐字节写)。
3433
+ /// rename 失败时清理 tmp,原 spec(若有)不动。
3434
+ pub(crate) fn write_spec_atomic(spec_path: &Path, spec: &Value) -> Result<(), LifecycleError> {
3435
+ if let Some(parent) = spec_path.parent() {
3436
+ std::fs::create_dir_all(parent)
3437
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
3438
+ }
3439
+ let tmp = spec_path.with_extension(format!("tmp-{}", std::process::id()));
3440
+ std::fs::write(&tmp, yaml::dumps(spec))
3441
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", tmp.display())))?;
3442
+ if let Err(e) = std::fs::rename(&tmp, spec_path) {
3443
+ let _ = std::fs::remove_file(&tmp);
3444
+ return Err(LifecycleError::StatePersist(format!(
3445
+ "{}: {e}",
3446
+ spec_path.display()
3447
+ )));
3448
+ }
3449
+ Ok(())
3450
+ }
3451
+
3452
+ pub(crate) fn override_spec_session_name(spec: &mut Value, session_name: &str) {
3318
3453
  let Value::Map(root) = spec else { return };
3319
3454
  let runtime_slot = root
3320
3455
  .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();
@@ -101,10 +101,15 @@ pub fn restart_with_transport_with_session_convergence_deadline(
101
101
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
102
102
  let mut state = selected.state;
103
103
  crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
104
- let spec_workspace = selected.spec_workspace.as_ref().ok_or_else(|| {
104
+ // E5 task#3 / RC-A6a + E4(leader 裁定:每次 restart 都从角色定义重建 runtime spec,覆盖):
105
+ // 角色定义=第一真相源。角色齐 → compile_team 重建 + 保留运行期 override(session_name)+
106
+ // 写 runtime spec。角色缺(TEAM.md/agents 不在)→ 显式拒(列缺哪些),旧 spec 原地保留不删不用。
107
+ let spec = rebuild_runtime_spec_from_roles(&selected.run_workspace, &selected.team_key, &state)?;
108
+ // 重建后 spec_workspace 恒为 runtime spec 的父目录(.team/runtime/<team_key>/)。
109
+ let runtime_spec = crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key);
110
+ let spec_workspace = runtime_spec.parent().ok_or_else(|| {
105
111
  LifecycleError::TeamSelect("active team spec workspace not found".to_string())
106
112
  })?;
107
- let spec = load_team_spec(spec_workspace)?;
108
113
  let safety = crate::lifecycle::launch::effective_runtime_config(&spec)?;
109
114
  let mut convergence = converge_missing_provider_sessions(
110
115
  &mut state,
@@ -713,6 +718,74 @@ fn restart_candidate_from_state(
713
718
  }
714
719
  }
715
720
 
721
+ /// E5 task#3 / RC-A6a:每次 restart 都以**角色定义**(team_dir 的 TEAM.md+agents/*.md)
722
+ /// compile_team 重建 runtime spec(覆盖),保留运行期 override(session_name 必须延续,
723
+ /// 否则 tmux session 对不上)。写到 .team/runtime/<team_key>/team.spec.yaml。
724
+ ///
725
+ /// 角色定义缺(team_dir 未记 / TEAM.md 不在 / agents 不在)→ **显式拒**(LifecycleError,
726
+ /// CLI N38 三行式),列出缺哪些;**旧 spec 原地保留不删不用**(T2 防数据销毁,无静默路径)。
727
+ fn rebuild_runtime_spec_from_roles(
728
+ run_workspace: &Path,
729
+ team_key: &str,
730
+ state: &serde_json::Value,
731
+ ) -> Result<YamlValue, LifecycleError> {
732
+ // team_dir(角色定义源)优先取 state.team_dir;缺则回落 run_workspace(自含 team-dir 布局,
733
+ // run_workspace 本身即角色目录)。两者都无角色定义则下面的齐全性检查会显式拒。
734
+ let team_dir = state
735
+ .get("team_dir")
736
+ .and_then(serde_json::Value::as_str)
737
+ .filter(|s| !s.is_empty())
738
+ .map(std::path::PathBuf::from)
739
+ .unwrap_or_else(|| run_workspace.to_path_buf());
740
+ // 角色定义齐全性检查(显式拒,列缺哪些;旧 spec 不动)。
741
+ let mut missing: Vec<String> = Vec::new();
742
+ if !team_dir.join("TEAM.md").exists() {
743
+ missing.push(format!("{}/TEAM.md", team_dir.display()));
744
+ }
745
+ let agents_dir = team_dir.join("agents");
746
+ let has_role_doc = std::fs::read_dir(&agents_dir)
747
+ .map(|entries| {
748
+ entries.flatten().any(|e| {
749
+ e.path().extension().and_then(|x| x.to_str()) == Some("md")
750
+ })
751
+ })
752
+ .unwrap_or(false);
753
+ if !has_role_doc {
754
+ missing.push(format!("{}/*.md (at least one role doc)", agents_dir.display()));
755
+ }
756
+ if !missing.is_empty() {
757
+ // N38 三行式:error / action / log。旧 runtime spec 原地保留(不删不用)。
758
+ return Err(LifecycleError::TeamSelect(format!(
759
+ "cannot restart: role definitions missing for team '{team_key}': {}. \
760
+ action: restore the listed role docs (TEAM.md + agents/*.md are the source of truth), \
761
+ then re-run restart; the previous runtime spec is left in place (not used). \
762
+ log: team_dir={}",
763
+ missing.join(", "),
764
+ team_dir.display(),
765
+ )));
766
+ }
767
+ // 重建:compile_team(角色定义) + 保留运行期 session_name override。
768
+ let mut spec = crate::compiler::compile_team(&team_dir)
769
+ .map_err(|e| LifecycleError::Compile(e.to_string()))?;
770
+ if let Some(session_name) = state
771
+ .get("session_name")
772
+ .and_then(serde_json::Value::as_str)
773
+ .filter(|s| !s.is_empty())
774
+ {
775
+ crate::lifecycle::launch::override_spec_session_name(&mut spec, session_name);
776
+ }
777
+ // 写 runtime spec(覆盖,原子 tmp+rename;Bug2)。
778
+ let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
779
+ crate::lifecycle::launch::write_spec_atomic(&spec_path, &spec)?;
780
+ // RC-A6a:重建成功后清理用户目录的 legacy spec(中间产物不该留在角色目录)。
781
+ // 仅删 team_dir 下的 team.spec.yaml(角色定义 TEAM.md/agents 不动);失败不致命(best-effort)。
782
+ let legacy_spec = team_dir.join("team.spec.yaml");
783
+ if legacy_spec.exists() && legacy_spec != spec_path {
784
+ let _ = std::fs::remove_file(&legacy_spec);
785
+ }
786
+ Ok(spec)
787
+ }
788
+
716
789
  fn restart_candidate_spec_path(workspace: &Path, state: &serde_json::Value) -> std::path::PathBuf {
717
790
  if let Some(path) = state
718
791
  .get("spec_path")
@@ -120,8 +120,8 @@ pub fn python_type_name(value: &serde_json::Value) -> &'static str {
120
120
  /// `_collect_corrupt_first_send_at`,`orchestration.py:430/467`)。读 fixture state 的
121
121
  /// `agents.<id>`,对每非 paused worker:
122
122
  /// (1) corrupt first_send_at → 收进 `corrupt_entries`(carry python type-name);
123
- /// (2) 算 resume 决策(`resumable→Resume` / `!resumable&&!interacted→FreshStart` /
124
- /// `!resumable&&interacted&&allow_fresh→FreshStart` / 否则 `Refuse`);
123
+ /// (2) 算 resume 决策(`session_id→Resume` / `null session && allow_fresh→FreshStart` /
124
+ /// 否则 `Refuse`;E6 层2:null session 不再因 first_send_at=null 静默 fresh);
125
125
  /// (3) `Refuse` 的 worker(reason=`no_persisted_session_id`(无 session)|`session_unresumable`)
126
126
  /// 进 `unresumable`。
127
127
  /// restart() **先**调它再 teardown;corrupt 非空 → `RefusedInvalidFirstSendAt`,unresumable
@@ -171,10 +171,12 @@ pub fn classify_restart_plan(
171
171
  .and_then(|v| v.as_str())
172
172
  .filter(|s| !s.is_empty())
173
173
  .map(SessionId::new);
174
- let interacted = matches!(first_send_at_state, FirstSendAtState::Valid);
174
+ // E6 层2 (C2, 用户裁定"绝不静默 fresh"): null session 只有显式 --allow-fresh 才 fresh,
175
+ // 否则 Refuse(→ resume_not_ready + 指引)。删 `!interacted` 短路 —— 自启动 worker
176
+ // (leader 从未发消息 → first_send_at=null → interacted=false)会被它静默 fresh 丢上下文。
175
177
  let decision = if session_id.is_some() {
176
178
  ResumeDecision::Resume
177
- } else if !interacted || allow_fresh {
179
+ } else if allow_fresh {
178
180
  ResumeDecision::FreshStart
179
181
  } else {
180
182
  ResumeDecision::Refuse
@@ -491,14 +491,57 @@ fn classify_restart_plan_interacted_unresumable_with_allow_fresh_yields_fresh_st
491
491
  }
492
492
 
493
493
  #[test]
494
- fn classify_restart_plan_never_interacted_yields_fresh_start() {
495
- // first_send_at absent(从未交互) FreshStart,即使 !allow_fresh( context 可丢)
494
+ fn classify_restart_plan_never_interacted_null_session_refuses_not_fresh() {
495
+ // E6 层2 (C2, 用户裁定"绝不静默 fresh"): first_send_at absent(自启动 worker,leader 从未发消息)
496
+ // + session_id null → 不能再静默 FreshStart(那会丢真实 provider 会话上下文)。
497
+ // 默认 !allow_fresh → Refuse + unresumable(诚实出口 resume_not_ready)。
496
498
  let state = json!({
497
499
  "agents": { "w1": { "provider": "claude", "session_id": null } }
498
500
  });
499
501
  let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
500
502
  assert_eq!(plan.decisions.len(), 1);
501
- assert_eq!(plan.decisions[0].decision, ResumeDecision::FreshStart);
503
+ assert_eq!(
504
+ plan.decisions[0].decision,
505
+ ResumeDecision::Refuse,
506
+ "null-session 自启动 worker 默认必须 Refuse,不许静默 fresh"
507
+ );
508
+ assert_eq!(plan.unresumable.len(), 1, "Refuse 必入 unresumable(诚实出口)");
509
+ assert_eq!(plan.unresumable[0].reason, "no_persisted_session_id");
510
+ }
511
+
512
+ #[test]
513
+ fn classify_restart_plan_never_interacted_null_session_with_allow_fresh_marks_forced_fresh() {
514
+ // E6 层2: 同上自启动 null-session worker,但显式 --allow-fresh → 用户主动认账丢上下文 → FreshStart。
515
+ let state = json!({
516
+ "agents": { "w1": { "provider": "claude", "session_id": null } }
517
+ });
518
+ let plan = classify_restart_plan(&state, true).expect("纯验证不应 Err");
519
+ assert_eq!(plan.decisions.len(), 1);
520
+ assert_eq!(
521
+ plan.decisions[0].decision,
522
+ ResumeDecision::FreshStart,
523
+ "显式 --allow-fresh 才允许 null-session worker fresh"
524
+ );
525
+ assert!(
526
+ plan.unresumable.is_empty(),
527
+ "allow_fresh 下不触发 unresumable refusal"
528
+ );
529
+ }
530
+
531
+ #[test]
532
+ fn classify_restart_plan_codex_with_session_still_resumes() {
533
+ // E6 层2 回归锁(不误伤): codex worker first_send_at=null 但 session_id 已捕 →
534
+ // 仍走 Resume(分流轴是 session_id 有无,不是 interacted)。防层2 修法把 has_session 也误判。
535
+ let state = json!({
536
+ "agents": { "w1": { "provider": "codex", "session_id": "sess-codex-abc" } }
537
+ });
538
+ let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
539
+ assert_eq!(plan.decisions.len(), 1);
540
+ assert_eq!(
541
+ plan.decisions[0].decision,
542
+ ResumeDecision::Resume,
543
+ "有 session_id 必 Resume,与 first_send_at/interacted 无关"
544
+ );
502
545
  assert!(plan.unresumable.is_empty());
503
546
  }
504
547