@team-agent/installer 0.5.77 → 0.5.80

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 (95) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +161 -29
  4. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  5. package/crates/team-agent/src/cli/emit.rs +24 -8
  6. package/crates/team-agent/src/cli/mod.rs +543 -98
  7. package/crates/team-agent/src/cli/models.rs +393 -32
  8. package/crates/team-agent/src/cli/named_address.rs +19 -5
  9. package/crates/team-agent/src/cli/profile.rs +35 -4
  10. package/crates/team-agent/src/cli/spec.rs +7 -2
  11. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  12. package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
  13. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  14. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  15. package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  17. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  18. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  19. package/crates/team-agent/src/cli/types.rs +1 -0
  20. package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
  21. package/crates/team-agent/src/leader/lease.rs +492 -33
  22. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  23. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  24. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  25. package/crates/team-agent/src/leader/start.rs +197 -88
  26. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  27. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  28. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  29. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  30. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  31. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +86 -23
  32. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  33. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  34. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  35. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  36. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  37. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  38. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
  42. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  43. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  44. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  45. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  46. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  47. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
  48. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  49. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  50. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  51. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  52. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  53. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  54. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
  55. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  56. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  57. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  58. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  59. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  60. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
  61. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  62. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  63. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  64. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
  65. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
  66. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  67. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  68. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  69. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  70. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  71. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  72. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  73. package/crates/team-agent/src/messaging/delivery.rs +221 -80
  74. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  75. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
  76. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  77. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  78. package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
  79. package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
  80. package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
  81. package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
  82. package/crates/team-agent/src/state/mod.rs +1 -0
  83. package/crates/team-agent/src/state/persist.rs +102 -4
  84. package/crates/team-agent/src/state/projection.rs +7 -1
  85. package/crates/team-agent/src/state/repository.rs +29 -0
  86. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
  87. package/crates/team-agent/src/tmux_backend/tests.rs +149 -4
  88. package/crates/team-agent/src/tmux_backend.rs +239 -127
  89. package/crates/team-agent/src/transport.rs +78 -0
  90. package/crates/team-agent/src/transport_factory.rs +53 -0
  91. package/package.json +4 -5
  92. package/skills/team-agent/SKILL.md +45 -17
  93. package/skills/team-agent/command-coverage.json +4 -29
  94. package/npm/check-skill-links.mjs +0 -88
  95. package/skills/team-agent/references/team-agent-operator.md +0 -672
@@ -1303,11 +1303,12 @@ fn restart_with_selected_team_and_transport(
1303
1303
  )?;
1304
1304
  // 0.3.30 Bug 1: auto-attach on partial restart too — workers that did
1305
1305
  // come up still need a leader_receiver pane to deliver report_result.
1306
- let attach_window_failures = try_autobind_leader_after_restart(
1307
- &selected.run_workspace,
1308
- Some(selected.team_key.as_str()),
1309
- &state,
1310
- );
1306
+ let (attach_window_failures, leader_bind_ok, leader_bind_reason) =
1307
+ restart_leader_public_bind(
1308
+ &selected.run_workspace,
1309
+ Some(selected.team_key.as_str()),
1310
+ &state,
1311
+ );
1311
1312
  return Ok(RestartReport::Partial {
1312
1313
  session_name,
1313
1314
  agents: successful_agents,
@@ -1317,6 +1318,8 @@ fn restart_with_selected_team_and_transport(
1317
1318
  next_actions,
1318
1319
  attach_commands,
1319
1320
  attach_window_failures,
1321
+ leader_bind_ok,
1322
+ leader_bind_reason,
1320
1323
  });
1321
1324
  }
1322
1325
  phase_timer.emit(&selected.run_workspace, "restart.phase", "completed");
@@ -1332,8 +1335,12 @@ fn restart_with_selected_team_and_transport(
1332
1335
  // invoked from a tmux pane should bind that pane as leader_receiver,
1333
1336
  // restoring the worker→leader delivery path. Failure is non-fatal — the
1334
1337
  // user can still run `team-agent attach-leader` manually.
1335
- let attach_window_failures =
1336
- try_autobind_leader_after_restart(&selected.run_workspace, Some(&selected.team_key), &state);
1338
+ let (attach_window_failures, leader_bind_ok, leader_bind_reason) =
1339
+ restart_leader_public_bind(
1340
+ &selected.run_workspace,
1341
+ Some(selected.team_key.as_str()),
1342
+ &state,
1343
+ );
1337
1344
  if let Ok(probe) = crate::lifecycle::display::probe_display_capabilities(&selected.run_workspace)
1338
1345
  {
1339
1346
  let _ = crate::lifecycle::display::rebuild_adaptive_display_after_rebind(
@@ -1355,6 +1362,8 @@ fn restart_with_selected_team_and_transport(
1355
1362
  next_actions,
1356
1363
  attach_commands,
1357
1364
  attach_window_failures,
1365
+ leader_bind_ok,
1366
+ leader_bind_reason,
1358
1367
  })
1359
1368
  }
1360
1369
 
@@ -1669,8 +1678,14 @@ fn restart_readiness(
1669
1678
  let session_created = session_live_or_default(transport, session_name, false);
1670
1679
  let worker_pane_addressable = restart_worker_panes_addressable(state, decisions, transport);
1671
1680
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
1672
- let coordinator_alive =
1673
- crate::coordinator::coordinator_health(&coordinator_workspace).ok && session_created;
1681
+ let health = crate::coordinator::coordinator_health(&coordinator_workspace);
1682
+ let coordinator_accepted = health.ok
1683
+ || (health.service_available
1684
+ && matches!(
1685
+ health.binary_identity_relation,
1686
+ crate::coordinator::CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller
1687
+ ));
1688
+ let coordinator_alive = coordinator_accepted && session_created;
1674
1689
  RestartReadiness {
1675
1690
  session_created,
1676
1691
  worker_pane_addressable,
@@ -1772,13 +1787,14 @@ fn restart_readiness_timeout_message(
1772
1787
  - tmux session created: {session}\n\
1773
1788
  - worker pane addressable: {pane}\n\
1774
1789
  - coordinator alive: {coordinator}\n\
1775
- Action: check coordinator log {log}, then `team-agent restart <agent> --allow-fresh` or `team-agent diagnose`\n\
1790
+ Action: check coordinator log {log}, then inspect this workspace with `team-agent diagnose --workspace '{workspace}'`\n\
1776
1791
  Log: coordinator_log={log} state={state} pid_file={pid}",
1777
1792
  missing = restart_readiness_missing_summary(readiness),
1778
1793
  session = yes_no(readiness.session_created),
1779
1794
  pane = yes_no(readiness.worker_pane_addressable),
1780
1795
  coordinator = yes_no(readiness.coordinator_alive),
1781
1796
  log = crate::coordinator::coordinator_log_path(&coordinator_workspace).display(),
1797
+ workspace = workspace.display(),
1782
1798
  state = crate::state::persist::runtime_state_path(workspace).display(),
1783
1799
  pid = crate::coordinator::coordinator_pid_path(&coordinator_workspace).display(),
1784
1800
  )
@@ -1824,63 +1840,276 @@ fn verify_spawned_agent_live(
1824
1840
  /// E51 guard). In that case the user must still run `attach-leader` manually,
1825
1841
  /// matching pre-fix behaviour. We log to stderr so the operator sees why
1826
1842
  /// auto-attach didn't take.
1843
+ fn load_restart_bind_state(
1844
+ workspace: &std::path::Path,
1845
+ team_key: &str,
1846
+ ) -> Option<serde_json::Value> {
1847
+ crate::state::projection::select_runtime_state(workspace, Some(team_key)).ok()
1848
+ }
1849
+
1850
+ fn selected_team_receiver<'a>(
1851
+ state: &'a serde_json::Value,
1852
+ team_key: &str,
1853
+ ) -> Option<&'a serde_json::Value> {
1854
+ crate::lifecycle::launch::selected_team_leader_receiver(state, team_key)
1855
+ }
1856
+
1857
+ fn transport_for_receiver(
1858
+ _workspace: &std::path::Path,
1859
+ receiver: &serde_json::Value,
1860
+ ) -> Option<Box<dyn crate::transport::Transport>> {
1861
+ let endpoint = receiver
1862
+ .get("tmux_socket")
1863
+ .and_then(serde_json::Value::as_str)
1864
+ .filter(|endpoint| !endpoint.is_empty() && *endpoint != "default")?;
1865
+ Some(crate::transport_factory::leader_endpoint_transport(
1866
+ endpoint,
1867
+ ))
1868
+ }
1869
+
1870
+ pub fn restart_bind_status(
1871
+ workspace: &std::path::Path,
1872
+ team_key: &str,
1873
+ state: &serde_json::Value,
1874
+ ) -> (bool, Option<String>) {
1875
+ let receiver = selected_team_receiver(state, team_key);
1876
+ let transport = receiver.and_then(|receiver| transport_for_receiver(workspace, receiver));
1877
+ match crate::lifecycle::launch::classify_leader_binding_at(
1878
+ workspace,
1879
+ team_key,
1880
+ state,
1881
+ transport.as_deref(),
1882
+ ) {
1883
+ crate::lifecycle::launch::LeaderBindingClass::Attached => (true, None),
1884
+ other => (false, Some(other.issue_id().to_string())),
1885
+ }
1886
+ }
1887
+
1888
+ fn owner_conflicts_with_caller(
1889
+ workspace: &std::path::Path,
1890
+ state: &serde_json::Value,
1891
+ team_key: &str,
1892
+ ) -> bool {
1893
+ let Some(receiver) = selected_team_receiver(state, team_key) else {
1894
+ return false;
1895
+ };
1896
+ let Some(owner_pane) = state
1897
+ .get("teams")
1898
+ .and_then(|teams| teams.get(team_key))
1899
+ .and_then(|team| team.pointer("/team_owner/pane_id"))
1900
+ .and_then(serde_json::Value::as_str)
1901
+ .or_else(|| receiver.get("pane_id").and_then(serde_json::Value::as_str))
1902
+ else {
1903
+ return false;
1904
+ };
1905
+ let Some(caller) = std::env::var("TMUX_PANE")
1906
+ .ok()
1907
+ .filter(|pane| !pane.is_empty())
1908
+ else {
1909
+ return false;
1910
+ };
1911
+ let owner_socket = receiver
1912
+ .get("tmux_socket")
1913
+ .and_then(serde_json::Value::as_str);
1914
+ let caller_socket = crate::tmux_backend::socket_name_from_tmux_env();
1915
+ let same_socket = match (owner_socket, caller_socket.as_deref()) {
1916
+ (Some(owner), Some(caller)) => owner == caller,
1917
+ _ => false,
1918
+ };
1919
+ if owner_pane == caller && same_socket {
1920
+ return false;
1921
+ }
1922
+ let Some(transport) = transport_for_receiver(workspace, receiver) else {
1923
+ return false;
1924
+ };
1925
+ matches!(
1926
+ crate::messaging::leader_channel::resolve_live_leader_channel(
1927
+ workspace,
1928
+ receiver,
1929
+ transport.as_ref(),
1930
+ ),
1931
+ crate::messaging::leader_channel::LeaderChannelResolution::Live(_)
1932
+ )
1933
+ }
1934
+
1935
+ fn binding_maintain_failed(value: &serde_json::Value) -> bool {
1936
+ value
1937
+ .get("registry_maintain_failed")
1938
+ .and_then(serde_json::Value::as_bool)
1939
+ == Some(true)
1940
+ || value
1941
+ .get("debt_maintain_failed")
1942
+ .and_then(serde_json::Value::as_bool)
1943
+ == Some(true)
1944
+ }
1945
+
1946
+ fn apply_binding_maintain_failures(
1947
+ attach_window_failures: &Option<serde_json::Value>,
1948
+ ok: bool,
1949
+ reason: Option<String>,
1950
+ ) -> (bool, Option<String>) {
1951
+ if attach_window_failures
1952
+ .as_ref()
1953
+ .is_some_and(binding_maintain_failed)
1954
+ {
1955
+ (
1956
+ false,
1957
+ reason.or_else(|| Some("leader_registry_maintain_failed".to_string())),
1958
+ )
1959
+ } else {
1960
+ (ok, reason)
1961
+ }
1962
+ }
1963
+
1964
+ /// Unique production constructor for Restarted/Partial `leader_bind_*`
1965
+ /// and `attach_window_failures`. `restart_with_selected_team` is the only
1966
+ /// product caller; IsolatedHome reuses this instead of a test success path.
1967
+ pub fn restart_leader_public_bind(
1968
+ workspace: &std::path::Path,
1969
+ team: Option<&str>,
1970
+ in_memory_state: &serde_json::Value,
1971
+ ) -> (Option<serde_json::Value>, bool, Option<String>) {
1972
+ let team_key = team.unwrap_or("");
1973
+ let attach_window_failures =
1974
+ try_autobind_leader_after_restart(workspace, team, in_memory_state);
1975
+ let persisted = load_restart_bind_state(workspace, team_key);
1976
+ let (class_ok, class_reason) = restart_bind_status(
1977
+ workspace,
1978
+ team_key,
1979
+ persisted.as_ref().unwrap_or(in_memory_state),
1980
+ );
1981
+ let (ok, reason) =
1982
+ apply_binding_maintain_failures(&attach_window_failures, class_ok, class_reason);
1983
+ (attach_window_failures, ok, reason)
1984
+ }
1985
+
1986
+ fn maintain_live_binding_index(
1987
+ workspace: &std::path::Path,
1988
+ team: Option<&str>,
1989
+ state: &serde_json::Value,
1990
+ ) -> Option<serde_json::Value> {
1991
+ let team_key = team.unwrap_or("");
1992
+ let register = crate::leader::registry::register_binding_from_state_best_effort(
1993
+ workspace,
1994
+ team,
1995
+ "restart-live-index",
1996
+ );
1997
+ let mut report = serde_json::Map::new();
1998
+ if !register
1999
+ .as_ref()
2000
+ .is_some_and(|outcome| outcome.status == "registered")
2001
+ {
2002
+ report.insert("registry_maintain_failed".to_string(), serde_json::json!(true));
2003
+ }
2004
+ let Some(receiver) = selected_team_receiver(state, team_key) else {
2005
+ return Some(serde_json::Value::Object(report));
2006
+ };
2007
+ let Some(pane) = receiver
2008
+ .get("pane_id")
2009
+ .and_then(serde_json::Value::as_str)
2010
+ .filter(|pane| !pane.is_empty())
2011
+ else {
2012
+ report.insert("debt_maintain_failed".to_string(), serde_json::json!(true));
2013
+ return Some(serde_json::Value::Object(report));
2014
+ };
2015
+ let store = match crate::message_store::MessageStore::open(workspace) {
2016
+ Ok(store) => store,
2017
+ Err(_) => {
2018
+ report.insert("debt_maintain_failed".to_string(), serde_json::json!(true));
2019
+ return Some(serde_json::Value::Object(report));
2020
+ }
2021
+ };
2022
+ let event_log = crate::event_log::EventLog::new(workspace);
2023
+ let team_id = crate::model::ids::TeamKey::new(team_key.to_string());
2024
+ let pane_id = crate::transport::PaneId::new(pane);
2025
+ match crate::messaging::watchers::requeue_delivery_exhausted_watchers(
2026
+ workspace,
2027
+ &store,
2028
+ &event_log,
2029
+ &team_id,
2030
+ &pane_id,
2031
+ ) {
2032
+ Ok(notices) => {
2033
+ report.insert("notices".to_string(), serde_json::json!(notices.len()));
2034
+ }
2035
+ Err(_) => {
2036
+ report.insert("debt_maintain_failed".to_string(), serde_json::json!(true));
2037
+ }
2038
+ }
2039
+ Some(serde_json::Value::Object(report))
2040
+ }
2041
+
1827
2042
  fn try_autobind_leader_after_restart(
1828
2043
  workspace: &std::path::Path,
1829
2044
  team: Option<&str>,
1830
2045
  state: &serde_json::Value,
1831
2046
  ) -> Option<serde_json::Value> {
2047
+ let team_key = team.unwrap_or("");
2048
+ let persisted = load_restart_bind_state(workspace, team_key);
2049
+ let state = persisted.as_ref().unwrap_or(state);
2050
+ if restart_bind_status(workspace, team_key, state).0 {
2051
+ return maintain_live_binding_index(workspace, team, state);
2052
+ }
1832
2053
  if std::env::var_os("TMUX_PANE").is_none() {
1833
2054
  return None;
1834
2055
  }
1835
- // Provider: prefer the existing team_owner.provider (if rebind),
1836
- // else leader_receiver.provider (stale, but still informative),
1837
- // else default to ClaudeCode (matches the most common deployment).
1838
- let provider = state
1839
- .pointer("/team_owner/provider")
2056
+ if owner_conflicts_with_caller(workspace, state, team_key) {
2057
+ eprintln!(
2058
+ "team_agent::restart auto_attach_leader skipped reason=owner_conflict team={team:?}"
2059
+ );
2060
+ return None;
2061
+ }
2062
+ let Some(provider) = state
2063
+ .get("teams")
2064
+ .and_then(|teams| teams.get(team_key))
2065
+ .and_then(|team| team.pointer("/team_owner/provider"))
1840
2066
  .and_then(serde_json::Value::as_str)
1841
2067
  .or_else(|| {
1842
- state
1843
- .pointer("/leader_receiver/provider")
2068
+ selected_team_receiver(state, team_key)
2069
+ .and_then(|receiver| receiver.get("provider"))
1844
2070
  .and_then(serde_json::Value::as_str)
1845
2071
  })
1846
- .and_then(|s| {
1847
- // Legacy auto-attach: collapse any claude variant to ClaudeCode
1848
- // (this site historically treated `Claude` and `ClaudeCode` as
1849
- // the same attach target). Wire-format `parse_provider` keeps
1850
- // them distinct everywhere else.
1851
- crate::provider::wire::parse_provider(s).map(|p| match p {
1852
- crate::model::enums::Provider::Claude => crate::model::enums::Provider::ClaudeCode,
1853
- other => other,
1854
- })
1855
- })
1856
- .unwrap_or(crate::model::enums::Provider::ClaudeCode);
1857
- let team_str = team;
1858
- match crate::leader::attach_leader(workspace, team_str, None, provider) {
2072
+ .and_then(crate::provider::wire::parse_provider)
2073
+ else {
2074
+ eprintln!(
2075
+ "team_agent::restart auto_attach_leader skipped reason=provider_unresolved team={team:?}"
2076
+ );
2077
+ return None;
2078
+ };
2079
+ match crate::leader::attach_leader(workspace, team, None, provider) {
1859
2080
  Ok(result) if result.ok => {
1860
- let debt = result.attach_window_failures.clone();
1861
- let _ = crate::leader::registry::register_binding_from_state_best_effort(
2081
+ let mut debt = result
2082
+ .attach_window_failures
2083
+ .clone()
2084
+ .unwrap_or_else(|| serde_json::json!({}));
2085
+ let register = crate::leader::registry::register_binding_from_state_best_effort(
1862
2086
  workspace,
1863
- team_str,
2087
+ team,
1864
2088
  "restart-auto-attach",
1865
2089
  );
1866
- eprintln!(
1867
- "team_agent::restart auto_attach_leader ok pane={:?} team={:?}",
1868
- result.bound_pane_id.as_ref().map(|p| p.as_str()),
1869
- team_str,
1870
- );
1871
- debt
2090
+ if !register
2091
+ .as_ref()
2092
+ .is_some_and(|outcome| outcome.status == "registered")
2093
+ {
2094
+ if let Some(object) = debt.as_object_mut() {
2095
+ object.insert(
2096
+ "registry_maintain_failed".to_string(),
2097
+ serde_json::json!(true),
2098
+ );
2099
+ }
2100
+ }
2101
+ Some(debt)
1872
2102
  }
1873
2103
  Ok(result) => {
1874
2104
  eprintln!(
1875
- "team_agent::restart auto_attach_leader skipped reason={:?} team={:?}",
1876
- result.reason, team_str,
2105
+ "team_agent::restart auto_attach_leader skipped reason={:?} team={team:?}",
2106
+ result.reason,
1877
2107
  );
1878
2108
  None
1879
2109
  }
1880
2110
  Err(error) => {
1881
2111
  eprintln!(
1882
- "team_agent::restart auto_attach_leader failed error={error} team={team_str:?} \
1883
- (run `team-agent attach-leader` from your tmux pane to bind manually)",
2112
+ "team_agent::restart auto_attach_leader failed error={error} team={team:?}"
1884
2113
  );
1885
2114
  None
1886
2115
  }
@@ -2529,10 +2758,10 @@ fn write_restart_agent_failed_event(
2529
2758
  "session_id": decision.session_id.as_ref().map(|session| session.as_str()),
2530
2759
  "phase": phase,
2531
2760
  "error": error,
2532
- "action": restart_failure_action(
2533
- &decision.agent_id,
2534
- phase,
2535
- decision.session_id.as_ref(),
2761
+ "action": format!(
2762
+ "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
2763
+ decision.agent_id,
2764
+ decision.agent_id
2536
2765
  ),
2537
2766
  "log": format!(
2538
2767
  ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
@@ -3015,30 +3244,14 @@ fn restart_failure_next_actions(failed_agents: &[RestartFailedAgent]) -> Vec<Str
3015
3244
  failed_agents
3016
3245
  .iter()
3017
3246
  .map(|failure| {
3018
- restart_failure_action(
3019
- &failure.agent_id,
3020
- &failure.phase,
3021
- failure.session_id.as_ref(),
3247
+ format!(
3248
+ "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
3249
+ failure.agent_id, failure.agent_id
3022
3250
  )
3023
3251
  })
3024
3252
  .collect()
3025
3253
  }
3026
3254
 
3027
- pub(crate) fn restart_failure_action(
3028
- agent_id: &AgentId,
3029
- phase: &str,
3030
- session_id: Option<&SessionId>,
3031
- ) -> String {
3032
- if phase == "spawn" && session_id.is_none() {
3033
- return format!(
3034
- "worker {agent_id} failed before a pane was created; inspect the reported provider error, then rerun `team-agent restart --allow-fresh`"
3035
- );
3036
- }
3037
- format!(
3038
- "inspect worker {agent_id} output, then reset that worker with `team-agent reset-agent {agent_id}` or rerun `team-agent restart --allow-fresh`"
3039
- )
3040
- }
3041
-
3042
3255
  fn start_mode_wire(mode: StartMode) -> &'static str {
3043
3256
  match mode {
3044
3257
  StartMode::Resumed => "resumed",
@@ -4007,4 +4220,721 @@ tasks:
4007
4220
  );
4008
4221
  }
4009
4222
  }
4223
+
4224
+ #[test]
4225
+ #[serial_test::serial(env)]
4226
+ fn restart_bind_status_live_index_missing_is_not_ok() {
4227
+ let workspace = std::env::temp_dir().join(format!(
4228
+ "ta-n1-index-{}-{}",
4229
+ std::process::id(),
4230
+ std::time::SystemTime::now()
4231
+ .duration_since(std::time::UNIX_EPOCH)
4232
+ .unwrap()
4233
+ .as_nanos()
4234
+ ));
4235
+ std::fs::create_dir_all(&workspace).unwrap();
4236
+ let home = workspace.join("home");
4237
+ std::fs::create_dir_all(&home).unwrap();
4238
+ let previous = std::env::var_os("HOME");
4239
+ std::env::set_var("HOME", &home);
4240
+ let state = serde_json::json!({
4241
+ "teams": {
4242
+ "alpha": {
4243
+ "team_owner": {"pane_id": "%1", "owner_epoch": 1, "provider": "codex"},
4244
+ "leader_receiver": {
4245
+ "status": "attached",
4246
+ "pane_id": "%1",
4247
+ "owner_epoch": 1,
4248
+ "tmux_socket": "/tmp/leader-not-workspace.sock"
4249
+ }
4250
+ }
4251
+ }
4252
+ });
4253
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
4254
+ let (ok, reason) = restart_bind_status(&workspace, "alpha", &state);
4255
+ match previous {
4256
+ Some(home) => std::env::set_var("HOME", home),
4257
+ None => std::env::remove_var("HOME"),
4258
+ }
4259
+ assert!(!ok, "Live/index-missing must not report bind success");
4260
+ assert_eq!(reason.as_deref(), Some("leader_registry_index_missing"));
4261
+ let _ = std::fs::remove_dir_all(&workspace);
4262
+ }
4263
+
4264
+ #[test]
4265
+ #[serial_test::serial(env)]
4266
+ fn restart_bind_status_uses_persisted_receiver_endpoint() {
4267
+ let workspace = std::env::temp_dir().join(format!(
4268
+ "ta-n1-persisted-{}-{}",
4269
+ std::process::id(),
4270
+ std::time::SystemTime::now()
4271
+ .duration_since(std::time::UNIX_EPOCH)
4272
+ .unwrap()
4273
+ .as_nanos()
4274
+ ));
4275
+ std::fs::create_dir_all(&workspace).unwrap();
4276
+ let home = workspace.join("home");
4277
+ std::fs::create_dir_all(&home).unwrap();
4278
+ let previous = std::env::var_os("HOME");
4279
+ std::env::set_var("HOME", &home);
4280
+ let state = serde_json::json!({
4281
+ "teams": {
4282
+ "alpha": {
4283
+ "leader_receiver": {
4284
+ "status": "pending",
4285
+ "pane_id": "%old"
4286
+ }
4287
+ }
4288
+ }
4289
+ });
4290
+ let persisted = serde_json::json!({
4291
+ "teams": {
4292
+ "alpha": {
4293
+ "team_owner": {"pane_id": "%2", "owner_epoch": 2, "provider": "codex"},
4294
+ "leader_receiver": {
4295
+ "status": "attached",
4296
+ "pane_id": "%2",
4297
+ "owner_epoch": 2,
4298
+ "tmux_socket": "/tmp/caller-socket.sock"
4299
+ }
4300
+ }
4301
+ }
4302
+ });
4303
+ let (ok_stale, reason_stale) = restart_bind_status(&workspace, "alpha", &state);
4304
+ let (ok_new, reason_new) = restart_bind_status(&workspace, "alpha", &persisted);
4305
+ match previous {
4306
+ Some(home) => std::env::set_var("HOME", home),
4307
+ None => std::env::remove_var("HOME"),
4308
+ }
4309
+ let _ = std::fs::remove_dir_all(&workspace);
4310
+ assert!(!ok_stale, "pending without owner/registry is not bind success");
4311
+ assert_eq!(
4312
+ reason_stale.as_deref(),
4313
+ Some("leader_receiver_unbound"),
4314
+ "hermetic empty registry + pending receiver is Unbound, not Unknown"
4315
+ );
4316
+ assert!(!ok_new);
4317
+ assert_eq!(
4318
+ reason_new.as_deref(),
4319
+ Some("leader_registry_index_missing"),
4320
+ "persisted attached+owner without registry is index-missing, not the stale pending class"
4321
+ );
4322
+ }
4323
+
4324
+ #[test]
4325
+ #[serial_test::serial(env)]
4326
+ fn restart_bind_status_attached_live_is_ok() {
4327
+ let root = std::env::temp_dir().join(format!(
4328
+ "ta-n1-ok-{}-{}",
4329
+ std::process::id(),
4330
+ std::time::SystemTime::now()
4331
+ .duration_since(std::time::UNIX_EPOCH)
4332
+ .unwrap()
4333
+ .as_nanos()
4334
+ ));
4335
+ let home = root.join("home");
4336
+ let workspace = root.join("ws");
4337
+ std::fs::create_dir_all(&home).unwrap();
4338
+ std::fs::create_dir_all(&workspace).unwrap();
4339
+ let previous = std::env::var_os("HOME");
4340
+ std::env::set_var("HOME", &home);
4341
+ let endpoint = "/tmp/ta-n1-leader-not-workspace.sock";
4342
+ let pending = serde_json::json!({
4343
+ "teams": {
4344
+ "alpha": {
4345
+ "team_owner": {"pane_id": "%1", "owner_epoch": 1, "provider": "codex"},
4346
+ "leader_receiver": {
4347
+ "status": "pending",
4348
+ "discovery": "quick_start_seed",
4349
+ "pane_id": "%1",
4350
+ "owner_epoch": 1,
4351
+ "tmux_socket": endpoint
4352
+ }
4353
+ }
4354
+ }
4355
+ });
4356
+ let attached = serde_json::json!({
4357
+ "teams": {
4358
+ "alpha": {
4359
+ "team_owner": {"pane_id": "%1", "owner_epoch": 1, "provider": "codex"},
4360
+ "leader_receiver": {
4361
+ "status": "attached",
4362
+ "pane_id": "%1",
4363
+ "owner_epoch": 1,
4364
+ "tmux_socket": endpoint
4365
+ }
4366
+ }
4367
+ }
4368
+ });
4369
+ crate::state::persist::save_runtime_state(&workspace, &attached).unwrap();
4370
+ assert_eq!(
4371
+ crate::leader::registry::register_binding_from_state_best_effort(
4372
+ &workspace,
4373
+ Some("alpha"),
4374
+ "restart-ok",
4375
+ )
4376
+ .as_ref()
4377
+ .map(|outcome| outcome.status),
4378
+ Some("registered")
4379
+ );
4380
+ let transport = crate::transport::test_support::OfflineTransport::default()
4381
+ .with_tmux_endpoint(endpoint)
4382
+ .with_targets(vec![crate::transport::PaneInfo {
4383
+ pane_id: crate::transport::PaneId::new("%1"),
4384
+ session: crate::transport::SessionName::new("s"),
4385
+ window_index: None,
4386
+ window_name: None,
4387
+ pane_index: None,
4388
+ tty: None,
4389
+ current_command: Some("codex".to_string()),
4390
+ current_path: Some(workspace.clone()),
4391
+ active: true,
4392
+ pane_pid: None,
4393
+ leader_env: Default::default(),
4394
+ }]);
4395
+ let (ok, reason) = crate::transport_factory::with_leader_endpoint_transport(
4396
+ endpoint,
4397
+ transport.clone(),
4398
+ || restart_bind_status(&workspace, "alpha", &attached),
4399
+ );
4400
+ let (ok_pending, _) = crate::transport_factory::with_leader_endpoint_transport(
4401
+ endpoint,
4402
+ transport,
4403
+ || restart_bind_status(&workspace, "alpha", &pending),
4404
+ );
4405
+ match previous {
4406
+ Some(home) => std::env::set_var("HOME", home),
4407
+ None => std::env::remove_var("HOME"),
4408
+ }
4409
+ let _ = std::fs::remove_dir_all(&root);
4410
+ assert_eq!((ok, reason.as_deref()), (true, None));
4411
+ assert!(
4412
+ !ok_pending,
4413
+ "in-memory pending snapshot must not flip restart success"
4414
+ );
4415
+ }
4416
+
4417
+ fn restart_index_owner_state(
4418
+ pane: &str,
4419
+ endpoint: &str,
4420
+ uuid: &str,
4421
+ provider: &str,
4422
+ ) -> serde_json::Value {
4423
+ serde_json::json!({
4424
+ "teams": {
4425
+ "alpha": {
4426
+ "team_owner": {
4427
+ "pane_id": pane,
4428
+ "owner_epoch": 1,
4429
+ "provider": provider,
4430
+ "leader_session_uuid": uuid
4431
+ },
4432
+ "leader_receiver": {
4433
+ "status": "attached",
4434
+ "pane_id": pane,
4435
+ "owner_epoch": 1,
4436
+ "provider": provider,
4437
+ "tmux_socket": endpoint,
4438
+ "leader_session_uuid": uuid,
4439
+ "discovery": "env_pane"
4440
+ }
4441
+ }
4442
+ }
4443
+ })
4444
+ }
4445
+
4446
+ fn restart_index_wrapper_pane(
4447
+ workspace: &Path,
4448
+ pane: &str,
4449
+ uuid: Option<&str>,
4450
+ active: bool,
4451
+ command: &str,
4452
+ ) -> PaneInfo {
4453
+ let mut leader_env = BTreeMap::new();
4454
+ if let Some(uuid) = uuid {
4455
+ leader_env.insert(
4456
+ "TEAM_AGENT_LEADER_SESSION_UUID".to_string(),
4457
+ uuid.to_string(),
4458
+ );
4459
+ }
4460
+ PaneInfo {
4461
+ pane_id: crate::transport::PaneId::new(pane),
4462
+ session: crate::transport::SessionName::new("leader"),
4463
+ window_index: Some(0),
4464
+ window_name: Some(crate::transport::WindowName::new("pi")),
4465
+ pane_index: Some(0),
4466
+ tty: None,
4467
+ current_command: Some(command.to_string()),
4468
+ current_path: Some(workspace.to_path_buf()),
4469
+ active,
4470
+ pane_pid: Some(42),
4471
+ leader_env,
4472
+ }
4473
+ }
4474
+
4475
+ fn restart_index_bind_after_unregister(
4476
+ workspace: &Path,
4477
+ endpoint: &str,
4478
+ pane: &str,
4479
+ targets: Vec<PaneInfo>,
4480
+ ) -> (
4481
+ (bool, Option<String>),
4482
+ crate::lifecycle::launch::LeaderBindingClass,
4483
+ ) {
4484
+ std::env::set_var("TMUX", format!("{endpoint},1,0"));
4485
+ std::env::set_var("TMUX_PANE", pane);
4486
+ let transport = crate::transport::test_support::OfflineTransport::default()
4487
+ .with_tmux_endpoint(endpoint)
4488
+ .with_targets(targets);
4489
+ crate::transport_factory::with_leader_endpoint_transport(endpoint, transport, || {
4490
+ let (_failures, ok, reason) =
4491
+ restart_leader_public_bind(workspace, Some("alpha"), &serde_json::json!({}));
4492
+ let class = crate::lifecycle::launch::classify_leader_binding(workspace, "alpha");
4493
+ ((ok, reason), class)
4494
+ })
4495
+ }
4496
+
4497
+ #[test]
4498
+ #[serial_test::serial(env)]
4499
+ fn restart_rebinds_inactive_pi_bash_wrapper_after_unregister() {
4500
+ let root = std::env::temp_dir().join(format!(
4501
+ "ta-r14-idx-ok-{}-{}",
4502
+ std::process::id(),
4503
+ std::time::SystemTime::now()
4504
+ .duration_since(std::time::UNIX_EPOCH)
4505
+ .unwrap()
4506
+ .as_nanos()
4507
+ ));
4508
+ let home = root.join("home");
4509
+ let workspace = root.join("ws");
4510
+ std::fs::create_dir_all(&home).unwrap();
4511
+ std::fs::create_dir_all(&workspace).unwrap();
4512
+ let previous_home = std::env::var_os("HOME");
4513
+ let previous_tmux = std::env::var_os("TMUX");
4514
+ let previous_pane = std::env::var_os("TMUX_PANE");
4515
+ std::env::set_var("HOME", &home);
4516
+ let endpoint = "/tmp/ta-r14-idx-ok.sock";
4517
+ let uuid = "c1e3f7a7aeaed95491667b4d9c3ce6fb";
4518
+ let state = restart_index_owner_state("%0", endpoint, uuid, "codex");
4519
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
4520
+ assert_eq!(
4521
+ crate::leader::registry::register_binding_from_state_best_effort(
4522
+ &workspace,
4523
+ Some("alpha"),
4524
+ "quick-start",
4525
+ )
4526
+ .as_ref()
4527
+ .map(|outcome| outcome.status),
4528
+ Some("registered")
4529
+ );
4530
+ crate::leader::registry::unregister_entry(&workspace, "alpha");
4531
+ let ((ok, reason), class) = restart_index_bind_after_unregister(
4532
+ &workspace,
4533
+ endpoint,
4534
+ "%0",
4535
+ vec![restart_index_wrapper_pane(
4536
+ &workspace,
4537
+ "%0",
4538
+ Some(uuid),
4539
+ false,
4540
+ "bash",
4541
+ )],
4542
+ );
4543
+ match previous_home {
4544
+ Some(home) => std::env::set_var("HOME", home),
4545
+ None => std::env::remove_var("HOME"),
4546
+ }
4547
+ match previous_tmux {
4548
+ Some(value) => std::env::set_var("TMUX", value),
4549
+ None => std::env::remove_var("TMUX"),
4550
+ }
4551
+ match previous_pane {
4552
+ Some(value) => std::env::set_var("TMUX_PANE", value),
4553
+ None => std::env::remove_var("TMUX_PANE"),
4554
+ }
4555
+ let _ = std::fs::remove_dir_all(&root);
4556
+ assert_eq!((ok, reason.as_deref()), (true, None));
4557
+ assert_eq!(class, crate::lifecycle::launch::LeaderBindingClass::Attached);
4558
+ }
4559
+
4560
+ #[test]
4561
+ #[serial_test::serial(env)]
4562
+ fn restart_index_missing_refuses_dead_and_wrong_identity() {
4563
+ let root = std::env::temp_dir().join(format!(
4564
+ "ta-r14-idx-neg-{}-{}",
4565
+ std::process::id(),
4566
+ std::time::SystemTime::now()
4567
+ .duration_since(std::time::UNIX_EPOCH)
4568
+ .unwrap()
4569
+ .as_nanos()
4570
+ ));
4571
+ let home = root.join("home");
4572
+ let workspace = root.join("ws");
4573
+ std::fs::create_dir_all(&home).unwrap();
4574
+ std::fs::create_dir_all(&workspace).unwrap();
4575
+ let previous_home = std::env::var_os("HOME");
4576
+ let previous_tmux = std::env::var_os("TMUX");
4577
+ let previous_pane = std::env::var_os("TMUX_PANE");
4578
+ std::env::set_var("HOME", &home);
4579
+ let endpoint = "/tmp/ta-r14-idx-neg.sock";
4580
+ let uuid = "c1e3f7a7aeaed95491667b4d9c3ce6fb";
4581
+ let state = restart_index_owner_state("%0", endpoint, uuid, "codex");
4582
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
4583
+ crate::leader::registry::unregister_entry(&workspace, "alpha");
4584
+
4585
+ let (dead, _) = restart_index_bind_after_unregister(&workspace, endpoint, "%0", Vec::new());
4586
+ let (wrong_pane, _) = restart_index_bind_after_unregister(
4587
+ &workspace,
4588
+ endpoint,
4589
+ "%9",
4590
+ vec![restart_index_wrapper_pane(
4591
+ &workspace,
4592
+ "%9",
4593
+ Some(uuid),
4594
+ false,
4595
+ "bash",
4596
+ )],
4597
+ );
4598
+ let wrong_socket = {
4599
+ std::env::set_var("TMUX", "/tmp/other.sock,1,0");
4600
+ std::env::set_var("TMUX_PANE", "%0");
4601
+ let transport = crate::transport::test_support::OfflineTransport::default()
4602
+ .with_tmux_endpoint("/tmp/other.sock")
4603
+ .with_targets(vec![restart_index_wrapper_pane(
4604
+ &workspace,
4605
+ "%0",
4606
+ Some(uuid),
4607
+ false,
4608
+ "bash",
4609
+ )]);
4610
+ crate::transport_factory::with_leader_endpoint_transport(
4611
+ "/tmp/other.sock",
4612
+ transport,
4613
+ || {
4614
+ let (_failures, ok, reason) = restart_leader_public_bind(
4615
+ &workspace,
4616
+ Some("alpha"),
4617
+ &serde_json::json!({}),
4618
+ );
4619
+ (ok, reason)
4620
+ },
4621
+ )
4622
+ };
4623
+ let (wrong_uuid, _) = restart_index_bind_after_unregister(
4624
+ &workspace,
4625
+ endpoint,
4626
+ "%0",
4627
+ vec![restart_index_wrapper_pane(
4628
+ &workspace,
4629
+ "%0",
4630
+ Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
4631
+ false,
4632
+ "bash",
4633
+ )],
4634
+ );
4635
+ match previous_home {
4636
+ Some(home) => std::env::set_var("HOME", home),
4637
+ None => std::env::remove_var("HOME"),
4638
+ }
4639
+ match previous_tmux {
4640
+ Some(value) => std::env::set_var("TMUX", value),
4641
+ None => std::env::remove_var("TMUX"),
4642
+ }
4643
+ match previous_pane {
4644
+ Some(value) => std::env::set_var("TMUX_PANE", value),
4645
+ None => std::env::remove_var("TMUX_PANE"),
4646
+ }
4647
+ let _ = std::fs::remove_dir_all(&root);
4648
+ assert!(!dead.0);
4649
+ assert_eq!(dead.1.as_deref(), Some("leader_registry_index_missing"));
4650
+ assert!(!wrong_pane.0);
4651
+ assert_eq!(
4652
+ wrong_pane.1.as_deref(),
4653
+ Some("leader_registry_index_missing")
4654
+ );
4655
+ assert!(!wrong_socket.0);
4656
+ assert_eq!(
4657
+ wrong_socket.1.as_deref(),
4658
+ Some("leader_registry_index_missing")
4659
+ );
4660
+ assert!(!wrong_uuid.0);
4661
+ assert_eq!(
4662
+ wrong_uuid.1.as_deref(),
4663
+ Some("leader_registry_index_missing")
4664
+ );
4665
+ }
4666
+
4667
+ #[test]
4668
+ #[serial_test::serial(env)]
4669
+ fn maintain_live_binding_index_register_failure_is_public() {
4670
+ let root = std::env::temp_dir().join(format!(
4671
+ "ta-n1-debt-{}-{}",
4672
+ std::process::id(),
4673
+ std::time::SystemTime::now()
4674
+ .duration_since(std::time::UNIX_EPOCH)
4675
+ .unwrap()
4676
+ .as_nanos()
4677
+ ));
4678
+ let home = root.join("home");
4679
+ let workspace = root.join("ws");
4680
+ std::fs::create_dir_all(&home).unwrap();
4681
+ std::fs::create_dir_all(&workspace).unwrap();
4682
+ std::fs::write(home.join(".team-agent"), b"not-a-directory").unwrap();
4683
+ let previous = std::env::var_os("HOME");
4684
+ std::env::set_var("HOME", &home);
4685
+ let state = serde_json::json!({
4686
+ "teams": {
4687
+ "alpha": {
4688
+ "team_owner": {"pane_id": "%1", "owner_epoch": 1},
4689
+ "leader_receiver": {
4690
+ "status": "attached",
4691
+ "pane_id": "%1",
4692
+ "owner_epoch": 1,
4693
+ "tmux_socket": "/tmp/ta-n1-debt.sock"
4694
+ }
4695
+ }
4696
+ }
4697
+ });
4698
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
4699
+ let debt = maintain_live_binding_index(&workspace, Some("alpha"), &state);
4700
+ let (ok, reason) = apply_binding_maintain_failures(&debt, true, None);
4701
+ let (keep_ok, keep_reason) = apply_binding_maintain_failures(
4702
+ &debt,
4703
+ false,
4704
+ Some("leader_registry_index_missing".to_string()),
4705
+ );
4706
+ let coordinator = crate::lifecycle::CoordinatorStartSummary {
4707
+ ok: true,
4708
+ status: "started".to_string(),
4709
+ pid: None,
4710
+ binary_path: None,
4711
+ binary_version: None,
4712
+ rotation_reason: None,
4713
+ binary_identity_relation: "same".to_string(),
4714
+ };
4715
+ let restarted = crate::cli::lifecycle_port::restart_value(
4716
+ crate::lifecycle::RestartReport::Restarted {
4717
+ session_name: crate::transport::SessionName::new("s"),
4718
+ agents: Vec::new(),
4719
+ coordinator_started: true,
4720
+ coordinator: coordinator.clone(),
4721
+ next_actions: Vec::new(),
4722
+ attach_commands: Vec::new(),
4723
+ attach_window_failures: debt.clone(),
4724
+ leader_bind_ok: ok,
4725
+ leader_bind_reason: reason.clone(),
4726
+ },
4727
+ Some("alpha"),
4728
+ );
4729
+ let partial = crate::cli::lifecycle_port::restart_value(
4730
+ crate::lifecycle::RestartReport::Partial {
4731
+ session_name: crate::transport::SessionName::new("s"),
4732
+ agents: Vec::new(),
4733
+ failed_agents: Vec::new(),
4734
+ coordinator_started: true,
4735
+ coordinator,
4736
+ next_actions: Vec::new(),
4737
+ attach_commands: Vec::new(),
4738
+ attach_window_failures: debt.clone(),
4739
+ leader_bind_ok: ok,
4740
+ leader_bind_reason: reason.clone(),
4741
+ },
4742
+ Some("alpha"),
4743
+ );
4744
+ match previous {
4745
+ Some(home) => std::env::set_var("HOME", home),
4746
+ None => std::env::remove_var("HOME"),
4747
+ }
4748
+ let _ = std::fs::remove_dir_all(&root);
4749
+ assert_eq!(
4750
+ debt.as_ref()
4751
+ .and_then(|value| value.get("registry_maintain_failed"))
4752
+ .and_then(serde_json::Value::as_bool),
4753
+ Some(true)
4754
+ );
4755
+ assert!(!ok);
4756
+ assert_eq!(reason.as_deref(), Some("leader_registry_maintain_failed"));
4757
+ assert!(!keep_ok);
4758
+ assert_eq!(
4759
+ keep_reason.as_deref(),
4760
+ Some("leader_registry_index_missing"),
4761
+ "earlier bind reason must not be overwritten by maintain failure"
4762
+ );
4763
+ assert_eq!(restarted.get("ok").and_then(serde_json::Value::as_bool), Some(false));
4764
+ assert_eq!(
4765
+ restarted.get("reason").and_then(serde_json::Value::as_str),
4766
+ Some("leader_registry_maintain_failed")
4767
+ );
4768
+ assert_eq!(
4769
+ partial.get("leader_bind_ok").and_then(serde_json::Value::as_bool),
4770
+ Some(false)
4771
+ );
4772
+ assert_eq!(
4773
+ partial
4774
+ .get("leader_bind_reason")
4775
+ .and_then(serde_json::Value::as_str),
4776
+ Some("leader_registry_maintain_failed")
4777
+ );
4778
+ }
4779
+
4780
+ #[test]
4781
+ #[serial_test::serial(env)]
4782
+ fn restart_readiness_accepts_only_healthy_or_newer_daemon() {
4783
+ let identity = crate::coordinator::current_coordinator_binary_identity();
4784
+ let cases = vec![
4785
+ (
4786
+ "same",
4787
+ std::process::id(),
4788
+ crate::coordinator::PROTOCOL_VERSION,
4789
+ true,
4790
+ identity.binary_version.clone(),
4791
+ identity.binary_path.clone(),
4792
+ true,
4793
+ ),
4794
+ (
4795
+ "newer",
4796
+ std::process::id(),
4797
+ crate::coordinator::PROTOCOL_VERSION,
4798
+ true,
4799
+ "999.0.0".to_string(),
4800
+ identity.binary_path.clone(),
4801
+ true,
4802
+ ),
4803
+ (
4804
+ "caller-newer",
4805
+ std::process::id(),
4806
+ crate::coordinator::PROTOCOL_VERSION,
4807
+ true,
4808
+ "0.0.1".to_string(),
4809
+ identity.binary_path.clone(),
4810
+ false,
4811
+ ),
4812
+ (
4813
+ "same-version-path-mismatch",
4814
+ std::process::id(),
4815
+ crate::coordinator::PROTOCOL_VERSION,
4816
+ true,
4817
+ identity.binary_version.clone(),
4818
+ "/tmp/team-agent-other-binary".to_string(),
4819
+ false,
4820
+ ),
4821
+ (
4822
+ "unknown-identity",
4823
+ std::process::id(),
4824
+ crate::coordinator::PROTOCOL_VERSION,
4825
+ true,
4826
+ "not-a-version".to_string(),
4827
+ identity.binary_path.clone(),
4828
+ false,
4829
+ ),
4830
+ (
4831
+ "dead-process",
4832
+ u32::MAX,
4833
+ crate::coordinator::PROTOCOL_VERSION,
4834
+ true,
4835
+ "999.0.0".to_string(),
4836
+ identity.binary_path.clone(),
4837
+ false,
4838
+ ),
4839
+ (
4840
+ "protocol-mismatch",
4841
+ std::process::id(),
4842
+ crate::coordinator::PROTOCOL_VERSION + 1,
4843
+ true,
4844
+ "999.0.0".to_string(),
4845
+ identity.binary_path.clone(),
4846
+ false,
4847
+ ),
4848
+ (
4849
+ "schema-mismatch",
4850
+ std::process::id(),
4851
+ crate::coordinator::PROTOCOL_VERSION,
4852
+ false,
4853
+ "999.0.0".to_string(),
4854
+ identity.binary_path.clone(),
4855
+ false,
4856
+ ),
4857
+ ];
4858
+
4859
+ for (tag, pid, protocol, schema_ok, version, path, expected) in cases {
4860
+ let workspace = std::env::temp_dir().join(format!(
4861
+ "ta-restart-readiness-002-{tag}-{}",
4862
+ std::process::id()
4863
+ ));
4864
+ let _ = std::fs::remove_dir_all(&workspace);
4865
+ seed_restart_readiness_health(&workspace, pid, protocol, schema_ok, &version, &path);
4866
+ let transport = crate::transport::test_support::OfflineTransport::new()
4867
+ .with_session_present(true);
4868
+ let readiness = restart_readiness(
4869
+ &workspace,
4870
+ &serde_json::json!({}),
4871
+ &SessionName::new("team-current"),
4872
+ &[],
4873
+ &transport,
4874
+ );
4875
+ assert_eq!(
4876
+ readiness.coordinator_alive, expected,
4877
+ "{tag}: coordinator readiness mismatch; readiness={readiness:?}"
4878
+ );
4879
+ assert!(readiness.session_created, "{tag}: session gate changed");
4880
+ assert!(readiness.worker_pane_addressable, "{tag}: pane gate changed");
4881
+ let _ = std::fs::remove_dir_all(&workspace);
4882
+ }
4883
+ }
4884
+
4885
+ #[test]
4886
+ fn restart_readiness_timeout_message_preserves_workspace_diagnosis() {
4887
+ let workspace = Path::new("/tmp/team-agent-restart-002-workspace");
4888
+ let message = restart_readiness_timeout_message(
4889
+ workspace,
4890
+ RestartReadiness {
4891
+ session_created: true,
4892
+ worker_pane_addressable: true,
4893
+ coordinator_alive: false,
4894
+ },
4895
+ std::time::Duration::from_secs(30),
4896
+ );
4897
+ assert!(message.contains("team-agent diagnose --workspace"));
4898
+ assert!(message.contains(workspace.to_string_lossy().as_ref()));
4899
+ assert!(!message.contains("--allow-fresh"));
4900
+ assert!(!message.contains("restart <agent>"));
4901
+ }
4902
+
4903
+ fn seed_restart_readiness_health(
4904
+ workspace: &Path,
4905
+ pid: u32,
4906
+ protocol: u32,
4907
+ schema_ok: bool,
4908
+ binary_version: &str,
4909
+ binary_path: &str,
4910
+ ) {
4911
+ let runtime = crate::model::paths::runtime_dir(workspace);
4912
+ std::fs::create_dir_all(&runtime).expect("create readiness runtime");
4913
+ if schema_ok {
4914
+ crate::message_store::MessageStore::open(workspace)
4915
+ .expect("create readiness message store");
4916
+ } else {
4917
+ std::fs::create_dir(runtime.join("team.db")).expect("corrupt readiness schema");
4918
+ }
4919
+ let wp = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
4920
+ let metadata = serde_json::json!({
4921
+ "pid": pid,
4922
+ "protocol_version": protocol,
4923
+ "message_store_schema_version": crate::db::schema::SCHEMA_VERSION,
4924
+ "binary_path": binary_path,
4925
+ "binary_version": binary_version,
4926
+ "source": "boot",
4927
+ "updated_at": "2026-09-10T00:00:00Z",
4928
+ });
4929
+ std::fs::write(
4930
+ crate::coordinator::coordinator_meta_path(&wp),
4931
+ serde_json::to_vec_pretty(&metadata).expect("serialize readiness metadata"),
4932
+ )
4933
+ .expect("write readiness metadata");
4934
+ std::fs::write(
4935
+ crate::coordinator::coordinator_pid_path(&wp),
4936
+ pid.to_string(),
4937
+ )
4938
+ .expect("write readiness pid");
4939
+ }
4010
4940
  }