@team-agent/installer 0.3.12 → 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 (29) 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/tick.rs +16 -83
  11. package/crates/team-agent/src/leader/lease.rs +4 -20
  12. package/crates/team-agent/src/leader/mod.rs +3 -1
  13. package/crates/team-agent/src/leader/owner_bind.rs +46 -48
  14. package/crates/team-agent/src/leader/provider_attribution.rs +214 -0
  15. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -3
  16. package/crates/team-agent/src/leader/rediscover.rs +34 -17
  17. package/crates/team-agent/src/lifecycle/launch.rs +158 -12
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +229 -28
  19. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +135 -8
  20. package/crates/team-agent/src/lifecycle/types.rs +28 -0
  21. package/crates/team-agent/src/messaging/delivery.rs +5 -1
  22. package/crates/team-agent/src/messaging/helpers.rs +1 -1
  23. package/crates/team-agent/src/messaging/tests/runtime.rs +14 -0
  24. package/crates/team-agent/src/provider/adapter.rs +6 -3
  25. package/crates/team-agent/src/provider/startup_prompt.rs +173 -0
  26. package/crates/team-agent/src/provider/testdata/copilot-ready-marker.txt +5 -0
  27. package/crates/team-agent/src/provider/testdata/copilot-trust-prompt.txt +19 -0
  28. package/crates/team-agent/src/transport/test_support.rs +22 -7
  29. package/package.json +4 -4
@@ -383,6 +383,7 @@ struct LeaderTarget {
383
383
  current_command: Option<String>,
384
384
  current_path: Option<PathBuf>,
385
385
  active: bool,
386
+ pane_pid: Option<u32>,
386
387
  leader_env: BTreeMap<String, String>,
387
388
  provider: Option<Provider>,
388
389
  leader_session_uuid: Option<LeaderSessionUuid>,
@@ -394,7 +395,7 @@ impl LeaderTarget {
394
395
  if !info.active {
395
396
  return None;
396
397
  }
397
- let provider = leader_command_provider(info.current_command.as_deref().unwrap_or(""));
398
+ let provider = super::attribute_pane_provider(info);
398
399
  let leader_session_uuid = info
399
400
  .leader_env
400
401
  .get("TEAM_AGENT_LEADER_SESSION_UUID")
@@ -411,6 +412,7 @@ impl LeaderTarget {
411
412
  current_command: info.current_command.clone(),
412
413
  current_path: info.current_path.clone(),
413
414
  active: info.active,
415
+ pane_pid: info.pane_pid,
414
416
  leader_env: info.leader_env.clone(),
415
417
  provider,
416
418
  leader_session_uuid,
@@ -508,8 +510,26 @@ impl OwnerIdentity {
508
510
  fn target_from_value(value: &Value) -> Option<LeaderTarget> {
509
511
  let pane_id = get_str(value, "pane_id").or_else(|| get_str(value, "pane"))?;
510
512
  let current_command = get_str(value, "pane_current_command").or_else(|| get_str(value, "current_command"));
511
- let provider = current_command.as_deref().and_then(leader_command_provider);
512
513
  let leader_env = map_env(value.get("leader_env").or_else(|| value.get("env")));
514
+ let pane_pid = get_u32(value, "pane_pid");
515
+ let provider = super::attribute_pane_provider(&PaneInfo {
516
+ pane_id: PaneId::new(pane_id.clone()),
517
+ session: get_str(value, "session_name")
518
+ .or_else(|| get_str(value, "session"))
519
+ .map(SessionName::new)
520
+ .unwrap_or_else(|| SessionName::new("")),
521
+ window_index: get_u32(value, "window_index"),
522
+ window_name: get_str(value, "window_name").map(WindowName::new),
523
+ pane_index: get_u32(value, "pane_index"),
524
+ tty: get_str(value, "pane_tty").or_else(|| get_str(value, "tty")),
525
+ current_command: current_command.clone(),
526
+ current_path: get_str(value, "pane_current_path")
527
+ .or_else(|| get_str(value, "current_path"))
528
+ .map(PathBuf::from),
529
+ active: value.get("active").and_then(Value::as_bool).unwrap_or(true),
530
+ pane_pid,
531
+ leader_env: leader_env.clone(),
532
+ });
513
533
  let leader_session_uuid = get_str(value, "leader_session_uuid")
514
534
  .or_else(|| leader_env.get("TEAM_AGENT_LEADER_SESSION_UUID").cloned())
515
535
  .and_then(|raw| serde_json::from_value(Value::String(raw)).ok());
@@ -528,6 +548,7 @@ fn target_from_value(value: &Value) -> Option<LeaderTarget> {
528
548
  .or_else(|| get_str(value, "current_path"))
529
549
  .map(PathBuf::from),
530
550
  active: value.get("active").and_then(Value::as_bool).unwrap_or(true),
551
+ pane_pid,
531
552
  leader_env,
532
553
  provider,
533
554
  leader_session_uuid,
@@ -584,21 +605,6 @@ fn path_in_workspace(path: &Path, workspace: &Path) -> bool {
584
605
  cwd == root || cwd.starts_with(root)
585
606
  }
586
607
 
587
- fn leader_command_provider(command: &str) -> Option<Provider> {
588
- let lower = command.to_ascii_lowercase();
589
- if lower.contains("claude") {
590
- Some(Provider::ClaudeCode)
591
- } else if lower.contains("codex") {
592
- Some(Provider::Codex)
593
- } else if lower.contains("copilot") {
594
- Some(Provider::Copilot)
595
- } else if lower.contains("fake") {
596
- Some(Provider::Fake)
597
- } else {
598
- None
599
- }
600
- }
601
-
602
608
  fn target_matches_owner_identity(target: &LeaderTarget, identity: &OwnerIdentity) -> bool {
603
609
  identity
604
610
  .pane_id
@@ -624,6 +630,7 @@ fn pane_info_value(
624
630
  "pane_index": info.pane_index,
625
631
  "pane_tty": info.tty,
626
632
  "pane_current_command": info.current_command,
633
+ "pane_pid": info.pane_pid,
627
634
  "pane_current_path": info.current_path.as_ref().map(|path| path.to_string_lossy().to_string()),
628
635
  "fingerprint": info.leader_env.get("TEAM_AGENT_MACHINE_FINGERPRINT"),
629
636
  "provider": provider.map(provider_wire),
@@ -1100,5 +1107,15 @@ fn get_str(value: &Value, key: &str) -> Option<String> {
1100
1107
  .filter(|s| !s.is_empty())
1101
1108
  }
1102
1109
 
1110
+ fn get_u32(value: &Value, key: &str) -> Option<u32> {
1111
+ value
1112
+ .get(key)
1113
+ .and_then(|raw| {
1114
+ raw.as_u64()
1115
+ .or_else(|| raw.as_i64().and_then(|n| u64::try_from(n).ok()))
1116
+ })
1117
+ .and_then(|n| u32::try_from(n).ok())
1118
+ }
1119
+
1103
1120
  #[cfg(test)]
1104
1121
  mod tests;
@@ -9,7 +9,7 @@ use crate::model::ids::AgentId;
9
9
  use crate::model::permissions::{self, AgentPermissionInput};
10
10
  use crate::model::yaml::{self, Value};
11
11
  use crate::state::persist::{load_runtime_state, save_runtime_state};
12
- use crate::transport::{SessionName, Target, Transport, WindowName};
12
+ use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
13
13
 
14
14
  use super::*;
15
15
 
@@ -595,7 +595,7 @@ fn merge_workspace_team_state_with_key(
595
595
  launched_key: &str,
596
596
  ) -> serde_json::Value {
597
597
  let mut launched_obj = launched.as_object().cloned().unwrap_or_default();
598
- let mut teams = launched
598
+ let mut teams = existing
599
599
  .get("teams")
600
600
  .and_then(serde_json::Value::as_object)
601
601
  .cloned()
@@ -638,6 +638,41 @@ fn merge_workspace_team_state_with_key(
638
638
  serde_json::Value::Object(merged)
639
639
  }
640
640
 
641
+ #[cfg(test)]
642
+ mod merge_workspace_team_state_with_key_tests {
643
+ use super::*;
644
+ use serde_json::json;
645
+
646
+ #[test]
647
+ fn empty_top_level_existing_session_preserves_existing_teams() {
648
+ let existing = json!({
649
+ "session_name": "",
650
+ "active_team_key": "parent",
651
+ "teams": {
652
+ "parent": {
653
+ "session_name": "team-parent",
654
+ "agents": {"parent_worker": {"status": "running"}}
655
+ }
656
+ }
657
+ });
658
+ let launched = json!({
659
+ "session_name": "team-child",
660
+ "agents": {"child_worker": {"status": "running"}}
661
+ });
662
+ let merged = merge_workspace_team_state_with_key(&existing, &launched, "child");
663
+ assert_eq!(
664
+ merged.pointer("/teams/parent/session_name"),
665
+ Some(&json!("team-parent")),
666
+ "existing.teams must survive even when existing.session_name is empty: {merged}"
667
+ );
668
+ assert_eq!(
669
+ merged.pointer("/teams/child/session_name"),
670
+ Some(&json!("team-child")),
671
+ "launched team must still be inserted under its runtime key: {merged}"
672
+ );
673
+ }
674
+ }
675
+
641
676
  fn promote_launched_binding_from_team_entry(launched: &mut serde_json::Value, launched_key: &str) {
642
677
  let entry = launched
643
678
  .get("teams")
@@ -849,6 +884,29 @@ fn write_invalid_leader_pane_env_warning(workspaces: &[&Path], pane_id_raw: &str
849
884
  }
850
885
  }
851
886
 
887
+ fn warn_ignored_owner_team_id(workspace: &Path, team_dir: &Path, runtime_team_key: &str) {
888
+ let Ok(Some(ignored)) = crate::compiler::ignored_owner_team_id_from_team_md(team_dir) else {
889
+ return;
890
+ };
891
+ eprintln!("Warning: ignored TEAM.md {}={}", ignored.field, ignored.value);
892
+ eprintln!("Reason: owner identity is the canonical runtime team key ({runtime_team_key}), not TEAM.md front matter");
893
+ eprintln!("Action: remove {} from TEAM.md", ignored.field);
894
+ if let Err(err) = crate::event_log::EventLog::new(workspace).write(
895
+ "spec.field_ignored",
896
+ serde_json::json!({
897
+ "field": ignored.field,
898
+ "source": team_dir.join("TEAM.md").to_string_lossy().to_string(),
899
+ "value": ignored.value,
900
+ "warning": "ignored user-set owner_team_id",
901
+ "reason": "owner identity is derived from the canonical runtime team key",
902
+ "action": "remove owner_team_id from TEAM.md",
903
+ "runtime_team_key": runtime_team_key,
904
+ }),
905
+ ) {
906
+ eprintln!("Warning: spec.field_ignored event write failed: {err}");
907
+ }
908
+ }
909
+
852
910
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
853
911
  pub(crate) enum LeaderPaneEnvState {
854
912
  Live,
@@ -932,12 +990,13 @@ fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &
932
990
  let Some(owner) = unbound_launched_owner(launched, launched_key) else {
933
991
  return;
934
992
  };
935
- let provider = launched
936
- .get("team_owner")
937
- .and_then(|owner| owner.get("provider"))
993
+ let Some(provider) = owner
994
+ .get("provider")
938
995
  .and_then(serde_json::Value::as_str)
939
996
  .filter(|provider| !provider.is_empty())
940
- .unwrap_or("codex");
997
+ else {
998
+ return;
999
+ };
941
1000
  let owner_epoch = 1u64;
942
1001
  let receiver = serde_json::json!({
943
1002
  "mode": "direct_tmux",
@@ -958,12 +1017,7 @@ fn unbound_launched_owner(
958
1017
  launched: &serde_json::Value,
959
1018
  launched_key: &str,
960
1019
  ) -> Option<serde_json::Value> {
961
- let provider = launched
962
- .get("team_owner")
963
- .and_then(|owner| owner.get("provider"))
964
- .and_then(serde_json::Value::as_str)
965
- .filter(|provider| !provider.is_empty())
966
- .unwrap_or("codex");
1020
+ let provider = unbound_launched_provider(launched)?;
967
1021
  let machine_fingerprint = launched
968
1022
  .get("team_owner")
969
1023
  .and_then(|owner| owner.get("machine_fingerprint"))
@@ -994,6 +1048,97 @@ fn unbound_launched_owner(
994
1048
  }))
995
1049
  }
996
1050
 
1051
+ fn unbound_launched_provider(launched: &serde_json::Value) -> Option<String> {
1052
+ if let Some(provider) = launched
1053
+ .get("team_owner")
1054
+ .and_then(|owner| owner.get("provider"))
1055
+ .and_then(serde_json::Value::as_str)
1056
+ .filter(|provider| !provider.is_empty())
1057
+ .and_then(parse_provider)
1058
+ .and_then(provider_wire_string)
1059
+ {
1060
+ return Some(provider);
1061
+ }
1062
+ let workspace = launched
1063
+ .get("workspace")
1064
+ .and_then(serde_json::Value::as_str)
1065
+ .filter(|workspace| !workspace.is_empty())?;
1066
+ let pane = launched
1067
+ .get("team_owner")
1068
+ .and_then(|owner| owner.get("pane_id"))
1069
+ .and_then(serde_json::Value::as_str)
1070
+ .filter(|pane| !pane.is_empty())?;
1071
+ let target = PaneId::new(pane);
1072
+ crate::tmux_backend::TmuxBackend::for_workspace(Path::new(workspace))
1073
+ .list_targets()
1074
+ .ok()?
1075
+ .into_iter()
1076
+ .find(|info| info.pane_id == target)
1077
+ .and_then(|info| crate::leader::attribute_pane_provider(&info))
1078
+ .and_then(provider_wire_string)
1079
+ }
1080
+
1081
+ fn provider_wire_string(provider: Provider) -> Option<String> {
1082
+ serde_json::to_value(provider)
1083
+ .ok()
1084
+ .and_then(|value| value.as_str().map(str::to_string))
1085
+ }
1086
+
1087
+ #[cfg(test)]
1088
+ mod e22_unbound_owner_provider_tests {
1089
+ use super::*;
1090
+
1091
+ #[test]
1092
+ fn unbound_owner_preserves_explicit_copilot_provider() {
1093
+ let mut launched = serde_json::json!({
1094
+ "workspace": "/tmp/team-agent-e22",
1095
+ "team_owner": {
1096
+ "provider": "copilot",
1097
+ "machine_fingerprint": "machine"
1098
+ }
1099
+ });
1100
+
1101
+ seed_unbound_launched_owner(&mut launched, "team-e22");
1102
+
1103
+ assert_eq!(
1104
+ launched
1105
+ .pointer("/team_owner/provider")
1106
+ .and_then(serde_json::Value::as_str),
1107
+ Some("copilot")
1108
+ );
1109
+ assert_eq!(
1110
+ launched
1111
+ .pointer("/leader_receiver/provider")
1112
+ .and_then(serde_json::Value::as_str),
1113
+ Some("copilot")
1114
+ );
1115
+ }
1116
+
1117
+ #[test]
1118
+ fn unbound_owner_without_attributed_provider_does_not_default_codex() {
1119
+ let mut launched = serde_json::json!({
1120
+ "workspace": "/tmp/team-agent-e22",
1121
+ "team_owner": {
1122
+ "machine_fingerprint": "machine"
1123
+ }
1124
+ });
1125
+
1126
+ seed_unbound_launched_owner(&mut launched, "team-e22");
1127
+
1128
+ assert!(
1129
+ launched.get("leader_receiver").is_none(),
1130
+ "unattributed unbound owner must not seed a codex receiver: {launched}"
1131
+ );
1132
+ assert!(
1133
+ launched
1134
+ .pointer("/team_owner/provider")
1135
+ .and_then(serde_json::Value::as_str)
1136
+ != Some("codex"),
1137
+ "unattributed unbound owner must not silently become codex: {launched}"
1138
+ );
1139
+ }
1140
+ }
1141
+
997
1142
  fn owner_pane_belongs_to_other_team(
998
1143
  existing: &serde_json::Value,
999
1144
  launched_key: &str,
@@ -2144,6 +2289,7 @@ pub fn quick_start_with_transport_in_workspace(
2144
2289
  let state_team_key = explicit_team_key.clone().unwrap_or_else(|| {
2145
2290
  runtime_team_key_for_spec(&agents_dir.join("team.spec.yaml"), &spec, &session_name)
2146
2291
  });
2292
+ warn_ignored_owner_team_id(workspace.as_path(), agents_dir, &state_team_key);
2147
2293
  // E5 spec 迁移:spec 写到 .team/runtime/<team_key>/(中间产物,绝不落用户目录 agents_dir)。
2148
2294
  // Bug2:原子写(tmp+rename),避免半截 spec。
2149
2295
  let spec_path = crate::model::paths::runtime_spec_path(&workspace, &state_team_key);
@@ -212,35 +212,49 @@ pub fn restart_with_transport_with_session_convergence_deadline(
212
212
  crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
213
213
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
214
214
  }
215
- let mut last_spawned: Option<AgentId> = None;
215
+ let mut successful_agents: Vec<RestartedAgent> = Vec::new();
216
+ let mut failed_agents: Vec<RestartFailedAgent> = Vec::new();
217
+ // B5 restart isolation loop: per-agent spawn failures must be recorded and
218
+ // isolated here. Do not reintroduce `?`, `break`, or `return` inside this loop.
219
+ // BEGIN_B5_RESTART_ISOLATION_LOOP
216
220
  for decision in &plan.decisions {
217
- let agent = state
221
+ let Some(agent) = state
218
222
  .get("agents")
219
223
  .and_then(|v| v.get(decision.agent_id.as_str()))
220
- .ok_or_else(|| {
221
- LifecycleError::RequirementUnmet(format!(
222
- "agent {} not found for restart",
223
- decision.agent_id
224
- ))
225
- })?
226
- .clone();
224
+ .cloned()
225
+ else {
226
+ let error = format!("agent {} not found for restart", decision.agent_id);
227
+ mark_agent_restart_failed(&mut state, decision, &error);
228
+ let _ = write_restart_agent_failed_event(&selected.run_workspace, decision, "spawn", &error);
229
+ failed_agents.push(restart_failed_agent(decision, "spawn", error));
230
+ continue;
231
+ };
227
232
  let session_id = if matches!(decision.restart_mode, StartMode::Resumed) {
228
233
  decision.session_id.as_ref()
229
234
  } else {
230
235
  None
231
236
  };
232
- let session_live = session_live_or_default(transport, &session_name, false);
237
+ let mut session_live = session_live_or_default(transport, &session_name, false);
233
238
  if !session_live {
234
- if let Some(previous) = &last_spawned {
235
- return Err(LifecycleError::Transport(format!(
239
+ if let Some(previous) = successful_agents.pop() {
240
+ let error = format!(
236
241
  "session_disappeared_after_spawn: provider_resume_exited for {}; session {} disappeared before spawning {}",
237
- previous,
242
+ previous.agent_id,
238
243
  session_name.as_str(),
239
244
  decision.agent_id
240
- )));
245
+ );
246
+ mark_agent_restart_failed(&mut state, &previous, &error);
247
+ let _ = write_restart_agent_failed_event(
248
+ &selected.run_workspace,
249
+ &previous,
250
+ "resume",
251
+ &error,
252
+ );
253
+ failed_agents.push(restart_failed_agent(&previous, "resume", error));
254
+ session_live = false;
241
255
  }
242
256
  }
243
- let spawn = spawn_agent_window(
257
+ let spawn = match spawn_agent_window(
244
258
  &selected.run_workspace,
245
259
  &session_name,
246
260
  &decision.agent_id,
@@ -250,10 +264,33 @@ pub fn restart_with_transport_with_session_convergence_deadline(
250
264
  transport,
251
265
  Some(&safety),
252
266
  Some(spec_workspace),
253
- )?;
254
- verify_spawned_agent_live(&decision.agent_id, &spawn, transport)?;
255
- mark_agent_respawned(&mut state, &decision.agent_id, &spawn, transport, &safety)?;
256
- last_spawned = Some(decision.agent_id.clone());
267
+ ) {
268
+ Ok(spawn) => spawn,
269
+ Err(error) => {
270
+ let error = error.to_string();
271
+ mark_agent_restart_failed(&mut state, decision, &error);
272
+ let _ = write_restart_agent_failed_event(&selected.run_workspace, decision, "spawn", &error);
273
+ failed_agents.push(restart_failed_agent(decision, "spawn", error));
274
+ continue;
275
+ }
276
+ };
277
+ if let Err(error) = verify_spawned_agent_live(&decision.agent_id, &spawn, transport)
278
+ .and_then(|_| {
279
+ mark_agent_respawned(&mut state, &decision.agent_id, &spawn, transport, &safety)
280
+ })
281
+ {
282
+ let error = error.to_string();
283
+ mark_agent_restart_failed(&mut state, decision, &error);
284
+ let _ = write_restart_agent_failed_event(
285
+ &selected.run_workspace,
286
+ decision,
287
+ "readiness",
288
+ &error,
289
+ );
290
+ failed_agents.push(restart_failed_agent(decision, "readiness", error));
291
+ continue;
292
+ }
293
+ successful_agents.push(decision.clone());
257
294
  if let Some(agent) = state
258
295
  .get_mut("agents")
259
296
  .and_then(serde_json::Value::as_object_mut)
@@ -263,14 +300,31 @@ pub fn restart_with_transport_with_session_convergence_deadline(
263
300
  persist_effective_approval_policy_for_restart(agent, &safety);
264
301
  }
265
302
  }
303
+ // END_B5_RESTART_ISOLATION_LOOP
266
304
  crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
267
305
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
306
+ if successful_agents.is_empty() && !failed_agents.is_empty() {
307
+ let attach_commands = Vec::new();
308
+ let next_actions = restart_failure_next_actions(&failed_agents);
309
+ write_restart_completed_event(
310
+ &selected.run_workspace,
311
+ &successful_agents,
312
+ &failed_agents,
313
+ "fail",
314
+ )?;
315
+ return Ok(RestartReport::Failed {
316
+ session_name,
317
+ failed_agents,
318
+ next_actions,
319
+ attach_commands,
320
+ });
321
+ }
268
322
  let coordinator_started = start_coordinator_for_workspace(&selected.run_workspace)?;
269
323
  wait_restart_readiness_or_timeout(
270
324
  &selected.run_workspace,
271
325
  &state,
272
326
  &session_name,
273
- &plan.decisions,
327
+ &successful_agents,
274
328
  transport,
275
329
  restart_readiness_deadline(readiness_deadline_ms),
276
330
  restart_readiness_poll_interval(),
@@ -278,14 +332,37 @@ pub fn restart_with_transport_with_session_convergence_deadline(
278
332
  let attach_commands = crate::tmux_backend::attach_commands_for_windows(
279
333
  &selected.run_workspace,
280
334
  &session_name,
281
- plan.decisions
335
+ successful_agents
282
336
  .iter()
283
337
  .map(|decision| decision.agent_id.as_str()),
284
338
  );
285
- let next_actions = attach_commands.clone();
339
+ let mut next_actions = attach_commands.clone();
340
+ if !failed_agents.is_empty() {
341
+ next_actions.extend(restart_failure_next_actions(&failed_agents));
342
+ write_restart_completed_event(
343
+ &selected.run_workspace,
344
+ &successful_agents,
345
+ &failed_agents,
346
+ "partial",
347
+ )?;
348
+ return Ok(RestartReport::Partial {
349
+ session_name,
350
+ agents: successful_agents,
351
+ failed_agents,
352
+ coordinator_started,
353
+ next_actions,
354
+ attach_commands,
355
+ });
356
+ }
357
+ write_restart_completed_event(
358
+ &selected.run_workspace,
359
+ &successful_agents,
360
+ &failed_agents,
361
+ "ok",
362
+ )?;
286
363
  Ok(RestartReport::Restarted {
287
364
  session_name,
288
- agents: plan.decisions,
365
+ agents: successful_agents,
289
366
  coordinator_started,
290
367
  next_actions,
291
368
  attach_commands,
@@ -749,6 +826,134 @@ fn mark_agent_respawned(
749
826
  Ok(())
750
827
  }
751
828
 
829
+ fn restart_failed_agent(
830
+ decision: &RestartedAgent,
831
+ phase: impl Into<String>,
832
+ error: String,
833
+ ) -> RestartFailedAgent {
834
+ RestartFailedAgent {
835
+ agent_id: decision.agent_id.clone(),
836
+ restart_mode: decision.restart_mode,
837
+ decision: decision.decision,
838
+ session_id: decision.session_id.clone(),
839
+ phase: phase.into(),
840
+ error,
841
+ }
842
+ }
843
+
844
+ fn mark_agent_restart_failed(
845
+ state: &mut serde_json::Value,
846
+ decision: &RestartedAgent,
847
+ error: &str,
848
+ ) {
849
+ let Some(agent) = state
850
+ .get_mut("agents")
851
+ .and_then(serde_json::Value::as_object_mut)
852
+ .and_then(|agents| agents.get_mut(decision.agent_id.as_str()))
853
+ .and_then(serde_json::Value::as_object_mut)
854
+ else {
855
+ return;
856
+ };
857
+ agent.insert("status".to_string(), serde_json::json!("failed"));
858
+ agent.insert("restart_error".to_string(), serde_json::json!(error));
859
+ agent.insert(
860
+ "restart_failed_at".to_string(),
861
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
862
+ );
863
+ agent.remove("pane_id");
864
+ agent.remove("pane_pid");
865
+ }
866
+
867
+ fn write_restart_agent_failed_event(
868
+ workspace: &Path,
869
+ decision: &RestartedAgent,
870
+ phase: &str,
871
+ error: &str,
872
+ ) -> Result<(), LifecycleError> {
873
+ crate::event_log::EventLog::new(workspace)
874
+ .write(
875
+ "restart.agent_failed",
876
+ serde_json::json!({
877
+ "agent_id": decision.agent_id.as_str(),
878
+ "restart_mode": start_mode_wire(decision.restart_mode),
879
+ "decision": resume_decision_wire(decision.decision),
880
+ "session_id": decision.session_id.as_ref().map(|session| session.as_str()),
881
+ "phase": phase,
882
+ "error": error,
883
+ "action": format!(
884
+ "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
885
+ decision.agent_id,
886
+ decision.agent_id
887
+ ),
888
+ "log": format!(
889
+ ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
890
+ decision.agent_id
891
+ ),
892
+ }),
893
+ )
894
+ .map(|_| ())
895
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
896
+ }
897
+
898
+ fn write_restart_completed_event(
899
+ workspace: &Path,
900
+ successful_agents: &[RestartedAgent],
901
+ failed_agents: &[RestartFailedAgent],
902
+ rc: &str,
903
+ ) -> Result<(), LifecycleError> {
904
+ crate::event_log::EventLog::new(workspace)
905
+ .write(
906
+ "restart.completed",
907
+ serde_json::json!({
908
+ "rc": rc,
909
+ "status": rc,
910
+ "successful_agents": successful_agents
911
+ .iter()
912
+ .map(|agent| agent.agent_id.as_str())
913
+ .collect::<Vec<_>>(),
914
+ "failed_agents": failed_agents
915
+ .iter()
916
+ .map(|failure| serde_json::json!({
917
+ "agent_id": failure.agent_id.as_str(),
918
+ "phase": failure.phase,
919
+ "error": failure.error,
920
+ }))
921
+ .collect::<Vec<_>>(),
922
+ }),
923
+ )
924
+ .map(|_| ())
925
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
926
+ }
927
+
928
+ fn restart_failure_next_actions(failed_agents: &[RestartFailedAgent]) -> Vec<String> {
929
+ failed_agents
930
+ .iter()
931
+ .map(|failure| {
932
+ format!(
933
+ "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
934
+ failure.agent_id, failure.agent_id
935
+ )
936
+ })
937
+ .collect()
938
+ }
939
+
940
+ fn start_mode_wire(mode: StartMode) -> &'static str {
941
+ match mode {
942
+ StartMode::Resumed => "resumed",
943
+ StartMode::Fresh => "fresh",
944
+ StartMode::FreshAfterMissingRollout => "fresh_after_missing_rollout",
945
+ StartMode::Noop => "noop",
946
+ }
947
+ }
948
+
949
+ fn resume_decision_wire(decision: ResumeDecision) -> &'static str {
950
+ match decision {
951
+ ResumeDecision::Resume => "resume",
952
+ ResumeDecision::FreshStart => "fresh_start",
953
+ ResumeDecision::Refuse => "refuse",
954
+ }
955
+ }
956
+
752
957
  fn write_restart_resume_decision_events(
753
958
  workspace: &Path,
754
959
  state: &serde_json::Value,
@@ -768,11 +973,7 @@ fn write_restart_resume_decision_events(
768
973
  .filter(|s| !s.is_empty())
769
974
  .map(|s| s.to_string());
770
975
  let session_id = decision.session_id.as_ref().map(|s| s.as_str().to_string());
771
- let decision_wire = match decision.decision {
772
- ResumeDecision::Resume => "resume",
773
- ResumeDecision::FreshStart => "fresh_start",
774
- ResumeDecision::Refuse => "refuse",
775
- };
976
+ let decision_wire = resume_decision_wire(decision.decision);
776
977
  write_restart_resume_decision_event(
777
978
  workspace,
778
979
  decision.agent_id.as_str(),