@team-agent/installer 0.5.16 → 0.5.18

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.
@@ -107,20 +107,20 @@ pub enum TickError {
107
107
  /// returns `None` and the tick falls back to the raw state — preserving
108
108
  /// behavior for legacy single-team workspaces and tests that don't seed
109
109
  /// `teams.<key>`. Sibling teams under `state.teams.*` are NOT touched.
110
- fn coordinator_team_scoped_state(workspace: &std::path::Path, raw_state: &Value) -> Option<Value> {
110
+ fn coordinator_team_scoped_state(
111
+ workspace: &std::path::Path,
112
+ raw_state: &Value,
113
+ daemon_team_key: Option<&str>,
114
+ ) -> Option<Value> {
115
+ let teams = raw_state.get("teams").and_then(Value::as_object)?;
111
116
  let active = raw_state
112
117
  .get("active_team_key")
113
118
  .and_then(Value::as_str)
114
- .filter(|s| !s.is_empty())?;
115
- let has_team_entry = raw_state
116
- .get("teams")
117
- .and_then(Value::as_object)
118
- .map(|teams| teams.contains_key(active))
119
- .unwrap_or(false);
120
- if !has_team_entry {
121
- return None;
122
- }
123
- crate::state::projection::select_runtime_state(workspace, Some(active)).ok()
119
+ .filter(|s| !s.is_empty());
120
+ let selected = daemon_team_key
121
+ .filter(|key| !key.is_empty() && teams.contains_key(*key))
122
+ .or_else(|| active.filter(|key| teams.contains_key(*key)))?;
123
+ crate::state::projection::select_runtime_state(workspace, Some(selected)).ok()
124
124
  }
125
125
 
126
126
  // ===========================================================================
@@ -149,6 +149,10 @@ pub struct Coordinator {
149
149
  /// transport 控制面(tmux session 存活探测等;经 trait 注入,可 mock)。
150
150
  #[allow(dead_code)]
151
151
  transport: Box<dyn crate::transport::Transport>,
152
+ /// Daemon CLI-selected team key. When present, tick projection uses this
153
+ /// instead of a stale root active_team_key.
154
+ #[allow(dead_code)]
155
+ daemon_team_key: Option<String>,
152
156
  /// bug-084 save 注入钩。`None` ⇔ 真实 `state::save_runtime_state`。
153
157
  #[allow(dead_code)]
154
158
  save_hook: Option<SaveHook>,
@@ -168,11 +172,17 @@ impl Coordinator {
168
172
  workspace,
169
173
  provider_registry,
170
174
  transport,
175
+ daemon_team_key: None,
171
176
  save_hook: None,
172
177
  order_recorder: None,
173
178
  }
174
179
  }
175
180
 
181
+ pub(crate) fn with_team_key(mut self, team_key: Option<String>) -> Self {
182
+ self.daemon_team_key = team_key.filter(|key| !key.is_empty());
183
+ self
184
+ }
185
+
176
186
  /// 测试装配:直接构出 `Coordinator`(不经 `new` 的 `unimplemented!()`),注入 mock
177
187
  /// transport + mock provider registry + 可选 save 注入钩 + ORDER 探针。**纯 test-support
178
188
  /// 脚手架**(真实 impl,非 `unimplemented!()`):它只装配字段,不执行任何 daemon 逻辑;
@@ -189,6 +199,7 @@ impl Coordinator {
189
199
  workspace,
190
200
  provider_registry,
191
201
  transport,
202
+ daemon_team_key: None,
192
203
  save_hook,
193
204
  order_recorder,
194
205
  }
@@ -224,8 +235,12 @@ impl Coordinator {
224
235
  // `team-prerelease-040-round3b`) and emit `coordinator.session_missing`
225
236
  // even though the right session is alive. Fall back to raw state when
226
237
  // no team scope can be derived (legacy single-team workspaces).
227
- let mut state = coordinator_team_scoped_state(self.workspace.as_path(), &raw_state)
228
- .unwrap_or(raw_state);
238
+ let mut state = coordinator_team_scoped_state(
239
+ self.workspace.as_path(),
240
+ &raw_state,
241
+ self.daemon_team_key.as_deref(),
242
+ )
243
+ .unwrap_or(raw_state);
229
244
  let store = crate::message_store::MessageStore::open(self.workspace.as_path())?;
230
245
  let event_log = EventLog::new(self.workspace.as_path());
231
246
  increment_coordinator_tick_iteration_count(&self.workspace);
@@ -1264,27 +1279,7 @@ impl Coordinator {
1264
1279
  /// `coordinator_health`(`lifecycle.py:26`)。pid + meta + schema 三合一健康。
1265
1280
  /// doctor / start 前置调它。`ok = running ∧ metadata_ok ∧ schema_ok`。
1266
1281
  pub fn health(&self) -> Result<HealthReport, TickError> {
1267
- let schema = self.schema_health();
1268
- let pid_path = coordinator_pid_path(&self.workspace);
1269
- let pid = read_pid_file(&pid_path);
1270
- let (status, running) = match pid {
1271
- Some(pid) if pid_is_running(pid).unwrap_or(false) => {
1272
- (CoordinatorHealthStatus::Running, true)
1273
- }
1274
- Some(_) => (CoordinatorHealthStatus::Stale, false),
1275
- None if pid_path.exists() => (CoordinatorHealthStatus::InvalidPid, false),
1276
- None => (CoordinatorHealthStatus::Missing, false),
1277
- };
1278
- let metadata = read_coordinator_metadata(&self.workspace);
1279
- let metadata_ok = pid.is_some_and(|p| coordinator_metadata_ok(metadata.as_ref(), p));
1280
- Ok(HealthReport {
1281
- ok: running && metadata_ok && schema.ok,
1282
- status,
1283
- pid,
1284
- metadata,
1285
- metadata_ok,
1286
- schema,
1287
- })
1282
+ Ok(super::health::coordinator_health(&self.workspace))
1288
1283
  }
1289
1284
 
1290
1285
  /// `start_coordinator`(`lifecycle.py:49`)。幂等启动:已健康 no-op;metadata 不兼容先 stop 再起;
@@ -1292,40 +1287,7 @@ impl Coordinator {
1292
1287
  /// Python 是 `python -m team_agent.coordinator`,`lifecycle.py:108`)。
1293
1288
  /// **schema 兼容门**:三元任一不匹配 → restart_incompatible,**不可静默继续**(card §89)。
1294
1289
  pub fn start(&self) -> Result<StartReport, StartError> {
1295
- let health = self
1296
- .health()
1297
- .map_err(|e| std::io::Error::other(e.to_string()))?;
1298
- if health.ok {
1299
- return Ok(StartReport {
1300
- ok: true,
1301
- pid: health.pid,
1302
- status: StartOutcome::AlreadyRunning,
1303
- log: Some(coordinator_log_path(&self.workspace)),
1304
- schema_error: None,
1305
- action: None,
1306
- });
1307
- }
1308
- if !health.schema.ok {
1309
- return Ok(StartReport {
1310
- ok: false,
1311
- pid: health.pid,
1312
- status: StartOutcome::SchemaIncompatible,
1313
- log: None,
1314
- schema_error: health.schema.error,
1315
- action: health.schema.action,
1316
- });
1317
- }
1318
- let pid = Pid::new(std::process::id());
1319
- write_coordinator_metadata(&self.workspace, pid, MetadataSource::Start)?;
1320
- std::fs::write(coordinator_pid_path(&self.workspace), pid.to_string())?;
1321
- Ok(StartReport {
1322
- ok: true,
1323
- pid: Some(pid),
1324
- status: StartOutcome::Started,
1325
- log: Some(coordinator_log_path(&self.workspace)),
1326
- schema_error: None,
1327
- action: None,
1328
- })
1290
+ super::health::start_coordinator(&self.workspace)
1329
1291
  }
1330
1292
 
1331
1293
  /// `stop_coordinator`(`lifecycle.py:229`)。SIGTERM + 清 pid/meta。pid 非整数 → 清文件返回。
@@ -2544,4 +2506,44 @@ mod u1_tests {
2544
2506
  "capture_missing failure must be visible in events.jsonl; got {events}"
2545
2507
  );
2546
2508
  }
2509
+
2510
+ #[test]
2511
+ fn daemon_team_key_projection_beats_stale_root_active_team_key() {
2512
+ let dir = std::env::temp_dir().join(format!(
2513
+ "team-agent-daemon-team-key-{}-{}",
2514
+ std::process::id(),
2515
+ std::time::SystemTime::now()
2516
+ .duration_since(std::time::UNIX_EPOCH)
2517
+ .unwrap()
2518
+ .as_nanos()
2519
+ ));
2520
+ std::fs::create_dir_all(&dir).unwrap();
2521
+ let raw = serde_json::json!({
2522
+ "active_team_key": "stale-b",
2523
+ "session_name": "team-stale-b",
2524
+ "teams": {
2525
+ "fresh-a": {
2526
+ "active_team_key": "fresh-a",
2527
+ "team_key": "fresh-a",
2528
+ "session_name": "team-fresh-a",
2529
+ "agents": {}
2530
+ },
2531
+ "stale-b": {
2532
+ "active_team_key": "stale-b",
2533
+ "team_key": "stale-b",
2534
+ "session_name": "team-stale-b",
2535
+ "agents": {}
2536
+ }
2537
+ }
2538
+ });
2539
+ crate::state::persist::save_runtime_state(&dir, &raw).unwrap();
2540
+
2541
+ let selected =
2542
+ coordinator_team_scoped_state(&dir, &raw, Some("fresh-a")).expect("selected team");
2543
+
2544
+ assert_eq!(
2545
+ selected.get("session_name").and_then(Value::as_str),
2546
+ Some("team-fresh-a")
2547
+ );
2548
+ }
2547
2549
  }
@@ -112,6 +112,8 @@ pub enum StartOutcome {
112
112
  SchemaIncompatible,
113
113
  /// 正常 spawn(`lifecycle.py:121`)。
114
114
  Started,
115
+ /// 当前 CLI 发现 live daemon 的 binary identity 过期,stop 后重新 spawn。
116
+ StartedAfterRotation,
115
117
  }
116
118
 
117
119
  /// `stop_coordinator` 结果 status(`lifecycle.py:232,238,243,247`)。
@@ -170,11 +172,50 @@ pub struct CoordinatorMetadata {
170
172
  pub pid: Pid,
171
173
  pub protocol_version: u32,
172
174
  pub message_store_schema_version: i64,
175
+ #[serde(default, skip_serializing_if = "Option::is_none")]
176
+ pub binary_path: Option<String>,
177
+ #[serde(default, skip_serializing_if = "Option::is_none")]
178
+ pub binary_version: Option<String>,
173
179
  pub source: MetadataSource,
174
180
  /// ISO8601(`datetime.now(timezone.utc).isoformat()`,`metadata.py:56`)。
175
181
  pub updated_at: String,
176
182
  }
177
183
 
184
+ /// Current coordinator daemon binary identity. Version comes from the compiled
185
+ /// package; path comes from the currently executing CLI binary, not PATH.
186
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187
+ pub struct CoordinatorBinaryIdentity {
188
+ pub binary_path: String,
189
+ pub binary_version: String,
190
+ }
191
+
192
+ /// Stable machine-readable reason for coordinator metadata rejection.
193
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
194
+ #[serde(rename_all = "snake_case")]
195
+ pub enum CoordinatorMetadataMismatchReason {
196
+ MetadataMissing,
197
+ PidMismatch,
198
+ ProtocolVersionMismatch,
199
+ MessageStoreSchemaVersionMismatch,
200
+ BinaryIdentityMissing,
201
+ BinaryVersionMismatch,
202
+ BinaryPathMismatch,
203
+ }
204
+
205
+ impl CoordinatorMetadataMismatchReason {
206
+ pub fn as_str(self) -> &'static str {
207
+ match self {
208
+ Self::MetadataMissing => "metadata_missing",
209
+ Self::PidMismatch => "pid_mismatch",
210
+ Self::ProtocolVersionMismatch => "protocol_version_mismatch",
211
+ Self::MessageStoreSchemaVersionMismatch => "message_store_schema_version_mismatch",
212
+ Self::BinaryIdentityMissing => "binary_identity_missing",
213
+ Self::BinaryVersionMismatch => "binary_version_mismatch",
214
+ Self::BinaryPathMismatch => "binary_path_mismatch",
215
+ }
216
+ }
217
+ }
218
+
178
219
  // ===========================================================================
179
220
  // SchemaHealth(lifecycle.py:197-226)
180
221
  // ===========================================================================
@@ -244,6 +285,8 @@ pub struct HealthReport {
244
285
  pub metadata: Option<CoordinatorMetadata>,
245
286
  /// 三元全等(`coordinator_metadata_ok`,`metadata.py:37-43`)。
246
287
  pub metadata_ok: bool,
288
+ pub metadata_mismatch_reason: Option<String>,
289
+ pub current_binary_identity: CoordinatorBinaryIdentity,
247
290
  pub schema: SchemaHealth,
248
291
  }
249
292
 
@@ -253,6 +296,10 @@ pub struct StartReport {
253
296
  pub ok: bool,
254
297
  pub pid: Option<Pid>,
255
298
  pub status: StartOutcome,
299
+ pub previous_pid: Option<Pid>,
300
+ pub binary_path: Option<String>,
301
+ pub binary_version: Option<String>,
302
+ pub rotation_reason: Option<String>,
256
303
  /// coordinator.log 路径(成功路径,`lifecycle.py:54/121`)。
257
304
  pub log: Option<PathBuf>,
258
305
  /// schema_incompatible 时的修复 hint / 失败原因。
@@ -9,7 +9,9 @@ use crate::cli::CliError;
9
9
  use crate::coordinator::health::{
10
10
  coordinator_metadata_ok, pid_is_running, read_coordinator_metadata, terminate_pid_tree,
11
11
  };
12
- use crate::coordinator::types::{OrphanReason, Pid, WorkspacePath};
12
+ use crate::coordinator::types::{
13
+ CoordinatorMetadata, OrphanReason, Pid, WorkspacePath, PROTOCOL_VERSION,
14
+ };
13
15
  // Phase 1d Batch 6: `TmuxBackend` import removed — all sites now use
14
16
  // `transport_factory::tmux_socket_name_transport` for grep-visibility.
15
17
  use crate::transport::{SessionName, Transport};
@@ -477,8 +479,7 @@ fn ps_command_rows() -> Vec<ProcessRow> {
477
479
  Command::new("ps").args(["-axww", "-o", "pid=,command="]),
478
480
  "ps_table",
479
481
  None,
480
- )
481
- {
482
+ ) {
482
483
  Ok(output) if output.status.success() => output,
483
484
  _ => return Vec::new(),
484
485
  };
@@ -490,9 +491,7 @@ fn ps_command_rows() -> Vec<ProcessRow> {
490
491
 
491
492
  fn parse_ps_command_line(line: &str) -> Option<ProcessRow> {
492
493
  let line = line.trim_start();
493
- let split = line
494
- .find(char::is_whitespace)
495
- .unwrap_or(line.len());
494
+ let split = line.find(char::is_whitespace).unwrap_or(line.len());
496
495
  let pid = line.get(..split)?.trim().parse::<u32>().ok()?;
497
496
  let command = line.get(split..)?.trim().to_string();
498
497
  Some(ProcessRow {
@@ -568,7 +567,7 @@ fn classify_workspace_orphan(workspace: &Path, pid: Pid) -> Option<OrphanReason>
568
567
  }
569
568
  let workspace_path = WorkspacePath::new(workspace.to_path_buf());
570
569
  let metadata = read_coordinator_metadata(&workspace_path);
571
- if metadata.is_some() && !coordinator_metadata_ok(metadata.as_ref(), pid) {
570
+ if coordinator_process_metadata_mismatch(metadata.as_ref(), pid) {
572
571
  return Some(OrphanReason::MetadataMismatch);
573
572
  }
574
573
  if pid_is_running(pid).ok() == Some(false) {
@@ -577,6 +576,15 @@ fn classify_workspace_orphan(workspace: &Path, pid: Pid) -> Option<OrphanReason>
577
576
  None
578
577
  }
579
578
 
579
+ fn coordinator_process_metadata_mismatch(metadata: Option<&CoordinatorMetadata>, pid: Pid) -> bool {
580
+ let Some(metadata) = metadata else {
581
+ return false;
582
+ };
583
+ metadata.pid != pid
584
+ || metadata.protocol_version != PROTOCOL_VERSION
585
+ || metadata.message_store_schema_version != crate::db::schema::SCHEMA_VERSION
586
+ }
587
+
580
588
  fn classify_workspace_without_pid(workspace: &Path) -> Option<OrphanReason> {
581
589
  if !workspace.is_absolute() {
582
590
  return None;
@@ -592,10 +600,7 @@ fn classify_workspace_without_pid(workspace: &Path) -> Option<OrphanReason> {
592
600
 
593
601
  fn ephemeral_workspace_hint(workspace: &Path) -> Option<String> {
594
602
  let text = workspace.to_string_lossy();
595
- let patterns = [
596
- "ta_doctor_comms_orphans-",
597
- "team-agent-watcher-dedupe",
598
- ];
603
+ let patterns = ["ta_doctor_comms_orphans-", "team-agent-watcher-dedupe"];
599
604
  patterns
600
605
  .iter()
601
606
  .find(|pattern| text.contains(**pattern))
@@ -626,8 +631,7 @@ fn ps_parent_map() -> BTreeMap<u32, u32> {
626
631
  Command::new("ps").args(["-axo", "pid=,ppid="]),
627
632
  "ps_parent",
628
633
  None,
629
- )
630
- {
634
+ ) {
631
635
  Ok(output) if output.status.success() => output,
632
636
  _ => return BTreeMap::new(),
633
637
  };
@@ -732,4 +736,22 @@ mod tests {
732
736
  Some(PathBuf::from("/tmp/My Agent"))
733
737
  );
734
738
  }
739
+
740
+ #[test]
741
+ fn process_orphan_metadata_ignores_binary_identity_rotation() {
742
+ let metadata = CoordinatorMetadata {
743
+ pid: Pid::new(123),
744
+ protocol_version: PROTOCOL_VERSION,
745
+ message_store_schema_version: crate::db::schema::SCHEMA_VERSION,
746
+ binary_path: Some("/old/team-agent".to_string()),
747
+ binary_version: Some("0.5.17".to_string()),
748
+ source: crate::coordinator::MetadataSource::Boot,
749
+ updated_at: "2026-07-09T00:00:00+00:00".to_string(),
750
+ };
751
+
752
+ assert!(
753
+ !coordinator_process_metadata_mismatch(Some(&metadata), Pid::new(123)),
754
+ "binary identity drift is a rotation signal, not orphan cleanup proof"
755
+ );
756
+ }
735
757
  }
@@ -22,6 +22,7 @@
22
22
  use std::path::{Path, PathBuf};
23
23
 
24
24
  use serde::{Deserialize, Serialize};
25
+ use serde_json::{json, Value};
25
26
  use sha2::{Digest, Sha256};
26
27
 
27
28
  use crate::transport::Transport;
@@ -98,6 +99,26 @@ pub const AMBIGUOUS_NAMES_FIELD: &str = "ambiguous_names";
98
99
  /// a fully-qualified form.
99
100
  pub const REASON_AMBIGUOUS: &str = "name_ambiguous";
100
101
 
102
+ #[derive(Debug, Clone, PartialEq, Eq)]
103
+ pub struct RegistryWriteOutcome {
104
+ pub status: &'static str,
105
+ pub path: Option<PathBuf>,
106
+ pub team_key: String,
107
+ pub workspace_hash: String,
108
+ pub source: String,
109
+ pub owner_epoch: u64,
110
+ }
111
+
112
+ impl RegistryWriteOutcome {
113
+ #[must_use]
114
+ pub fn response_json(&self) -> Value {
115
+ match &self.path {
116
+ Some(path) => json!({"status": self.status, "path": path.display().to_string()}),
117
+ None => json!({"status": self.status}),
118
+ }
119
+ }
120
+ }
121
+
101
122
  /// Absolute path to `~/.team-agent/leaders`. Returns `None` when the
102
123
  /// caller has no HOME (e.g. some CI shells) — callers should treat this as
103
124
  /// "no registry" rather than an error, since registry is derived.
@@ -170,6 +191,89 @@ pub fn build_entry(
170
191
  }
171
192
  }
172
193
 
194
+ /// After a canonical leader binding succeeds, write the derived host
195
+ /// discovery entry from the just-persisted state. Registry failure is
196
+ /// non-fatal: callers get a write_failed outcome and the binding remains
197
+ /// successful.
198
+ pub fn register_binding_from_state_best_effort(
199
+ workspace: &Path,
200
+ team: Option<&str>,
201
+ source: &str,
202
+ ) -> Option<RegistryWriteOutcome> {
203
+ let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
204
+ return None;
205
+ };
206
+ let team_key = match team.filter(|team| !team.is_empty()) {
207
+ Some(team) => team.to_string(),
208
+ None => crate::state::projection::team_state_key(&state),
209
+ };
210
+ let receiver = state
211
+ .get("teams")
212
+ .and_then(|value| value.as_object())
213
+ .and_then(|teams| teams.get(&team_key))
214
+ .and_then(|team| team.get("leader_receiver"))
215
+ .or_else(|| state.get("leader_receiver"))
216
+ .cloned()?;
217
+ let transport_kind = receiver
218
+ .get("transport_kind")
219
+ .and_then(Value::as_str)
220
+ .unwrap_or("direct_tmux")
221
+ .to_string();
222
+ let owner_epoch = receiver
223
+ .get("owner_epoch")
224
+ .and_then(Value::as_u64)
225
+ .or_else(|| {
226
+ state
227
+ .get("teams")
228
+ .and_then(|value| value.as_object())
229
+ .and_then(|teams| teams.get(&team_key))
230
+ .and_then(|team| team.get("owner_epoch"))
231
+ .and_then(Value::as_u64)
232
+ })
233
+ .unwrap_or(0);
234
+ let entry = build_entry(
235
+ workspace,
236
+ &team_key,
237
+ &transport_kind,
238
+ receiver,
239
+ owner_epoch,
240
+ source,
241
+ chrono::Utc::now().to_rfc3339(),
242
+ );
243
+ let event_log = crate::event_log::EventLog::new(workspace);
244
+ let write_result = write_entry_best_effort(&entry);
245
+ let status = if let Some(path) = &write_result {
246
+ let _ = event_log.write(
247
+ EVENT_REGISTERED,
248
+ json!({
249
+ "path": path.display().to_string(),
250
+ "team_key": team_key,
251
+ "workspace_hash": entry.workspace_hash,
252
+ "source": source,
253
+ }),
254
+ );
255
+ "registered"
256
+ } else {
257
+ let _ = event_log.write(
258
+ EVENT_WRITE_FAILED,
259
+ json!({
260
+ "team_key": team_key,
261
+ "workspace_hash": entry.workspace_hash,
262
+ "source": source,
263
+ }),
264
+ );
265
+ "write_failed"
266
+ };
267
+ Some(RegistryWriteOutcome {
268
+ status,
269
+ path: write_result,
270
+ team_key,
271
+ workspace_hash: entry.workspace_hash,
272
+ source: source.to_string(),
273
+ owner_epoch: entry.owner_epoch,
274
+ })
275
+ }
276
+
173
277
  fn entry_filename(entry: &LeaderRegistryEntry) -> String {
174
278
  format!("{}__{}.json", entry.workspace_hash, entry.team_key)
175
279
  }
@@ -157,7 +157,8 @@ pub(crate) fn start_agent_at_paths(
157
157
  write_team_state(spec_workspace, &spec, &state)?;
158
158
  }
159
159
  replay_worker_target_missing_messages(workspace, agent_id, &team_key, &state, transport)?;
160
- let coordinator_started = start_coordinator_for_workspace(workspace)?;
160
+ let coordinator = start_coordinator_for_workspace(workspace, Some(&team_key))?;
161
+ let coordinator_started = coordinator.ok;
161
162
  let target = format!("{}:{window}", session_name.as_str());
162
163
  if let Some(new_binding) = refreshed_binding.as_ref() {
163
164
  if old_binding.as_ref() != Some(new_binding) {
@@ -310,7 +311,8 @@ pub(crate) fn start_agent_at_paths(
310
311
  tmux_start_mode_for_spawn(&spawn, into_existing_session),
311
312
  )?;
312
313
  replay_worker_target_missing_messages(workspace, agent_id, &team_key, &state, transport)?;
313
- let coordinator_started = start_coordinator_for_workspace(workspace)?;
314
+ let coordinator = start_coordinator_for_workspace(workspace, Some(&team_key))?;
315
+ let coordinator_started = coordinator.ok;
314
316
  Ok(StartAgentOutcome::Running {
315
317
  env: AgentActionEnvelope {
316
318
  agent_id: agent_id.clone(),
@@ -449,10 +449,13 @@ fn window_present_in_live(
449
449
  false
450
450
  }
451
451
 
452
- pub(super) fn start_coordinator_for_workspace(workspace: &Path) -> Result<bool, LifecycleError> {
452
+ pub(super) fn start_coordinator_for_workspace(
453
+ workspace: &Path,
454
+ team_key: Option<&str>,
455
+ ) -> Result<crate::lifecycle::CoordinatorStartSummary, LifecycleError> {
453
456
  let workspace = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
454
- crate::coordinator::start_coordinator(&workspace)
455
- .map(|report| report.ok)
457
+ crate::coordinator::health::start_coordinator_with_team(&workspace, team_key)
458
+ .map(|report| crate::lifecycle::CoordinatorStartSummary::from_start_report(&report))
456
459
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
457
460
  }
458
461
 
@@ -590,6 +590,7 @@ fn restart_with_selected_team_and_transport(
590
590
  &successful_agents,
591
591
  &failed_agents,
592
592
  "fail",
593
+ None,
593
594
  )?;
594
595
  return Ok(RestartReport::Failed {
595
596
  session_name,
@@ -606,6 +607,7 @@ fn restart_with_selected_team_and_transport(
606
607
  &successful_agents,
607
608
  &failed_agents,
608
609
  "fail",
610
+ None,
609
611
  )?;
610
612
  return Ok(RestartReport::Failed {
611
613
  session_name,
@@ -824,6 +826,7 @@ fn restart_with_selected_team_and_transport(
824
826
  &successful_agents,
825
827
  &failed_agents,
826
828
  "fail",
829
+ None,
827
830
  )?;
828
831
  return Ok(RestartReport::Failed {
829
832
  session_name,
@@ -833,7 +836,9 @@ fn restart_with_selected_team_and_transport(
833
836
  });
834
837
  }
835
838
  drop(lifecycle_lock);
836
- let coordinator_started = start_coordinator_for_workspace(&selected.run_workspace)?;
839
+ let coordinator =
840
+ start_coordinator_for_workspace(&selected.run_workspace, Some(&selected.team_key))?;
841
+ let coordinator_started = coordinator.ok;
837
842
  wait_restart_readiness_or_timeout(
838
843
  &selected.run_workspace,
839
844
  &state,
@@ -862,6 +867,7 @@ fn restart_with_selected_team_and_transport(
862
867
  &successful_agents,
863
868
  &failed_agents,
864
869
  "partial",
870
+ Some(&coordinator),
865
871
  )?;
866
872
  // 0.3.30 Bug 1: auto-attach on partial restart too — workers that did
867
873
  // come up still need a leader_receiver pane to deliver report_result.
@@ -875,6 +881,7 @@ fn restart_with_selected_team_and_transport(
875
881
  agents: successful_agents,
876
882
  failed_agents,
877
883
  coordinator_started,
884
+ coordinator,
878
885
  next_actions,
879
886
  attach_commands,
880
887
  });
@@ -884,6 +891,7 @@ fn restart_with_selected_team_and_transport(
884
891
  &successful_agents,
885
892
  &failed_agents,
886
893
  "ok",
894
+ Some(&coordinator),
887
895
  )?;
888
896
  // 0.3.30 Bug 1: auto-attach leader from caller's TMUX_PANE if available.
889
897
  // Mirrors quick-start's seed_launched_owner_from_env behaviour: a restart
@@ -900,6 +908,7 @@ fn restart_with_selected_team_and_transport(
900
908
  session_name,
901
909
  agents: successful_agents,
902
910
  coordinator_started,
911
+ coordinator,
903
912
  next_actions,
904
913
  attach_commands,
905
914
  })
@@ -1404,6 +1413,11 @@ fn try_autobind_leader_after_restart(
1404
1413
  let team_str = team;
1405
1414
  match crate::leader::attach_leader(workspace, team_str, None, provider) {
1406
1415
  Ok(result) if result.ok => {
1416
+ let _ = crate::leader::registry::register_binding_from_state_best_effort(
1417
+ workspace,
1418
+ team_str,
1419
+ "restart-auto-attach",
1420
+ );
1407
1421
  eprintln!(
1408
1422
  "team_agent::restart auto_attach_leader ok pane={:?} team={:?}",
1409
1423
  result.bound_pane_id.as_ref().map(|p| p.as_str()),
@@ -1948,27 +1962,32 @@ fn write_restart_completed_event(
1948
1962
  successful_agents: &[RestartedAgent],
1949
1963
  failed_agents: &[RestartFailedAgent],
1950
1964
  rc: &str,
1965
+ coordinator: Option<&crate::lifecycle::CoordinatorStartSummary>,
1951
1966
  ) -> Result<(), LifecycleError> {
1967
+ let mut payload = serde_json::json!({
1968
+ "rc": rc,
1969
+ "status": rc,
1970
+ "successful_agents": successful_agents
1971
+ .iter()
1972
+ .map(|agent| agent.agent_id.as_str())
1973
+ .collect::<Vec<_>>(),
1974
+ "failed_agents": failed_agents
1975
+ .iter()
1976
+ .map(|failure| serde_json::json!({
1977
+ "agent_id": failure.agent_id.as_str(),
1978
+ "phase": failure.phase,
1979
+ "error": failure.error,
1980
+ }))
1981
+ .collect::<Vec<_>>(),
1982
+ });
1983
+ if let (Some(object), Some(coordinator)) = (payload.as_object_mut(), coordinator) {
1984
+ object.insert(
1985
+ "coordinator".to_string(),
1986
+ crate::lifecycle::coordinator_start_summary_value(coordinator),
1987
+ );
1988
+ }
1952
1989
  crate::event_log::EventLog::new(workspace)
1953
- .write(
1954
- "restart.completed",
1955
- serde_json::json!({
1956
- "rc": rc,
1957
- "status": rc,
1958
- "successful_agents": successful_agents
1959
- .iter()
1960
- .map(|agent| agent.agent_id.as_str())
1961
- .collect::<Vec<_>>(),
1962
- "failed_agents": failed_agents
1963
- .iter()
1964
- .map(|failure| serde_json::json!({
1965
- "agent_id": failure.agent_id.as_str(),
1966
- "phase": failure.phase,
1967
- "error": failure.error,
1968
- }))
1969
- .collect::<Vec<_>>(),
1970
- }),
1971
- )
1990
+ .write("restart.completed", payload)
1972
1991
  .map(|_| ())
1973
1992
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
1974
1993
  }
@@ -3,6 +3,9 @@ use crate::transport::test_support::OfflineTransport;
3
3
  use serde_json::json;
4
4
  use serial_test::serial;
5
5
 
6
+ #[allow(dead_code)]
7
+ struct HermeticTestEnv;
8
+
6
9
  const QS_TEAM_MD: &str =
7
10
  "---\nname: quickteam\nobjective: Quick start.\nprovider: codex\n---\n\nQuick-start team.\n";
8
11
  pub(super) const QS_VALID_ROLE: &str = "---\nname: implementer\nrole: Implementation Engineer\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nImplement bounded tasks.\n";