@team-agent/installer 0.5.75 → 0.5.78

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 (98) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -0
  4. package/README.zh.md +8 -0
  5. package/crates/team-agent/src/cli/adapters.rs +184 -125
  6. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  7. package/crates/team-agent/src/cli/emit.rs +59 -15
  8. package/crates/team-agent/src/cli/mod.rs +600 -134
  9. package/crates/team-agent/src/cli/models.rs +485 -44
  10. package/crates/team-agent/src/cli/named_address.rs +19 -5
  11. package/crates/team-agent/src/cli/profile.rs +35 -4
  12. package/crates/team-agent/src/cli/spec.rs +7 -2
  13. package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
  14. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  15. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  16. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  17. package/crates/team-agent/src/cli/status_port.rs +8 -0
  18. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  19. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  20. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  21. package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
  22. package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
  23. package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
  24. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  25. package/crates/team-agent/src/cli/types.rs +1 -0
  26. package/crates/team-agent/src/leader/lease.rs +538 -41
  27. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  28. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  29. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  30. package/crates/team-agent/src/leader/start.rs +151 -14
  31. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  32. package/crates/team-agent/src/leader/types.rs +4 -0
  33. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  34. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  35. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  36. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  37. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +138 -14
  38. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  39. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  40. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  41. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  42. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  43. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  44. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  45. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  46. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  47. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +1002 -65
  48. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  49. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  50. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
  51. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  52. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  53. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  54. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -3
  55. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  56. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  57. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  58. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  59. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  60. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  61. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  62. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  63. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  64. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  65. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  66. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +4 -3
  67. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  68. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  69. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  70. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +69 -28
  71. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  72. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  73. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  74. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
  75. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  76. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  77. package/crates/team-agent/src/lifecycle/types.rs +12 -0
  78. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  79. package/crates/team-agent/src/messaging/delivery.rs +67 -8
  80. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  81. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  82. package/crates/team-agent/src/messaging/watchers.rs +48 -4
  83. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  84. package/crates/team-agent/src/state/paths.rs +17 -2
  85. package/crates/team-agent/src/state/persist.rs +97 -1
  86. package/crates/team-agent/src/state/projection.rs +18 -2
  87. package/crates/team-agent/src/state/repository.rs +29 -0
  88. package/crates/team-agent/src/state/selector.rs +23 -3
  89. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +144 -0
  90. package/crates/team-agent/src/tmux_backend/tests.rs +121 -4
  91. package/crates/team-agent/src/tmux_backend.rs +208 -118
  92. package/crates/team-agent/src/transport.rs +72 -0
  93. package/crates/team-agent/src/transport_factory.rs +53 -0
  94. package/package.json +4 -5
  95. package/skills/team-agent/SKILL.md +45 -17
  96. package/skills/team-agent/command-coverage.json +4 -29
  97. package/npm/check-skill-links.mjs +0 -88
  98. 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
- 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,
@@ -1316,6 +1317,9 @@ fn restart_with_selected_team_and_transport(
1316
1317
  coordinator,
1317
1318
  next_actions,
1318
1319
  attach_commands,
1320
+ attach_window_failures,
1321
+ leader_bind_ok,
1322
+ leader_bind_reason,
1319
1323
  });
1320
1324
  }
1321
1325
  phase_timer.emit(&selected.run_workspace, "restart.phase", "completed");
@@ -1331,7 +1335,12 @@ fn restart_with_selected_team_and_transport(
1331
1335
  // invoked from a tmux pane should bind that pane as leader_receiver,
1332
1336
  // restoring the worker→leader delivery path. Failure is non-fatal — the
1333
1337
  // user can still run `team-agent attach-leader` manually.
1334
- 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
+ );
1335
1344
  if let Ok(probe) = crate::lifecycle::display::probe_display_capabilities(&selected.run_workspace)
1336
1345
  {
1337
1346
  let _ = crate::lifecycle::display::rebuild_adaptive_display_after_rebind(
@@ -1352,6 +1361,9 @@ fn restart_with_selected_team_and_transport(
1352
1361
  coordinator,
1353
1362
  next_actions,
1354
1363
  attach_commands,
1364
+ attach_window_failures,
1365
+ leader_bind_ok,
1366
+ leader_bind_reason,
1355
1367
  })
1356
1368
  }
1357
1369
 
@@ -1666,8 +1678,14 @@ fn restart_readiness(
1666
1678
  let session_created = session_live_or_default(transport, session_name, false);
1667
1679
  let worker_pane_addressable = restart_worker_panes_addressable(state, decisions, transport);
1668
1680
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
1669
- let coordinator_alive =
1670
- 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;
1671
1689
  RestartReadiness {
1672
1690
  session_created,
1673
1691
  worker_pane_addressable,
@@ -1769,13 +1787,14 @@ fn restart_readiness_timeout_message(
1769
1787
  - tmux session created: {session}\n\
1770
1788
  - worker pane addressable: {pane}\n\
1771
1789
  - coordinator alive: {coordinator}\n\
1772
- 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\
1773
1791
  Log: coordinator_log={log} state={state} pid_file={pid}",
1774
1792
  missing = restart_readiness_missing_summary(readiness),
1775
1793
  session = yes_no(readiness.session_created),
1776
1794
  pane = yes_no(readiness.worker_pane_addressable),
1777
1795
  coordinator = yes_no(readiness.coordinator_alive),
1778
1796
  log = crate::coordinator::coordinator_log_path(&coordinator_workspace).display(),
1797
+ workspace = workspace.display(),
1779
1798
  state = crate::state::persist::runtime_state_path(workspace).display(),
1780
1799
  pid = crate::coordinator::coordinator_pid_path(&coordinator_workspace).display(),
1781
1800
  )
@@ -1821,61 +1840,278 @@ fn verify_spawned_agent_live(
1821
1840
  /// E51 guard). In that case the user must still run `attach-leader` manually,
1822
1841
  /// matching pre-fix behaviour. We log to stderr so the operator sees why
1823
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
+
1824
2042
  fn try_autobind_leader_after_restart(
1825
2043
  workspace: &std::path::Path,
1826
2044
  team: Option<&str>,
1827
2045
  state: &serde_json::Value,
1828
- ) {
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
+ }
1829
2053
  if std::env::var_os("TMUX_PANE").is_none() {
1830
- return;
2054
+ return None;
1831
2055
  }
1832
- // Provider: prefer the existing team_owner.provider (if rebind),
1833
- // else leader_receiver.provider (stale, but still informative),
1834
- // else default to ClaudeCode (matches the most common deployment).
1835
- let provider = state
1836
- .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"))
1837
2066
  .and_then(serde_json::Value::as_str)
1838
2067
  .or_else(|| {
1839
- state
1840
- .pointer("/leader_receiver/provider")
2068
+ selected_team_receiver(state, team_key)
2069
+ .and_then(|receiver| receiver.get("provider"))
1841
2070
  .and_then(serde_json::Value::as_str)
1842
2071
  })
1843
- .and_then(|s| {
1844
- // Legacy auto-attach: collapse any claude variant to ClaudeCode
1845
- // (this site historically treated `Claude` and `ClaudeCode` as
1846
- // the same attach target). Wire-format `parse_provider` keeps
1847
- // them distinct everywhere else.
1848
- crate::provider::wire::parse_provider(s).map(|p| match p {
1849
- crate::model::enums::Provider::Claude => crate::model::enums::Provider::ClaudeCode,
1850
- other => other,
1851
- })
1852
- })
1853
- .unwrap_or(crate::model::enums::Provider::ClaudeCode);
1854
- let team_str = team;
1855
- 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) {
1856
2080
  Ok(result) if result.ok => {
1857
- 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(
1858
2086
  workspace,
1859
- team_str,
2087
+ team,
1860
2088
  "restart-auto-attach",
1861
2089
  );
1862
- eprintln!(
1863
- "team_agent::restart auto_attach_leader ok pane={:?} team={:?}",
1864
- result.bound_pane_id.as_ref().map(|p| p.as_str()),
1865
- team_str,
1866
- );
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)
1867
2102
  }
1868
2103
  Ok(result) => {
1869
2104
  eprintln!(
1870
- "team_agent::restart auto_attach_leader skipped reason={:?} team={:?}",
1871
- result.reason, team_str,
2105
+ "team_agent::restart auto_attach_leader skipped reason={:?} team={team:?}",
2106
+ result.reason,
1872
2107
  );
2108
+ None
1873
2109
  }
1874
2110
  Err(error) => {
1875
2111
  eprintln!(
1876
- "team_agent::restart auto_attach_leader failed error={error} team={team_str:?} \
1877
- (run `team-agent attach-leader` from your tmux pane to bind manually)",
2112
+ "team_agent::restart auto_attach_leader failed error={error} team={team:?}"
1878
2113
  );
2114
+ None
1879
2115
  }
1880
2116
  }
1881
2117
  }
@@ -2522,10 +2758,10 @@ fn write_restart_agent_failed_event(
2522
2758
  "session_id": decision.session_id.as_ref().map(|session| session.as_str()),
2523
2759
  "phase": phase,
2524
2760
  "error": error,
2525
- "action": restart_failure_action(
2526
- &decision.agent_id,
2527
- phase,
2528
- 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
2529
2765
  ),
2530
2766
  "log": format!(
2531
2767
  ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
@@ -3008,30 +3244,14 @@ fn restart_failure_next_actions(failed_agents: &[RestartFailedAgent]) -> Vec<Str
3008
3244
  failed_agents
3009
3245
  .iter()
3010
3246
  .map(|failure| {
3011
- restart_failure_action(
3012
- &failure.agent_id,
3013
- &failure.phase,
3014
- 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
3015
3250
  )
3016
3251
  })
3017
3252
  .collect()
3018
3253
  }
3019
3254
 
3020
- pub(crate) fn restart_failure_action(
3021
- agent_id: &AgentId,
3022
- phase: &str,
3023
- session_id: Option<&SessionId>,
3024
- ) -> String {
3025
- if phase == "spawn" && session_id.is_none() {
3026
- return format!(
3027
- "worker {agent_id} failed before a pane was created; inspect the reported provider error, then rerun `team-agent restart --allow-fresh`"
3028
- );
3029
- }
3030
- format!(
3031
- "inspect worker {agent_id} output, then reset that worker with `team-agent reset-agent {agent_id}` or rerun `team-agent restart --allow-fresh`"
3032
- )
3033
- }
3034
-
3035
3255
  fn start_mode_wire(mode: StartMode) -> &'static str {
3036
3256
  match mode {
3037
3257
  StartMode::Resumed => "resumed",
@@ -4000,4 +4220,721 @@ tasks:
4000
4220
  );
4001
4221
  }
4002
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
+ }
4003
4940
  }