@team-agent/installer 0.5.50 → 0.5.52

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 (93) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -3
  4. package/crates/team-agent/src/cli/emit.rs +2 -1
  5. package/crates/team-agent/src/cli/mod.rs +32 -14
  6. package/crates/team-agent/src/cli/named_address.rs +46 -115
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +44 -1169
  13. package/crates/team-agent/src/cli/spec.rs +1 -1
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  25. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
  26. package/crates/team-agent/src/cli/types.rs +1 -0
  27. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  29. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  30. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  31. package/crates/team-agent/src/db/message_store.rs +154 -44
  32. package/crates/team-agent/src/event_log.rs +73 -0
  33. package/crates/team-agent/src/leader/lease.rs +202 -16
  34. package/crates/team-agent/src/leader/start.rs +28 -4
  35. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  36. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
  37. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  38. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  39. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  41. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  42. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  43. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  44. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  45. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  46. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  48. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  49. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  50. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  51. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  52. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  53. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  54. package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
  55. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  56. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
  57. package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
  58. package/crates/team-agent/src/lifecycle/restart.rs +26 -2
  59. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
  60. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
  61. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  62. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  63. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  64. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  65. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  66. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  67. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  68. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  69. package/crates/team-agent/src/messaging/activity.rs +4 -2
  70. package/crates/team-agent/src/messaging/address.rs +86 -0
  71. package/crates/team-agent/src/messaging/delivery.rs +288 -183
  72. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  73. package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
  74. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  75. package/crates/team-agent/src/messaging/mod.rs +15 -2
  76. package/crates/team-agent/src/messaging/persist.rs +309 -0
  77. package/crates/team-agent/src/messaging/results.rs +16 -24
  78. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  79. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  80. package/crates/team-agent/src/messaging/send.rs +101 -49
  81. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +284 -21
  88. package/crates/team-agent/src/state/projection.rs +245 -30
  89. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  90. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  91. package/crates/team-agent/src/state/repository.rs +94 -27
  92. package/crates/team-agent/src/state/selector.rs +9 -18
  93. package/package.json +4 -4
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.50"
578
+ version = "0.5.52"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.50"
12
+ version = "0.5.52"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -841,8 +841,8 @@ fn provider_command(provider: &str) -> &str {
841
841
  }
842
842
 
843
843
  fn seed_fake_e2e_state(workspace: &Path) -> Result<(), CliError> {
844
- crate::state::persist::save_runtime_state(
845
- workspace,
844
+ crate::state::repository::StateRepository::new(workspace).save(
845
+ crate::state::repository::StateWriteIntent::FakeE2eSeed,
846
846
  &json!({
847
847
  "leader": {"id": "leader"},
848
848
  "session_name": "team-agent-fake-e2e",
@@ -876,7 +876,8 @@ fn fake_shutdown(workspace: &Path) -> Result<Value, CliError> {
876
876
  }
877
877
  }
878
878
  }
879
- crate::state::persist::save_runtime_state(workspace, &state)?;
879
+ crate::state::repository::StateRepository::new(workspace)
880
+ .save(crate::state::repository::StateWriteIntent::FakeE2eSeed, &state)?;
880
881
  Ok(json!({
881
882
  "ok": true,
882
883
  "session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
@@ -1353,6 +1354,7 @@ pub fn cmd_add_agent(args: &AddAgentArgs) -> Result<CmdResult, CliError> {
1353
1354
  &args.role_file,
1354
1355
  !args.no_display,
1355
1356
  args.team.as_deref(),
1357
+ args.force,
1356
1358
  )?,
1357
1359
  args.json,
1358
1360
  ))
@@ -329,7 +329,7 @@ fn command_help(command: Option<&str>) -> String {
329
329
  Some("reset-agent") => "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--no-display] [--json]".to_string(),
330
330
  Some("start-agent") => "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--no-display] [--json]".to_string(),
331
331
  Some("stop-agent") => "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
332
- Some("add-agent") => "usage: team-agent add-agent AGENT --role-file FILE [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
332
+ Some("add-agent") => "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
333
333
  Some("fork-agent") => "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
334
334
  Some("remove-agent") => "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]".to_string(),
335
335
  // 0.5.26 (§7.6): removed from help; dispatch was never wired.
@@ -1345,6 +1345,7 @@ fn add_agent_args(args: &[String], cwd: &Path) -> Result<AddAgentArgs, CliError>
1345
1345
  role_file: parsed
1346
1346
  .role_file
1347
1347
  .ok_or_else(|| CliError::Usage("missing --role-file".to_string()))?,
1348
+ force: parsed.force,
1348
1349
  no_display: parsed.no_display,
1349
1350
  json: parsed.json,
1350
1351
  })
@@ -884,7 +884,13 @@ pub mod lifecycle_port {
884
884
  if session_killed && !verification_degraded {
885
885
  mark_active_team_shutdown(&mut state, team_shutdown_status);
886
886
  }
887
- crate::state::projection::save_team_scoped_state(&run_workspace, &state)?;
887
+ crate::state::repository::StateRepository::new(&run_workspace).save(
888
+ crate::state::repository::StateWriteIntent::ShutdownTeam {
889
+ team_key: team,
890
+ clean: session_killed && !verification_degraded,
891
+ },
892
+ &state,
893
+ )?;
888
894
  promote_live_sibling_after_scoped_shutdown(&run_workspace, &state)?;
889
895
  } else {
890
896
  let _changed_keys = mark_matching_session_teams_stopped(
@@ -893,7 +899,13 @@ pub mod lifecycle_port {
893
899
  session_killed && !verification_degraded,
894
900
  team_shutdown_status,
895
901
  );
896
- crate::state::persist::save_runtime_state(&run_workspace, &state)?;
902
+ crate::state::repository::StateRepository::new(&run_workspace).save(
903
+ crate::state::repository::StateWriteIntent::ShutdownTeam {
904
+ team_key: None,
905
+ clean: session_killed && !verification_degraded,
906
+ },
907
+ &state,
908
+ )?;
897
909
  }
898
910
  let coordinator_status = if coordinator_timeout {
899
911
  "timeout"
@@ -2310,14 +2322,16 @@ pub mod lifecycle_port {
2310
2322
  role_file: &str,
2311
2323
  open_display: bool,
2312
2324
  team: Option<&str>,
2325
+ force: bool,
2313
2326
  ) -> Result<Value, CliError> {
2314
2327
  let agent_id = crate::model::ids::AgentId::new(agent);
2315
- match crate::lifecycle::add_agent(
2328
+ match crate::lifecycle::add_agent_force(
2316
2329
  workspace,
2317
2330
  &agent_id,
2318
2331
  Path::new(role_file),
2319
2332
  open_display,
2320
2333
  team,
2334
+ force,
2321
2335
  ) {
2322
2336
  Ok(report) => Ok(json!({
2323
2337
  "ok": true,
@@ -2372,7 +2386,7 @@ pub mod lifecycle_port {
2372
2386
  ));
2373
2387
  }
2374
2388
  }
2375
- Err(error) if confirm => return Ok(error_value(error)),
2389
+ Err(error) if confirm && !force => return Ok(error_value(error)),
2376
2390
  Err(_) => {}
2377
2391
  }
2378
2392
  if !confirm {
@@ -2548,7 +2562,13 @@ pub mod lifecycle_port {
2548
2562
  .and_then(|agents| agents.keys().next().cloned())
2549
2563
  .map(Value::String)
2550
2564
  .unwrap_or(Value::Null);
2551
- crate::state::persist::save_runtime_state(workspace, &state)
2565
+ crate::state::repository::StateRepository::new(workspace)
2566
+ .save(
2567
+ crate::state::repository::StateWriteIntent::IdleAck {
2568
+ team_key: Some(&team),
2569
+ },
2570
+ &state,
2571
+ )
2552
2572
  .map_err(|e| CliError::Runtime(e.to_string()))?;
2553
2573
  crate::event_log::EventLog::new(workspace)
2554
2574
  .write(
@@ -3553,7 +3573,13 @@ pub mod lifecycle_port {
3553
3573
  return Ok(());
3554
3574
  };
3555
3575
  let promoted = crate::state::projection::project_top_level_view(&raw, next_key);
3556
- crate::state::persist::save_runtime_state(workspace, &promoted)?;
3576
+ crate::state::repository::StateRepository::new(workspace).save(
3577
+ crate::state::repository::StateWriteIntent::PromoteLiveSiblingAfterShutdown {
3578
+ stopped_team_key: stopped_key,
3579
+ promoted_team_key: next_key,
3580
+ },
3581
+ &promoted,
3582
+ )?;
3557
3583
  Ok(())
3558
3584
  }
3559
3585
 
@@ -3966,14 +3992,6 @@ pub mod diagnose_port {
3966
3992
  format!("{:012x}", now & 0xffffffffffff)
3967
3993
  }
3968
3994
 
3969
- fn read_runtime_state(workspace: &Path) -> Value {
3970
- let path = workspace.join(".team").join("runtime").join("state.json");
3971
- std::fs::read_to_string(path)
3972
- .ok()
3973
- .and_then(|s| serde_json::from_str(&s).ok())
3974
- .unwrap_or_else(|| json!({}))
3975
- }
3976
-
3977
3995
  fn which_path(binary: &str) -> Option<String> {
3978
3996
  let path = std::env::var_os("PATH")?;
3979
3997
  for dir in std::env::split_paths(&path) {
@@ -505,57 +505,20 @@ enum ParsedTarget {
505
505
  }
506
506
 
507
507
  fn parse_named_address(raw_name: &str) -> Result<ParsedNamedAddress, NamedAddressError> {
508
- let raw = raw_name.trim();
509
- if raw.is_empty() {
510
- return Err(name_invalid("name is empty"));
511
- }
512
- let (workspace, name) = if let Some((workspace, rest)) = raw.split_once("::") {
513
- if workspace.trim().is_empty() || rest.trim().is_empty() {
514
- return Err(name_invalid(
515
- "workspace-qualified name must include workspace and target",
516
- ));
517
- }
518
- (Some(PathBuf::from(workspace)), rest.trim())
519
- } else {
520
- (None, raw)
521
- };
522
-
523
- if name.contains("//") {
524
- return Err(name_invalid("name contains an empty path segment"));
525
- }
526
-
527
- let target = if name.contains('/') {
528
- let parts = name.split('/').collect::<Vec<_>>();
529
- if parts.len() != 2 || !valid_component(parts[0]) || !valid_component(parts[1]) {
530
- return Err(name_invalid("expected <team>/<agent> or <team>/leader"));
531
- }
532
- ParsedTarget::TeamEntity {
533
- team: parts[0].to_string(),
534
- entity: parts[1].to_string(),
535
- }
536
- } else if name.contains(':') {
537
- let parts = name.split(':').collect::<Vec<_>>();
538
- if parts.len() != 2 || parts[0].trim().is_empty() || parts[1].trim().is_empty() {
539
- return Err(name_invalid("expected <session>:<window>"));
508
+ let parsed = crate::messaging::parse_logical_address(raw_name).map_err(name_invalid)?;
509
+ let target = match parsed.target {
510
+ crate::messaging::LogicalAddressTarget::Worker(agent) => ParsedTarget::BareAgent(agent),
511
+ crate::messaging::LogicalAddressTarget::TeamEntity { team, entity } => {
512
+ ParsedTarget::TeamEntity { team, entity }
540
513
  }
541
- ParsedTarget::SessionWindow {
542
- session: parts[0].to_string(),
543
- window: parts[1].to_string(),
514
+ crate::messaging::LogicalAddressTarget::SessionWindow { session, window } => {
515
+ ParsedTarget::SessionWindow { session, window }
544
516
  }
545
- } else {
546
- if !valid_component(name) {
547
- return Err(name_invalid("expected a non-empty agent id"));
548
- }
549
- ParsedTarget::BareAgent(name.to_string())
550
517
  };
551
- Ok(ParsedNamedAddress { workspace, target })
552
- }
553
-
554
- fn valid_component(raw: &str) -> bool {
555
- !raw.trim().is_empty()
556
- && !raw.contains(char::is_whitespace)
557
- && !raw.contains('/')
558
- && !raw.contains(':')
518
+ Ok(ParsedNamedAddress {
519
+ workspace: parsed.workspace,
520
+ target,
521
+ })
559
522
  }
560
523
 
561
524
  fn resolve_workspace(
@@ -976,103 +939,71 @@ fn resolve_leader(
976
939
  );
977
940
  return Err(err);
978
941
  }
979
- if crate::codex_app_server::receiver_is_app_server(receiver) {
980
- return resolve_app_server_leader(
981
- sender_workspace,
982
- target_workspace,
983
- team,
984
- parsed,
985
- receiver,
986
- );
987
- }
988
- let pane_id = string_field(receiver, "pane_id")
989
- .filter(|pane| !pane.is_empty())
990
- .ok_or_else(|| {
991
- let mut err = leader_not_attached_third_party(
992
- target_workspace,
993
- team,
994
- None,
995
- None,
996
- None,
997
- transport.tmux_endpoint(),
998
- );
999
- err.candidates =
1000
- leader_advisory_candidates(state, team, transport, "state_recorded_socket");
1001
- err
1002
- })?;
1003
942
  let session = string_field(receiver, "session_name");
1004
943
  let window = string_field(receiver, "window_name");
1005
944
  let socket = string_field(receiver, "tmux_socket")
1006
945
  .map(str::to_string)
1007
946
  .or_else(|| transport.tmux_endpoint());
1008
- let targets = list_targets(transport)?;
1009
- let matches = targets
1010
- .iter()
1011
- .filter(|target| {
1012
- target.pane_id.as_str() == pane_id
1013
- && session.is_none_or(|expected| target.session.as_str() == expected)
1014
- && window.is_none_or(|expected| {
1015
- target
1016
- .window_name
1017
- .as_ref()
1018
- .is_some_and(|name| name.as_str() == expected)
1019
- })
1020
- })
1021
- .collect::<Vec<_>>();
1022
- let stale_pane_seen = targets
1023
- .iter()
1024
- .any(|target| target.pane_id.as_str() == pane_id);
1025
- match matches.len() {
1026
- 1 => Ok(ResolvedNamedAddress {
947
+ match crate::messaging::resolve_live_leader_channel(
948
+ target_workspace,
949
+ receiver,
950
+ transport,
951
+ ) {
952
+ crate::messaging::LeaderChannelResolution::Live(
953
+ crate::messaging::LiveLeaderChannel::DirectTmux(channel),
954
+ ) => Ok(ResolvedNamedAddress {
1027
955
  raw_name: parsed.display_name(),
1028
956
  target_kind: NamedTargetKind::Leader,
1029
957
  sender_workspace: sender_workspace.to_path_buf(),
1030
958
  target_workspace: target_workspace.to_path_buf(),
1031
959
  team_key: Some(team.to_string()),
1032
960
  agent_id: None,
1033
- pane_id: pane_id.to_string(),
961
+ pane_id: channel.pane_id.clone(),
1034
962
  session_name: session.map(str::to_string),
1035
963
  window_name: window.map(str::to_string),
1036
964
  tmux_endpoint: socket,
1037
965
  transport_kind: Some("direct_tmux".to_string()),
1038
966
  app_server: None,
1039
- state_pane_id: Some(pane_id.to_string()),
967
+ state_pane_id: Some(channel.pane_id),
1040
968
  state_pane_stale: false,
1041
969
  agent_status: None,
1042
- warning: None,
970
+ warning: (!channel.metadata_drift.is_empty()).then(|| {
971
+ format!(
972
+ "leader_channel_metadata_drift:{}",
973
+ channel.metadata_drift.join(",")
974
+ )
975
+ }),
1043
976
  }),
1044
- 0 => {
977
+ crate::messaging::LeaderChannelResolution::Live(
978
+ crate::messaging::LiveLeaderChannel::CodexAppServer(_),
979
+ ) => resolve_app_server_leader(sender_workspace, target_workspace, team, parsed, receiver),
980
+ crate::messaging::LeaderChannelResolution::Unbound(reason) => {
981
+ let pane_id = string_field(receiver, "pane_id");
1045
982
  let mut err = leader_not_attached_third_party(
1046
983
  target_workspace,
1047
984
  team,
1048
- Some(pane_id),
985
+ pane_id,
1049
986
  session,
1050
987
  window,
1051
988
  socket,
1052
989
  );
1053
- if stale_pane_seen {
1054
- err.log = format!("{} stale_tuple_mismatch=true", err.log);
1055
- }
990
+ err.log = format!("{} channel_unbound={reason:?}", err.log);
1056
991
  err.candidates =
1057
992
  leader_advisory_candidates(state, team, transport, "state_recorded_socket");
1058
993
  Err(err)
1059
994
  }
1060
- _ => Err(name_ambiguous(
1061
- "multiple live panes matched leader_receiver pane id",
1062
- matches
1063
- .into_iter()
1064
- .map(|pane| {
1065
- json!({
1066
- "name": format!("{team}/leader"),
1067
- "workspace": target_workspace.display().to_string(),
1068
- "team_key": team,
1069
- "pane_id": pane.pane_id.as_str(),
1070
- "session": pane.session.as_str(),
1071
- "window": pane.window_name.as_ref().map(|w| w.as_str()),
1072
- })
1073
- })
1074
- .collect(),
1075
- )),
995
+ crate::messaging::LeaderChannelResolution::ProbeFailed(error) => {
996
+ let mut err = leader_not_attached_third_party(
997
+ target_workspace,
998
+ team,
999
+ string_field(receiver, "pane_id"),
1000
+ session,
1001
+ window,
1002
+ socket,
1003
+ );
1004
+ err.log = format!("{} channel_probe_failed={error}", err.log);
1005
+ Err(err)
1006
+ }
1076
1007
  }
1077
1008
  }
1078
1009
 
@@ -0,0 +1,163 @@
1
+ use super::*;
2
+
3
+ pub(super) fn dirty_topology_refusal_value(
4
+ selected: &crate::state::selector::SelectedTeam,
5
+ requested_team: Option<&str>,
6
+ ) -> Option<Value> {
7
+ let issue_ids = crate::topology::restart_dirty_topology_issue_ids(&selected.state);
8
+ if issue_ids.is_empty() {
9
+ return None;
10
+ }
11
+ let session_name = selected
12
+ .state
13
+ .get("session_name")
14
+ .and_then(Value::as_str)
15
+ .unwrap_or_default()
16
+ .to_string();
17
+ let reason = issue_ids
18
+ .first()
19
+ .cloned()
20
+ .unwrap_or_else(|| "dirty_topology".to_string());
21
+ let repair_team = requested_team
22
+ .filter(|team| !team.is_empty())
23
+ .unwrap_or(selected.team_key.as_str());
24
+ Some(json!({
25
+ "ok": false,
26
+ "status": "refused_dirty_topology",
27
+ "reason": reason,
28
+ "session_name": session_name,
29
+ "error": "send refused: tmux endpoint/socket topology is inconsistent; run diagnose from the intended leader socket before sending",
30
+ "issues": issue_ids
31
+ .iter()
32
+ .map(|id| json!({"id": id}))
33
+ .collect::<Vec<_>>(),
34
+ "next_actions": [
35
+ "team-agent diagnose --json",
36
+ format!("team-agent claim-leader --team {repair_team} --confirm --json"),
37
+ format!("team-agent takeover --team {repair_team} --confirm --json")
38
+ ],
39
+ }))
40
+ }
41
+
42
+ pub(super) fn target_has_known_worker(state: &Value, target: &MessageTarget, sender: &str) -> bool {
43
+ let Some(agents) = state.get("agents").and_then(Value::as_object) else {
44
+ return false;
45
+ };
46
+ match target {
47
+ MessageTarget::Single(target) => agents.contains_key(target),
48
+ MessageTarget::Broadcast => agents.keys().any(|agent| agent != sender),
49
+ MessageTarget::Fanout(recipients) => recipients
50
+ .iter()
51
+ .any(|recipient| agents.contains_key(recipient)),
52
+ }
53
+ }
54
+
55
+ #[derive(Debug, Clone)]
56
+ pub(super) struct LoudEnsureResult {
57
+ previous_status: String,
58
+ start: crate::coordinator::StartReport,
59
+ }
60
+
61
+ pub(super) fn loud_ensure_coordinator(
62
+ selected: &crate::state::selector::SelectedTeam,
63
+ ) -> Result<Option<LoudEnsureResult>, CliError> {
64
+ if in_process_unit_test() {
65
+ return Ok(None);
66
+ }
67
+ let workspace = crate::coordinator::WorkspacePath::new(selected.run_workspace.clone());
68
+ let previous = crate::coordinator::coordinator_health(&workspace);
69
+ if previous.ok {
70
+ return Ok(None);
71
+ }
72
+ if previous.service_available
73
+ && matches!(
74
+ previous.binary_identity_relation,
75
+ crate::coordinator::CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller
76
+ )
77
+ {
78
+ return Ok(None);
79
+ }
80
+ let previous_status = coordinator_health_status_wire(previous.status).to_string();
81
+ let start = crate::coordinator::start_coordinator_with_team(
82
+ &workspace,
83
+ Some(selected.team_key.as_str()),
84
+ )
85
+ .map_err(|error| CliError::Runtime(error.to_string()))?;
86
+ if !start.ok {
87
+ return Ok(Some(LoudEnsureResult {
88
+ previous_status,
89
+ start,
90
+ }));
91
+ }
92
+ if matches!(
93
+ start.status,
94
+ crate::coordinator::StartOutcome::Started
95
+ | crate::coordinator::StartOutcome::StartedAfterRotation
96
+ ) {
97
+ crate::event_log::EventLog::new(&selected.run_workspace)
98
+ .write(
99
+ "coordinator.ensure_restarted",
100
+ json!({
101
+ "coordinator_previous_status": previous_status,
102
+ "status": start.status,
103
+ "pid": start.pid.map(|pid| pid.get()),
104
+ "previous_pid": start.previous_pid.map(|pid| pid.get()),
105
+ "binary_path": start.binary_path,
106
+ "binary_version": start.binary_version,
107
+ "rotation_reason": start.rotation_reason,
108
+ }),
109
+ )
110
+ .map_err(|error| CliError::Runtime(error.to_string()))?;
111
+ return Ok(Some(LoudEnsureResult {
112
+ previous_status,
113
+ start,
114
+ }));
115
+ }
116
+ Ok(None)
117
+ }
118
+
119
+ #[cfg(test)]
120
+ pub(super) fn in_process_unit_test() -> bool {
121
+ true
122
+ }
123
+
124
+ #[cfg(not(test))]
125
+ pub(super) fn in_process_unit_test() -> bool {
126
+ false
127
+ }
128
+
129
+ pub(super) fn append_loud_ensure_fields(value: &mut Value, ensure: Option<&LoudEnsureResult>) {
130
+ let Some(ensure) = ensure else {
131
+ return;
132
+ };
133
+ if !ensure.start.ok {
134
+ return;
135
+ }
136
+ if let Some(obj) = value.as_object_mut() {
137
+ obj.insert("coordinator_auto_restarted".to_string(), json!(true));
138
+ obj.insert(
139
+ "coordinator_previous_status".to_string(),
140
+ json!(ensure.previous_status),
141
+ );
142
+ obj.insert(
143
+ "coordinator".to_string(),
144
+ coordinator_start_json(&ensure.start),
145
+ );
146
+ }
147
+ }
148
+
149
+ pub(super) fn coordinator_start_json(report: &crate::coordinator::StartReport) -> Value {
150
+ let summary = crate::lifecycle::CoordinatorStartSummary::from_start_report(report);
151
+ crate::lifecycle::coordinator_start_summary_value(&summary)
152
+ }
153
+
154
+ pub(super) fn coordinator_health_status_wire(
155
+ status: crate::coordinator::CoordinatorHealthStatus,
156
+ ) -> &'static str {
157
+ match status {
158
+ crate::coordinator::CoordinatorHealthStatus::Missing => "missing",
159
+ crate::coordinator::CoordinatorHealthStatus::InvalidPid => "invalid_pid",
160
+ crate::coordinator::CoordinatorHealthStatus::Running => "running",
161
+ crate::coordinator::CoordinatorHealthStatus::Stale => "stale",
162
+ }
163
+ }
@@ -0,0 +1,99 @@
1
+ use super::*;
2
+
3
+ pub(super) fn maybe_enqueue_offline_leader_mailbox(
4
+ sender_workspace: &Path,
5
+ to_name: &str,
6
+ content: &str,
7
+ sender: &str,
8
+ task_id: Option<&str>,
9
+ error: &crate::cli::named_address::NamedAddressError,
10
+ ) -> Result<Option<Value>, CliError> {
11
+ if error.kind != crate::cli::named_address::NamedAddressErrorKind::LeaderNotAttached {
12
+ return Ok(None);
13
+ }
14
+ let parsed = match crate::cli::named_address::parse_leader_target_workspace_and_team(
15
+ sender_workspace,
16
+ to_name,
17
+ ) {
18
+ Ok(Some(v)) => v,
19
+ Ok(None) => return Ok(None),
20
+ Err(_) => return Ok(None),
21
+ };
22
+ let (target_workspace, team_key) = parsed;
23
+ // Owner-scope refusal: sender workspace == target workspace. Keep
24
+ // the actionable attach hint (owner sees status/diagnose copy that
25
+ // points at `attach-leader`).
26
+ let sender_canonical =
27
+ std::fs::canonicalize(sender_workspace).unwrap_or_else(|_| sender_workspace.to_path_buf());
28
+ let target_canonical =
29
+ std::fs::canonicalize(&target_workspace).unwrap_or_else(|_| target_workspace.clone());
30
+ if sender_canonical == target_canonical {
31
+ return Ok(None);
32
+ }
33
+ // Verify the target team is actually alive on this host — mailbox
34
+ // is only for `team live + leader unattached`. Fail-closed otherwise
35
+ // so we never leave a message in a permanently-dead workspace's DB.
36
+ let state = match crate::state::persist::load_runtime_state(&target_workspace) {
37
+ Ok(s) => s,
38
+ Err(_) => return Ok(None),
39
+ };
40
+ let team_alive = target_team_is_alive_for_mailbox(&state, &team_key);
41
+ if !team_alive {
42
+ return Ok(None);
43
+ }
44
+ let event_log = crate::event_log::EventLog::new(&target_workspace);
45
+ let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
46
+ let outcome = messaging::enqueue_leader_mailbox_until_attach(
47
+ &target_workspace,
48
+ &team_key,
49
+ content,
50
+ task.as_ref(),
51
+ sender,
52
+ &event_log,
53
+ )
54
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
55
+ let message_id = outcome.message_id.clone().unwrap_or_else(|| "".to_string());
56
+ Ok(Some(json!({
57
+ "ok": true,
58
+ "status": "queued_until_leader_attach",
59
+ "message_status": "queued_until_leader_attach",
60
+ "channel": "leader_mailbox",
61
+ "delivered": false,
62
+ "to_name": to_name,
63
+ "target_workspace": target_workspace.display().to_string(),
64
+ "team_key": team_key,
65
+ "recipient": "leader",
66
+ "leader_attached": false,
67
+ "message_id": message_id,
68
+ })))
69
+ }
70
+
71
+ /// Positive-source liveness heuristic per offline-mailbox-toname-design.md §4:
72
+ /// - target workspace has state and the team key is present + not archived/down;
73
+ /// - AND at least one live tmux fact — a persisted `session_name` OR any
74
+ /// agent with a recorded pane on the recorded socket.
75
+ ///
76
+ /// We deliberately do NOT poll coordinator health here — enqueuing is
77
+ /// safe even when the coordinator is transiently down; attach-leader
78
+ /// itself replays via `requeue_blocked_leader_messages` regardless.
79
+ pub(super) fn target_team_is_alive_for_mailbox(state: &Value, team_key: &str) -> bool {
80
+ let team = state
81
+ .get("teams")
82
+ .and_then(|v| v.as_object())
83
+ .and_then(|teams| teams.get(team_key));
84
+ let Some(team) = team else {
85
+ return false;
86
+ };
87
+ let status = team
88
+ .get("status")
89
+ .and_then(|v| v.as_str())
90
+ .unwrap_or("alive");
91
+ if matches!(status, "archived" | "down" | "stopped") {
92
+ return false;
93
+ }
94
+ // A recorded session_name is enough — target's coordinator/attach
95
+ // path will re-verify tmux presence when the replay fires.
96
+ team.get("session_name")
97
+ .and_then(|v| v.as_str())
98
+ .is_some_and(|s| !s.is_empty())
99
+ }