@team-agent/installer 0.5.0 → 0.5.2

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 (40) hide show
  1. package/Cargo.lock +118 -9
  2. package/Cargo.toml +2 -2
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/app_server_test_support.rs +258 -0
  5. package/crates/team-agent/src/cli/adapters.rs +32 -3
  6. package/crates/team-agent/src/cli/attach_app_server_leader.rs +16 -0
  7. package/crates/team-agent/src/cli/diagnose.rs +14 -5
  8. package/crates/team-agent/src/cli/emit.rs +111 -3
  9. package/crates/team-agent/src/cli/mod.rs +72 -25
  10. package/crates/team-agent/src/cli/named_address.rs +975 -0
  11. package/crates/team-agent/src/cli/send.rs +200 -1
  12. package/crates/team-agent/src/cli/status_port.rs +44 -6
  13. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  14. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  15. package/crates/team-agent/src/cli/tests/named_address.rs +586 -0
  16. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  17. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  18. package/crates/team-agent/src/cli/types.rs +21 -0
  19. package/crates/team-agent/src/codex_app_server.rs +488 -0
  20. package/crates/team-agent/src/conpty/backend.rs +710 -0
  21. package/crates/team-agent/src/conpty/mod.rs +32 -0
  22. package/crates/team-agent/src/coordinator/backoff.rs +45 -6
  23. package/crates/team-agent/src/diagnose/orphans.rs +11 -3
  24. package/crates/team-agent/src/leader/lease.rs +144 -7
  25. package/crates/team-agent/src/leader/owner_bind.rs +5 -2
  26. package/crates/team-agent/src/leader/start.rs +18 -5
  27. package/crates/team-agent/src/leader/tests/lease_api.rs +172 -0
  28. package/crates/team-agent/src/lib.rs +23 -0
  29. package/crates/team-agent/src/lifecycle/launch.rs +127 -3
  30. package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
  31. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
  32. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +90 -0
  33. package/crates/team-agent/src/messaging/delivery.rs +329 -9
  34. package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
  35. package/crates/team-agent/src/messaging/mod.rs +2 -2
  36. package/crates/team-agent/src/messaging/results.rs +78 -21
  37. package/crates/team-agent/src/messaging/tests/runtime.rs +232 -0
  38. package/crates/team-agent/src/tmux_backend.rs +31 -0
  39. package/crates/team-agent/src/transport_factory.rs +632 -0
  40. package/package.json +4 -4
@@ -724,6 +724,19 @@ fn report_result_for_owner_team_inner(
724
724
  &event_log,
725
725
  ) {
726
726
  Ok(outcome) => outcome,
727
+ Err(error) if report_state_has_app_server_receiver(&state) => {
728
+ let message_id = format!("fallback:{result_id}");
729
+ crate::messaging::DeliveryOutcome {
730
+ ok: false,
731
+ status: crate::messaging::DeliveryStatus::Failed,
732
+ message_status: super::helpers::MessageStatusShadow("failed".to_string()),
733
+ message_id: Some(message_id),
734
+ verification: Some(format!("leader_funnel_error:{error}")),
735
+ stage: None,
736
+ reason: Some(crate::messaging::DeliveryRefusal::LeaderNotAttached),
737
+ channel: Some("codex_app_server".to_string()),
738
+ }
739
+ }
727
740
  Err(error) => {
728
741
  let message_id = format!("fallback:{result_id}");
729
742
  let fallback_error = fallback_primary_error_text(
@@ -744,17 +757,32 @@ fn report_result_for_owner_team_inner(
744
757
  };
745
758
  if let Some(message_id) = outcome.message_id.clone() {
746
759
  let store = MessageStore::open(workspace)?;
747
- let transport = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
760
+ // 0.5.x Phase 1d Batch 4: report_result retry uses the runtime
761
+ // factory transport so a conpty team's report notification
762
+ // goes to the shim (design §Batch 4). Previously we always
763
+ // built `TmuxBackend::for_workspace`, which for a conpty team
764
+ // silently no-op'd the retry. Factory refusal falls back to
765
+ // tmux workspace (byte-equivalent tmux teams; honest failure
766
+ // for conpty teams without a resolvable factory input).
748
767
  let delivery_state_raw = crate::state::persist::load_runtime_state(workspace)
749
768
  .unwrap_or_else(|_| state_for_owner.clone());
750
769
  let delivery_state = report_owner_state(&delivery_state_raw, &owner_team);
770
+ let transport: Box<dyn crate::transport::Transport> =
771
+ match crate::transport_factory::resolve_read_only_transport(
772
+ workspace,
773
+ Some(&delivery_state),
774
+ crate::transport_factory::TransportPurpose::MessageDelivery,
775
+ ) {
776
+ Ok(r) => r.backend,
777
+ Err(_) => Box::new(crate::tmux_backend::TmuxBackend::for_workspace(workspace)),
778
+ };
751
779
  let mut primary_error: Option<String> = None;
752
780
  for attempt in 0..3 {
753
781
  let _ = store.mark(&message_id, "accepted", None);
754
782
  match super::delivery::deliver_pending_message(
755
783
  workspace,
756
784
  &store,
757
- &transport,
785
+ transport.as_ref(),
758
786
  &message_id,
759
787
  &event_log,
760
788
  &delivery_state,
@@ -781,7 +809,7 @@ fn report_result_for_owner_team_inner(
781
809
  match super::delivery::deliver_pending_messages(
782
810
  workspace,
783
811
  &delivery_state,
784
- &transport,
812
+ transport.as_ref(),
785
813
  &event_log,
786
814
  ) {
787
815
  Ok(delivered)
@@ -824,24 +852,26 @@ fn report_result_for_owner_team_inner(
824
852
  }
825
853
  }
826
854
  if !outcome.ok {
827
- let fallback_error = primary_error.unwrap_or_else(|| {
828
- format!(
829
- "leader_notification_primary_failed:{}",
830
- super::helpers::status_wire(outcome.status)
831
- )
832
- });
833
- let fallback_error =
834
- fallback_primary_error_text(fallback_primary_error, fallback_error);
835
- outcome = super::leader_receiver::deliver_to_leader_fallback_pane(
836
- workspace,
837
- &delivery_state,
838
- &message_id,
839
- Some(&result_id),
840
- &content,
841
- false,
842
- Some(&fallback_error),
843
- &event_log,
844
- )?;
855
+ if !report_state_has_app_server_receiver(&delivery_state) {
856
+ let fallback_error = primary_error.unwrap_or_else(|| {
857
+ format!(
858
+ "leader_notification_primary_failed:{}",
859
+ super::helpers::status_wire(outcome.status)
860
+ )
861
+ });
862
+ let fallback_error =
863
+ fallback_primary_error_text(fallback_primary_error, fallback_error);
864
+ outcome = super::leader_receiver::deliver_to_leader_fallback_pane(
865
+ workspace,
866
+ &delivery_state,
867
+ &message_id,
868
+ Some(&result_id),
869
+ &content,
870
+ false,
871
+ Some(&fallback_error),
872
+ &event_log,
873
+ )?;
874
+ }
845
875
  }
846
876
  }
847
877
  let leader_notified = outcome.ok;
@@ -937,6 +967,33 @@ fn report_owner_state(state: &serde_json::Value, owner_team: &str) -> serde_json
937
967
  state
938
968
  }
939
969
 
970
+ fn report_state_has_app_server_receiver(state: &serde_json::Value) -> bool {
971
+ report_leader_receiver_value(state).is_some_and(crate::codex_app_server::receiver_is_app_server)
972
+ }
973
+
974
+ fn report_leader_receiver_value(state: &serde_json::Value) -> Option<&serde_json::Value> {
975
+ state
976
+ .get("leader_receiver")
977
+ .or_else(|| {
978
+ state
979
+ .get("active_team_key")
980
+ .and_then(serde_json::Value::as_str)
981
+ .and_then(|team| state.get("teams").and_then(|teams| teams.get(team)))
982
+ .and_then(|team| team.get("leader_receiver"))
983
+ })
984
+ .or_else(|| {
985
+ let teams = state.get("teams").and_then(serde_json::Value::as_object)?;
986
+ if teams.len() == 1 {
987
+ teams
988
+ .values()
989
+ .next()
990
+ .and_then(|team| team.get("leader_receiver"))
991
+ } else {
992
+ None
993
+ }
994
+ })
995
+ }
996
+
940
997
  fn insert_result_if_absent(
941
998
  conn: &rusqlite::Connection,
942
999
  result_id: &str,
@@ -1894,6 +1894,238 @@ fn e51_delivery_keeps_same_socket_pane_conflict_guard() {
1894
1894
  );
1895
1895
  }
1896
1896
 
1897
+ fn app_server_state(
1898
+ ws: &Path,
1899
+ endpoint: &str,
1900
+ thread_id: &str,
1901
+ session_id: &str,
1902
+ ) -> serde_json::Value {
1903
+ serde_json::json!({
1904
+ "active_team_key": "team-a",
1905
+ "teams": {
1906
+ "team-a": {
1907
+ "team_key": "team-a",
1908
+ "agents": {},
1909
+ "leader_receiver": {
1910
+ "mode": "codex_app_server",
1911
+ "transport_kind": "codex_app_server",
1912
+ "status": "attached",
1913
+ "provider": "codex",
1914
+ "owner_epoch": 7,
1915
+ "app_server": {
1916
+ "socket": endpoint,
1917
+ "thread_id": thread_id,
1918
+ "session_id": session_id,
1919
+ "cwd": ws.to_string_lossy(),
1920
+ "cli_version": "codex-appserver-team-agent-test/0.139.0",
1921
+ "bound_at": "2026-07-05T00:00:00Z"
1922
+ }
1923
+ },
1924
+ "team_owner": {
1925
+ "provider": "codex",
1926
+ "transport_kind": "codex_app_server",
1927
+ "owner_epoch": 7
1928
+ },
1929
+ "owner_epoch": 7
1930
+ }
1931
+ }
1932
+ })
1933
+ }
1934
+
1935
+ #[test]
1936
+ fn app_server_leader_delivery_marks_delivered_without_tmux_transport() {
1937
+ let ws = tmp_ws("appdeliver");
1938
+ let fake = crate::app_server_test_support::FakeAppServer::start(
1939
+ "deliver-ok",
1940
+ crate::app_server_test_support::FakeAppServerScript::happy(
1941
+ "thread-live",
1942
+ "session-live",
1943
+ ws.to_str().unwrap(),
1944
+ ),
1945
+ );
1946
+ let state = app_server_state(&ws, fake.endpoint(), "thread-live", "session-live");
1947
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
1948
+ let store = store_for(&ws);
1949
+ let log = EventLog::new(&ws);
1950
+ let message_id = store
1951
+ .create_message(
1952
+ None,
1953
+ "worker_a",
1954
+ "leader",
1955
+ "app server hello",
1956
+ None,
1957
+ false,
1958
+ Some("team-a"),
1959
+ )
1960
+ .unwrap();
1961
+
1962
+ let out = deliver_pending_message(&ws, &store, &NoopTransport, &message_id, &log, &state)
1963
+ .expect("app-server delivery should not require tmux transport");
1964
+
1965
+ assert!(
1966
+ out.ok,
1967
+ "app-server turn/start acceptance is delivery proof: {out:?}"
1968
+ );
1969
+ assert_eq!(out.message_status.0, "delivered");
1970
+ assert_eq!(out.channel.as_deref(), Some("codex_app_server"));
1971
+ let turns = fake.received_turns();
1972
+ assert_eq!(turns.len(), 1);
1973
+ assert_eq!(
1974
+ turns[0]["params"]["clientUserMessageId"],
1975
+ serde_json::json!(message_id)
1976
+ );
1977
+ let events = log.tail(0).unwrap();
1978
+ assert!(
1979
+ events.iter().any(|event| {
1980
+ event.get("event").and_then(serde_json::Value::as_str)
1981
+ == Some("leader_receiver.app_server_submitted")
1982
+ }),
1983
+ "delivery must emit app-server submission event; events={events:?}"
1984
+ );
1985
+ }
1986
+
1987
+ #[test]
1988
+ fn app_server_leader_delivery_stale_tuple_fails_closed_with_event() {
1989
+ let ws = tmp_ws("appstale");
1990
+ let fake = crate::app_server_test_support::FakeAppServer::start(
1991
+ "deliver-stale",
1992
+ crate::app_server_test_support::FakeAppServerScript::happy(
1993
+ "thread-live",
1994
+ "session-new",
1995
+ ws.to_str().unwrap(),
1996
+ ),
1997
+ );
1998
+ let state = app_server_state(&ws, fake.endpoint(), "thread-live", "session-old");
1999
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
2000
+ let store = store_for(&ws);
2001
+ let log = EventLog::new(&ws);
2002
+ let message_id = store
2003
+ .create_message(
2004
+ None,
2005
+ "worker_a",
2006
+ "leader",
2007
+ "stale",
2008
+ None,
2009
+ false,
2010
+ Some("team-a"),
2011
+ )
2012
+ .unwrap();
2013
+
2014
+ let out = deliver_pending_message(&ws, &store, &NoopTransport, &message_id, &log, &state)
2015
+ .expect("stale app-server binding should be a business failure, not panic");
2016
+
2017
+ assert!(!out.ok);
2018
+ assert_eq!(out.channel.as_deref(), Some("rebind_required"));
2019
+ assert_eq!(out.message_status.0, "failed");
2020
+ let events = log.tail(0).unwrap();
2021
+ assert!(
2022
+ events.iter().any(|event| {
2023
+ event.get("event").and_then(serde_json::Value::as_str)
2024
+ == Some("leader_receiver.app_server_thread_stale")
2025
+ && event.get("message_id").and_then(serde_json::Value::as_str)
2026
+ == Some(message_id.as_str())
2027
+ }),
2028
+ "stale app-server tuple must emit app_server_thread_stale; events={events:?}"
2029
+ );
2030
+ }
2031
+
2032
+ #[test]
2033
+ fn app_server_leader_delivery_fails_closed_on_mode_transport_conflict() {
2034
+ let ws = tmp_ws("appconflict");
2035
+ let mut state = app_server_state(
2036
+ &ws,
2037
+ "unix:///tmp/team-agent-should-not-connect.sock",
2038
+ "thread-live",
2039
+ "session-live",
2040
+ );
2041
+ state["teams"]["team-a"]["leader_receiver"]["mode"] = serde_json::json!("direct_tmux");
2042
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
2043
+ let store = store_for(&ws);
2044
+ let log = EventLog::new(&ws);
2045
+ let message_id = store
2046
+ .create_message(
2047
+ None,
2048
+ "worker_a",
2049
+ "leader",
2050
+ "conflict",
2051
+ None,
2052
+ false,
2053
+ Some("team-a"),
2054
+ )
2055
+ .unwrap();
2056
+
2057
+ let out = deliver_pending_message(&ws, &store, &NoopTransport, &message_id, &log, &state)
2058
+ .expect("transport conflict should be a business failure");
2059
+
2060
+ assert!(!out.ok);
2061
+ assert_eq!(out.channel.as_deref(), Some("rebind_required"));
2062
+ assert_eq!(out.message_status.0, "failed");
2063
+ assert_eq!(
2064
+ out.verification.as_deref(),
2065
+ Some("run team-agent claim-leader, takeover, or attach-app-server-leader")
2066
+ );
2067
+ let events = log.tail(0).unwrap();
2068
+ assert!(
2069
+ events.iter().any(|event| {
2070
+ event.get("event").and_then(serde_json::Value::as_str)
2071
+ == Some("leader_receiver.delivery_blocked")
2072
+ && event.get("reason").and_then(serde_json::Value::as_str)
2073
+ == Some("leader_receiver_transport_conflict")
2074
+ }),
2075
+ "transport conflict must emit explicit fail-closed event; events={events:?}"
2076
+ );
2077
+ }
2078
+
2079
+ #[test]
2080
+ fn app_server_leader_busy_is_retryable_and_does_not_steer() {
2081
+ let ws = tmp_ws("appbusy");
2082
+ let mut script = crate::app_server_test_support::FakeAppServerScript::happy(
2083
+ "thread-live",
2084
+ "session-live",
2085
+ ws.to_str().unwrap(),
2086
+ );
2087
+ script.turn_error = Some("thread already has an active turn".to_string());
2088
+ let fake = crate::app_server_test_support::FakeAppServer::start("deliver-busy", script);
2089
+ let state = app_server_state(&ws, fake.endpoint(), "thread-live", "session-live");
2090
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
2091
+ let store = store_for(&ws);
2092
+ let log = EventLog::new(&ws);
2093
+ let message_id = store
2094
+ .create_message(
2095
+ None,
2096
+ "worker_a",
2097
+ "leader",
2098
+ "busy",
2099
+ None,
2100
+ false,
2101
+ Some("team-a"),
2102
+ )
2103
+ .unwrap();
2104
+
2105
+ let out = deliver_pending_message(&ws, &store, &NoopTransport, &message_id, &log, &state)
2106
+ .expect("leader busy should be retryable");
2107
+
2108
+ assert!(!out.ok);
2109
+ assert_eq!(out.status, DeliveryStatus::RetryScheduled);
2110
+ assert_eq!(out.channel.as_deref(), Some("leader_busy"));
2111
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
2112
+ let status: String = conn
2113
+ .query_row(
2114
+ "select status from messages where message_id = ?1",
2115
+ [&message_id],
2116
+ |row| row.get(0),
2117
+ )
2118
+ .unwrap();
2119
+ assert_eq!(status, "target_resolved", "busy leader remains retryable");
2120
+ let turns = fake.received_turns();
2121
+ assert_eq!(
2122
+ turns.len(),
2123
+ 1,
2124
+ "must only call turn/start, never turn/steer"
2125
+ );
2126
+ assert_eq!(turns[0]["method"], serde_json::json!("turn/start"));
2127
+ }
2128
+
1897
2129
  #[test]
1898
2130
  fn u1_multi_team_send_does_not_backfill_top_level_leader_binding() {
1899
2131
  let ws = tmp_ws("u1backfill");
@@ -430,6 +430,37 @@ fn runtime_tmux_endpoint_from_state(
430
430
  /// stable FNV-1a hash over the canonicalized path. AF_UNIX `sun_path` is ~104 chars and the socket
431
431
  /// lives at `/tmp/tmux-<uid>/<name>`, so we must NOT use the (~88-char) session name. §10: a
432
432
  /// canonicalize failure falls back to the raw path (never panics).
433
+ /// Public re-export of the crate-private canonical workspace hash used
434
+ /// by the tmux short-socket derivation. `transport_factory` uses this
435
+ /// same hash for the ConPTY `workspace_hash` so both backends see
436
+ /// identical workspace identity. Adding a wrapper (not calling
437
+ /// `socket_name_for_workspace` directly outside this file) keeps the
438
+ /// existing internal API stable.
439
+ pub fn workspace_short_hash_pub(workspace: &Path) -> String {
440
+ // The tmux short-socket name is `ta-<12 hex>`; the workspace-hash
441
+ // used by the ConPTY workspace identity is the raw 12-hex portion
442
+ // (no `ta-` prefix), which is what the shim/pipe name derivation
443
+ // expects. Reuse the exact same hash so drift is impossible.
444
+ let name = socket_name_for_workspace(workspace);
445
+ name.strip_prefix("ta-").unwrap_or(&name).to_string()
446
+ }
447
+
448
+ /// Public re-export of the crate-private `runtime_tmux_endpoint_from_state`
449
+ /// probe. `transport_factory` uses this to spot the legacy tmux endpoint
450
+ /// in `state` without duplicating the field-precedence logic.
451
+ pub fn runtime_tmux_endpoint_from_state_pub(
452
+ state: Option<&serde_json::Value>,
453
+ ) -> Option<(String, &'static str)> {
454
+ runtime_tmux_endpoint_from_state(state).map(|(endpoint, source)| {
455
+ let src = match source {
456
+ RuntimeTmuxEndpointSource::StateTmuxEndpoint => "state.tmux_endpoint",
457
+ RuntimeTmuxEndpointSource::StateTmuxSocket => "state.tmux_socket",
458
+ RuntimeTmuxEndpointSource::WorkspaceFallback => "workspace_fallback",
459
+ };
460
+ (endpoint.to_string(), src)
461
+ })
462
+ }
463
+
433
464
  pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
434
465
  let canonical = workspace
435
466
  .canonicalize()