@team-agent/installer 0.5.17 → 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
  }
@@ -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
  })
@@ -1953,27 +1962,32 @@ fn write_restart_completed_event(
1953
1962
  successful_agents: &[RestartedAgent],
1954
1963
  failed_agents: &[RestartFailedAgent],
1955
1964
  rc: &str,
1965
+ coordinator: Option<&crate::lifecycle::CoordinatorStartSummary>,
1956
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
+ }
1957
1989
  crate::event_log::EventLog::new(workspace)
1958
- .write(
1959
- "restart.completed",
1960
- serde_json::json!({
1961
- "rc": rc,
1962
- "status": rc,
1963
- "successful_agents": successful_agents
1964
- .iter()
1965
- .map(|agent| agent.agent_id.as_str())
1966
- .collect::<Vec<_>>(),
1967
- "failed_agents": failed_agents
1968
- .iter()
1969
- .map(|failure| serde_json::json!({
1970
- "agent_id": failure.agent_id.as_str(),
1971
- "phase": failure.phase,
1972
- "error": failure.error,
1973
- }))
1974
- .collect::<Vec<_>>(),
1975
- }),
1976
- )
1990
+ .write("restart.completed", payload)
1977
1991
  .map(|_| ())
1978
1992
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
1979
1993
  }
@@ -242,7 +242,7 @@ fn r2_lifecycle_lock_exists_precondition() {
242
242
  assert_public_operation(&rebuild, "restart");
243
243
  let drop_idx = rebuild.find("drop(lifecycle_lock);").unwrap();
244
244
  let coordinator_idx = rebuild
245
- .find("start_coordinator_for_workspace(&selected.run_workspace)")
245
+ .find("start_coordinator_for_workspace(&selected.run_workspace, Some(&selected.team_key))")
246
246
  .unwrap();
247
247
  let readiness_idx = rebuild.find("wait_restart_readiness_or_timeout(").unwrap();
248
248
  assert!(