@team-agent/installer 0.3.11 → 0.3.13

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 (30) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +23 -0
  4. package/crates/team-agent/src/cli/leader.rs +2 -5
  5. package/crates/team-agent/src/cli/mod.rs +90 -4
  6. package/crates/team-agent/src/cli/tests/compile.rs +69 -0
  7. package/crates/team-agent/src/cli/tests/main_preserved.rs +68 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +160 -2
  9. package/crates/team-agent/src/compiler.rs +30 -0
  10. package/crates/team-agent/src/coordinator/health.rs +63 -3
  11. package/crates/team-agent/src/coordinator/tick.rs +16 -83
  12. package/crates/team-agent/src/leader/lease.rs +4 -20
  13. package/crates/team-agent/src/leader/mod.rs +3 -1
  14. package/crates/team-agent/src/leader/owner_bind.rs +46 -48
  15. package/crates/team-agent/src/leader/provider_attribution.rs +214 -0
  16. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -3
  17. package/crates/team-agent/src/leader/rediscover.rs +34 -17
  18. package/crates/team-agent/src/lifecycle/launch.rs +158 -12
  19. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +229 -28
  20. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +135 -8
  21. package/crates/team-agent/src/lifecycle/types.rs +28 -0
  22. package/crates/team-agent/src/messaging/delivery.rs +5 -1
  23. package/crates/team-agent/src/messaging/helpers.rs +1 -1
  24. package/crates/team-agent/src/messaging/tests/runtime.rs +14 -0
  25. package/crates/team-agent/src/provider/adapter.rs +6 -3
  26. package/crates/team-agent/src/provider/startup_prompt.rs +173 -0
  27. package/crates/team-agent/src/provider/testdata/copilot-ready-marker.txt +5 -0
  28. package/crates/team-agent/src/provider/testdata/copilot-trust-prompt.txt +19 -0
  29. package/crates/team-agent/src/transport/test_support.rs +22 -7
  30. package/package.json +4 -4
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.11"
569
+ version = "0.3.13"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.11"
12
+ version = "0.3.13"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -166,6 +166,7 @@ fn quickstart_human(value: &Value) -> String {
166
166
  pub fn cmd_compile(args: &CompileArgs) -> Result<CmdResult, CliError> {
167
167
  let spec = crate::compiler::compile_team(&args.team)
168
168
  .map_err(|e| CliError::Runtime(e.to_string()))?;
169
+ warn_ignored_owner_team_id(&args.team);
169
170
  std::fs::write(&args.out, crate::model::yaml::dumps(&spec))?;
170
171
  Ok(CmdResult::from_json(
171
172
  json!({
@@ -178,6 +179,28 @@ pub fn cmd_compile(args: &CompileArgs) -> Result<CmdResult, CliError> {
178
179
  ))
179
180
  }
180
181
 
182
+ fn warn_ignored_owner_team_id(team_dir: &std::path::Path) {
183
+ let Ok(Some(ignored)) = crate::compiler::ignored_owner_team_id_from_team_md(team_dir) else {
184
+ return;
185
+ };
186
+ let workspace = crate::model::paths::team_workspace(team_dir)
187
+ .unwrap_or_else(|_| team_dir.parent().unwrap_or(team_dir).to_path_buf());
188
+ eprintln!("Warning: ignored TEAM.md {}={}", ignored.field, ignored.value);
189
+ eprintln!("Reason: owner identity is the canonical runtime team key, not TEAM.md front matter");
190
+ eprintln!("Action: remove {} from TEAM.md", ignored.field);
191
+ let fields = json!({
192
+ "field": ignored.field,
193
+ "source": team_dir.join("TEAM.md").to_string_lossy().to_string(),
194
+ "value": ignored.value,
195
+ "warning": "ignored user-set owner_team_id",
196
+ "reason": "owner identity is derived from the canonical runtime team key",
197
+ "action": "remove owner_team_id from TEAM.md",
198
+ });
199
+ if let Err(err) = crate::event_log::EventLog::new(&workspace).write("spec.field_ignored", fields) {
200
+ eprintln!("Warning: spec.field_ignored event write failed: {err}");
201
+ }
202
+ }
203
+
181
204
  /// `cmd_status`(`commands.py:90`)。三态:`--summary`(xor json,xor agent)→五行文本;
182
205
  /// `--json`→`status_port::status(compact=!detail)`;else→`status_port::format_status(agent)`。
183
206
  pub fn cmd_status(args: &StatusArgs) -> Result<CmdResult, CliError> {
@@ -88,11 +88,8 @@ pub fn cmd_leader_passthrough(
88
88
  }
89
89
 
90
90
  pub(crate) fn leader_passthrough_provider(command: &str) -> crate::model::enums::Provider {
91
- match command {
92
- "codex" => crate::model::enums::Provider::Codex,
93
- "copilot" => crate::model::enums::Provider::Copilot,
94
- _ => crate::model::enums::Provider::ClaudeCode,
95
- }
91
+ crate::leader::attribute_command_provider(command)
92
+ .unwrap_or(crate::model::enums::Provider::ClaudeCode)
96
93
  }
97
94
 
98
95
  // =============================================================================
@@ -452,7 +452,9 @@ pub mod lifecycle_port {
452
452
  let coordinator_pid = stopped
453
453
  .as_ref()
454
454
  .and_then(|stopped| stopped.pid.map(|p| p.get()));
455
- let ok = stopped.as_ref().map(|stopped| stopped.ok).unwrap_or(true)
455
+ let coordinator_clean =
456
+ !coordinator_timeout && stopped.as_ref().map(|stopped| stopped.ok).unwrap_or(true);
457
+ let ok = coordinator_clean
456
458
  && kill_error.is_none()
457
459
  && session_residuals.is_empty()
458
460
  && process_residuals.is_empty()
@@ -1484,9 +1486,28 @@ pub mod lifecycle_port {
1484
1486
  }
1485
1487
  let agent_id = crate::model::ids::AgentId::new(agent);
1486
1488
  match crate::lifecycle::remove_agent(workspace, &agent_id, from_spec, force, team) {
1487
- Ok(report) => {
1488
- Ok(json!({"ok": true, "agent_id": agent, "report": format!("{report:?}")}))
1489
- }
1489
+ Ok(report @ crate::lifecycle::RemoveAgentOutcome::Removed { .. }) => Ok(json!({
1490
+ "ok": true,
1491
+ "agent_id": agent,
1492
+ "status": "removed",
1493
+ "report": format!("{report:?}"),
1494
+ })),
1495
+ Ok(crate::lifecycle::RemoveAgentOutcome::RefusedFromSpecConfirm { .. }) => Ok(json!({
1496
+ "ok": false,
1497
+ "agent_id": agent,
1498
+ "status": "refused",
1499
+ "reason": "from_spec_confirm_required",
1500
+ "error": "remove-agent requires --from-spec --confirm for spec-defined agents",
1501
+ "action": "rerun with --from-spec --confirm, or omit --from-spec only for dynamic agents",
1502
+ })),
1503
+ Ok(crate::lifecycle::RemoveAgentOutcome::RefusedForceRequired { .. }) => Ok(json!({
1504
+ "ok": false,
1505
+ "agent_id": agent,
1506
+ "status": "refused",
1507
+ "reason": "force_required",
1508
+ "error": "agent is running; remove-agent requires --force",
1509
+ "action": "rerun with --force to stop and remove the running agent",
1510
+ })),
1490
1511
  Err(e) => Ok(error_value(e)),
1491
1512
  }
1492
1513
  }
@@ -1913,6 +1934,71 @@ pub mod lifecycle_port {
1913
1934
  "next_actions": next_actions,
1914
1935
  "attach_commands": attach_commands,
1915
1936
  }),
1937
+ crate::lifecycle::RestartReport::Partial {
1938
+ session_name,
1939
+ agents,
1940
+ failed_agents,
1941
+ coordinator_started,
1942
+ next_actions,
1943
+ attach_commands,
1944
+ } => json!({
1945
+ "ok": false,
1946
+ "status": "partial",
1947
+ "reason": "restart_agent_failed",
1948
+ "session_name": session_name.as_str(),
1949
+ "agents": agents.iter().map(|a| a.agent_id.as_str()).collect::<Vec<_>>(),
1950
+ "failed_agents": failed_agents.iter().map(|failure| json!({
1951
+ "agent_id": failure.agent_id.as_str(),
1952
+ "restart_mode": failure.restart_mode,
1953
+ "decision": failure.decision,
1954
+ "session_id": failure.session_id.as_ref().map(|session| session.as_str()),
1955
+ "phase": failure.phase,
1956
+ "error": failure.error,
1957
+ "action": format!(
1958
+ "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
1959
+ failure.agent_id,
1960
+ failure.agent_id
1961
+ ),
1962
+ "log": format!(
1963
+ ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
1964
+ failure.agent_id
1965
+ ),
1966
+ })).collect::<Vec<_>>(),
1967
+ "coordinator_started": coordinator_started,
1968
+ "next_actions": next_actions,
1969
+ "attach_commands": attach_commands,
1970
+ }),
1971
+ crate::lifecycle::RestartReport::Failed {
1972
+ session_name,
1973
+ failed_agents,
1974
+ next_actions,
1975
+ attach_commands,
1976
+ } => json!({
1977
+ "ok": false,
1978
+ "status": "failed",
1979
+ "reason": "restart_all_agents_failed",
1980
+ "session_name": session_name.as_str(),
1981
+ "agents": [],
1982
+ "failed_agents": failed_agents.iter().map(|failure| json!({
1983
+ "agent_id": failure.agent_id.as_str(),
1984
+ "restart_mode": failure.restart_mode,
1985
+ "decision": failure.decision,
1986
+ "session_id": failure.session_id.as_ref().map(|session| session.as_str()),
1987
+ "phase": failure.phase,
1988
+ "error": failure.error,
1989
+ "action": format!(
1990
+ "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
1991
+ failure.agent_id,
1992
+ failure.agent_id
1993
+ ),
1994
+ "log": format!(
1995
+ ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
1996
+ failure.agent_id
1997
+ ),
1998
+ })).collect::<Vec<_>>(),
1999
+ "next_actions": next_actions,
2000
+ "attach_commands": attach_commands,
2001
+ }),
1916
2002
  crate::lifecycle::RestartReport::RefusedResumeAtomicity {
1917
2003
  unresumable,
1918
2004
  allow_fresh,
@@ -16,6 +16,16 @@ fn compile_team_dir(tag: &str) -> std::path::PathBuf {
16
16
  team
17
17
  }
18
18
 
19
+ fn compile_team_dir_with_owner_team_id(tag: &str) -> std::path::PathBuf {
20
+ let team = compile_team_dir(tag);
21
+ std::fs::write(
22
+ team.join("TEAM.md"),
23
+ "---\nname: compileteam\nobjective: Compile probe.\nprovider: fake\nowner_team_id: user-set-owner\n---\n\nCompile team.\n",
24
+ )
25
+ .unwrap();
26
+ team
27
+ }
28
+
19
29
  #[test]
20
30
  fn cmd_compile_json_and_human_match_golden_shape_and_writes_out() {
21
31
  let team = compile_team_dir("compile-ok");
@@ -47,6 +57,65 @@ fn cmd_compile_json_and_human_match_golden_shape_and_writes_out() {
47
57
  assert_eq!(human_text, expected_human, "golden human output preserves cmd_compile insertion order");
48
58
  }
49
59
 
60
+ #[test]
61
+ fn cmd_compile_ignores_user_owner_team_id_and_emits_warning_event() {
62
+ let team = compile_team_dir_with_owner_team_id("compile-ignored-owner");
63
+ let workspace = team.parent().unwrap().to_path_buf();
64
+ let out = workspace.join("ignored-owner-out.yaml");
65
+ let args = CompileArgs { team: team.clone(), out: out.clone(), json: true };
66
+
67
+ let result = cmd_compile(&args).expect("compile");
68
+ assert_eq!(result.exit, ExitCode::Ok);
69
+ let compiled = std::fs::read_to_string(&out).unwrap();
70
+ assert!(
71
+ !compiled.contains("owner_team_id"),
72
+ "user-set owner_team_id must not be compiled into team.spec.yaml"
73
+ );
74
+ let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
75
+ let event = events
76
+ .iter()
77
+ .find(|event| {
78
+ event
79
+ .get("event")
80
+ .and_then(serde_json::Value::as_str)
81
+ == Some("spec.field_ignored")
82
+ })
83
+ .expect("owner_team_id warning event");
84
+ assert_eq!(
85
+ event.get("field").and_then(serde_json::Value::as_str),
86
+ Some("owner_team_id")
87
+ );
88
+ assert_eq!(
89
+ event.get("value").and_then(serde_json::Value::as_str),
90
+ Some("user-set-owner")
91
+ );
92
+ assert_eq!(
93
+ event.get("action").and_then(serde_json::Value::as_str),
94
+ Some("remove owner_team_id from TEAM.md")
95
+ );
96
+ }
97
+
98
+ #[test]
99
+ fn cmd_compile_without_owner_team_id_emits_no_ignored_field_event() {
100
+ let team = compile_team_dir("compile-no-ignored-owner");
101
+ let workspace = team.parent().unwrap().to_path_buf();
102
+ let out = workspace.join("no-ignored-owner-out.yaml");
103
+ let args = CompileArgs { team: team.clone(), out, json: true };
104
+
105
+ let result = cmd_compile(&args).expect("compile");
106
+ assert_eq!(result.exit, ExitCode::Ok);
107
+ let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
108
+ assert!(
109
+ events.iter().all(|event| {
110
+ event
111
+ .get("event")
112
+ .and_then(serde_json::Value::as_str)
113
+ != Some("spec.field_ignored")
114
+ }),
115
+ "TEAM.md without owner_team_id must not emit ignored-field warning"
116
+ );
117
+ }
118
+
50
119
  #[test]
51
120
  fn run_dispatches_compile_and_error_path_exits_error() {
52
121
  let team = compile_team_dir("compile-dispatch");
@@ -472,6 +472,74 @@ fn seed_team_spec(ws: &std::path::Path) {
472
472
  other => panic!("expected JSON output, got {other:?}"),
473
473
  }
474
474
  }
475
+ fn seed_remove_agent_workspace(ws: &std::path::Path, status: &str) {
476
+ seed_team_spec(ws);
477
+ crate::state::persist::save_runtime_state(
478
+ ws,
479
+ &json!({
480
+ "session_name": "team-agent-fake-e2e",
481
+ "agents": {
482
+ "fake_impl": {
483
+ "status": status,
484
+ "provider": "fake",
485
+ "window": "fake_impl"
486
+ }
487
+ },
488
+ "spec_path": ws.join("team.spec.yaml").to_string_lossy()
489
+ }),
490
+ )
491
+ .unwrap();
492
+ }
493
+ #[test]
494
+ fn remove_agent_running_refusal_is_not_success_envelope() {
495
+ let ws = tmp_workspace();
496
+ seed_remove_agent_workspace(&ws, "running");
497
+ let out = json_output(
498
+ cmd_remove_agent(&RemoveAgentArgs {
499
+ agent: "fake_impl".to_string(),
500
+ workspace: ws.clone(),
501
+ team: None,
502
+ from_spec: true,
503
+ confirm: true,
504
+ force: false,
505
+ json: true,
506
+ })
507
+ .unwrap(),
508
+ );
509
+ assert_eq!(out["ok"], json!(false));
510
+ assert_eq!(out["status"], json!("refused"));
511
+ assert_eq!(out["reason"], json!("force_required"));
512
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
513
+ assert!(
514
+ state["agents"].get("fake_impl").is_some(),
515
+ "refused remove-agent must not delete the running agent"
516
+ );
517
+ }
518
+ #[test]
519
+ fn remove_agent_from_spec_refusal_is_not_success_envelope() {
520
+ let ws = tmp_workspace();
521
+ seed_remove_agent_workspace(&ws, "stopped");
522
+ let out = json_output(
523
+ cmd_remove_agent(&RemoveAgentArgs {
524
+ agent: "fake_impl".to_string(),
525
+ workspace: ws.clone(),
526
+ team: None,
527
+ from_spec: false,
528
+ confirm: true,
529
+ force: false,
530
+ json: true,
531
+ })
532
+ .unwrap(),
533
+ );
534
+ assert_eq!(out["ok"], json!(false));
535
+ assert_eq!(out["status"], json!("refused"));
536
+ assert_eq!(out["reason"], json!("from_spec_confirm_required"));
537
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
538
+ assert!(
539
+ state["agents"].get("fake_impl").is_some(),
540
+ "refused remove-agent must not delete the spec-defined agent"
541
+ );
542
+ }
475
543
  #[test]
476
544
  fn validate_result_file_good_and_inline_garbage_are_distinct() {
477
545
  let ws = tmp_workspace();
@@ -5,8 +5,15 @@
5
5
  //! 集成面由 tests/b5_leader_terminal_kill_red.rs 的真 tmux 契约覆盖,此处锁纯决策 + 4 反向 case。
6
6
 
7
7
  use crate::cli::lifecycle_port::{sessions_to_kill, KillDecision};
8
- use crate::transport::SessionName;
9
- use std::collections::BTreeSet;
8
+ use crate::transport::{
9
+ AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
10
+ Key, PaneField, PaneId, PaneInfo, SessionName, SetEnvOutcome, SpawnResult, Target, Transport,
11
+ TransportError, WindowName,
12
+ };
13
+ use serde_json::json;
14
+ use std::collections::{BTreeMap, BTreeSet};
15
+ use std::path::{Path, PathBuf};
16
+ use std::sync::Mutex;
10
17
 
11
18
  fn names(raw: &[&str]) -> Vec<SessionName> {
12
19
  raw.iter().map(|name| SessionName::new(*name)).collect()
@@ -102,3 +109,154 @@ fn union_prefix_and_anchor_no_double_count() {
102
109
  KillDecision::KillIndividually { to_kill: vec![], spared: names(&["team-agent-leader-claude-ws-beef"]) }
103
110
  );
104
111
  }
112
+
113
+ #[test]
114
+ fn missing_coordinator_is_ok_when_shutdown_cleaned_session() {
115
+ let ws = tmp_shutdown_workspace("missing-coordinator-clean");
116
+ crate::state::persist::save_runtime_state(
117
+ &ws,
118
+ &json!({
119
+ "session_name": "team-lane-l1-clean-shutdown",
120
+ "agents": {
121
+ "fake_impl": {
122
+ "status": "running",
123
+ "provider": "fake",
124
+ "window": "fake_impl"
125
+ }
126
+ }
127
+ }),
128
+ )
129
+ .unwrap();
130
+ let out = crate::cli::lifecycle_port::shutdown_with_transport(
131
+ &ws,
132
+ true,
133
+ None,
134
+ &CleanShutdownTransport::new(),
135
+ )
136
+ .expect("shutdown should complete");
137
+ assert_eq!(out["coordinator"]["status"], json!("missing"));
138
+ assert_eq!(
139
+ out["ok"], json!(true),
140
+ "coordinator.status=missing alone must not make a fully cleaned shutdown partial: {out}"
141
+ );
142
+ assert_eq!(out["status"], json!("ok"));
143
+ assert_eq!(out["residuals"]["sessions"], json!([]));
144
+ assert_eq!(out["residuals"]["processes"], json!([]));
145
+ }
146
+
147
+ fn tmp_shutdown_workspace(tag: &str) -> PathBuf {
148
+ use std::sync::atomic::{AtomicU64, Ordering};
149
+ static N: AtomicU64 = AtomicU64::new(0);
150
+ let dir = std::env::temp_dir().join(format!(
151
+ "ta-shutdown-{tag}-{}-{}",
152
+ std::process::id(),
153
+ N.fetch_add(1, Ordering::Relaxed)
154
+ ));
155
+ std::fs::create_dir_all(dir.join(".team").join("runtime")).unwrap();
156
+ dir
157
+ }
158
+
159
+ struct CleanShutdownTransport {
160
+ session_present: Mutex<bool>,
161
+ }
162
+
163
+ impl CleanShutdownTransport {
164
+ fn new() -> Self {
165
+ Self { session_present: Mutex::new(true) }
166
+ }
167
+ }
168
+
169
+ impl Transport for CleanShutdownTransport {
170
+ fn kind(&self) -> BackendKind {
171
+ BackendKind::Tmux
172
+ }
173
+
174
+ fn spawn_first(
175
+ &self,
176
+ _session: &SessionName,
177
+ _window: &WindowName,
178
+ _argv: &[String],
179
+ _cwd: &Path,
180
+ _env: &BTreeMap<String, String>,
181
+ ) -> Result<SpawnResult, TransportError> {
182
+ unimplemented!("shutdown test does not spawn")
183
+ }
184
+
185
+ fn spawn_into(
186
+ &self,
187
+ _session: &SessionName,
188
+ _window: &WindowName,
189
+ _argv: &[String],
190
+ _cwd: &Path,
191
+ _env: &BTreeMap<String, String>,
192
+ ) -> Result<SpawnResult, TransportError> {
193
+ unimplemented!("shutdown test does not spawn")
194
+ }
195
+
196
+ fn inject(
197
+ &self,
198
+ _target: &Target,
199
+ _payload: &InjectPayload,
200
+ _submit: Key,
201
+ _bracketed: bool,
202
+ ) -> Result<InjectReport, TransportError> {
203
+ unimplemented!("shutdown test does not inject")
204
+ }
205
+
206
+ fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
207
+ Ok(())
208
+ }
209
+
210
+ fn capture(
211
+ &self,
212
+ _target: &Target,
213
+ range: CaptureRange,
214
+ ) -> Result<CapturedText, TransportError> {
215
+ Ok(CapturedText { text: String::new(), range })
216
+ }
217
+
218
+ fn query(&self, _target: &Target, _field: PaneField) -> Result<Option<String>, TransportError> {
219
+ Ok(None)
220
+ }
221
+
222
+ fn liveness(
223
+ &self,
224
+ _pane: &PaneId,
225
+ ) -> Result<crate::model::enums::PaneLiveness, TransportError> {
226
+ Ok(crate::model::enums::PaneLiveness::Live)
227
+ }
228
+
229
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
230
+ Ok(Vec::new())
231
+ }
232
+
233
+ fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
234
+ Ok(*self.session_present.lock().unwrap())
235
+ }
236
+
237
+ fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
238
+ Ok(Vec::new())
239
+ }
240
+
241
+ fn set_session_env(
242
+ &self,
243
+ _session: &SessionName,
244
+ _key: &str,
245
+ _value: &str,
246
+ ) -> Result<SetEnvOutcome, TransportError> {
247
+ Ok(SetEnvOutcome::Applied)
248
+ }
249
+
250
+ fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
251
+ *self.session_present.lock().unwrap() = false;
252
+ Ok(())
253
+ }
254
+
255
+ fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
256
+ Ok(())
257
+ }
258
+
259
+ fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
260
+ Ok(AttachOutcome::Attached)
261
+ }
262
+ }
@@ -29,6 +29,14 @@ use std::path::Path;
29
29
  use crate::model::yaml::Value;
30
30
  use crate::model::{paths, spec, yaml, ModelError};
31
31
 
32
+ pub const IGNORED_OWNER_TEAM_ID_FIELD: &str = "owner_team_id";
33
+
34
+ #[derive(Debug, Clone, PartialEq, Eq)]
35
+ pub struct IgnoredTeamField {
36
+ pub field: &'static str,
37
+ pub value: String,
38
+ }
39
+
32
40
  /// `compiler._read_front_matter` (compiler.py:173-185).
33
41
  ///
34
42
  /// Reads `path` (UTF-8). If the text does not start with `"---\n"`, returns
@@ -76,6 +84,21 @@ pub fn read_front_matter(path: &Path) -> Result<(Value, String), ModelError> {
76
84
  Ok((meta, after_marker.trim_start_matches('\n').to_string()))
77
85
  }
78
86
 
87
+ pub fn ignored_owner_team_id_from_team_md(team_dir: &Path) -> Result<Option<IgnoredTeamField>, ModelError> {
88
+ let team_md = team_dir.join("TEAM.md");
89
+ if !team_md.exists() {
90
+ return Ok(None);
91
+ }
92
+ let (team_meta, _) = read_front_matter(&team_md)?;
93
+ let Some(value) = team_meta.get(IGNORED_OWNER_TEAM_ID_FIELD) else {
94
+ return Ok(None);
95
+ };
96
+ Ok(Some(IgnoredTeamField {
97
+ field: IGNORED_OWNER_TEAM_ID_FIELD,
98
+ value: front_matter_value_label(value),
99
+ }))
100
+ }
101
+
79
102
  /// `compiler.compile_team` (compiler.py:23-135) — returns the compiled spec dict.
80
103
  ///
81
104
  /// `TEAM.md` + sorted `agents/*.md` → the canonical spec `Value::Map` with the
@@ -518,5 +541,12 @@ fn max_active_agents(count: usize) -> i64 {
518
541
  }
519
542
  }
520
543
 
544
+ fn front_matter_value_label(value: &Value) -> String {
545
+ value
546
+ .as_str()
547
+ .map(ToString::to_string)
548
+ .unwrap_or_else(|| yaml::dumps(value).trim().to_string())
549
+ }
550
+
521
551
  #[cfg(test)]
522
552
  mod tests;
@@ -1,6 +1,8 @@
1
1
  //! coordinator 健康/身份 & 只读可观测面:metadata 身份原语 + coordinator 路径 + watch 实时流。
2
2
 
3
3
  use std::io::{Read, Seek, SeekFrom};
4
+ #[cfg(unix)]
5
+ use std::os::unix::process::CommandExt;
4
6
  use std::path::{Path, PathBuf};
5
7
  use std::process::{Command, Stdio};
6
8
  use std::time::Duration;
@@ -96,13 +98,15 @@ pub fn start_coordinator(workspace: &WorkspacePath) -> Result<StartReport, Start
96
98
  .append(true)
97
99
  .open(&log_path)?;
98
100
  let log_err = log.try_clone()?;
99
- let child = Command::new(std::env::current_exe()?)
101
+ let mut command = Command::new(std::env::current_exe()?);
102
+ command
100
103
  .args(["coordinator", "--workspace"])
101
104
  .arg(workspace.as_path())
102
105
  .stdin(Stdio::null())
103
106
  .stdout(Stdio::from(log))
104
- .stderr(Stdio::from(log_err))
105
- .spawn()?;
107
+ .stderr(Stdio::from(log_err));
108
+ detach_daemon_child(&mut command);
109
+ let child = command.spawn()?;
106
110
  let pid = Pid::new(child.id());
107
111
  std::fs::write(coordinator_pid_path(workspace), pid.to_string())?;
108
112
  write_coordinator_metadata(workspace, pid, MetadataSource::Start)?;
@@ -116,6 +120,24 @@ pub fn start_coordinator(workspace: &WorkspacePath) -> Result<StartReport, Start
116
120
  })
117
121
  }
118
122
 
123
+ #[cfg(unix)]
124
+ fn detach_daemon_child(command: &mut Command) {
125
+ // The coordinator is a daemon: it must not remain in the launcher's process
126
+ // group, otherwise bare SSH command teardown can SIGHUP it after quick-start exits.
127
+ unsafe {
128
+ command.pre_exec(|| {
129
+ if libc::setsid() == -1 {
130
+ Err(std::io::Error::last_os_error())
131
+ } else {
132
+ Ok(())
133
+ }
134
+ });
135
+ }
136
+ }
137
+
138
+ #[cfg(not(unix))]
139
+ fn detach_daemon_child(_command: &mut Command) {}
140
+
119
141
  /// `stop_coordinator`(`lifecycle.py:228-247`):SIGTERM pid + 清 pid/meta → typed report。
120
142
  pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopError> {
121
143
  let pid_path = coordinator_pid_path(workspace);
@@ -696,3 +718,41 @@ fn clean_text(text: &str) -> String {
696
718
  fn prefix_chars(text: &str, max: usize) -> String {
697
719
  text.chars().take(max).collect()
698
720
  }
721
+
722
+ #[cfg(all(test, unix))]
723
+ mod tests {
724
+ use super::*;
725
+
726
+ struct ChildGuard(std::process::Child);
727
+
728
+ impl Drop for ChildGuard {
729
+ fn drop(&mut self) {
730
+ unsafe {
731
+ libc::kill(self.0.id() as libc::pid_t, libc::SIGTERM);
732
+ }
733
+ let _ = self.0.wait();
734
+ }
735
+ }
736
+
737
+ #[test]
738
+ fn coordinator_daemon_spawn_helper_detaches_session() {
739
+ let mut command = Command::new("/bin/sleep");
740
+ command
741
+ .arg("30")
742
+ .stdin(Stdio::null())
743
+ .stdout(Stdio::null())
744
+ .stderr(Stdio::null());
745
+ detach_daemon_child(&mut command);
746
+
747
+ let child = command.spawn().expect("spawn detached child");
748
+ let guard = ChildGuard(child);
749
+ let pid = guard.0.id() as libc::pid_t;
750
+ let sid = unsafe { libc::getsid(pid) };
751
+
752
+ assert_ne!(sid, -1, "getsid({pid}) failed");
753
+ assert_eq!(
754
+ sid, pid,
755
+ "detached coordinator children must become session leaders so launcher SIGHUP does not reach them"
756
+ );
757
+ }
758
+ }