@team-agent/installer 0.3.36 → 0.3.37

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.
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.36"
569
+ version = "0.3.37"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.36"
12
+ version = "0.3.37"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -308,14 +308,13 @@ fn pane_conflicts_with_leader_or_other(
308
308
  else {
309
309
  return false;
310
310
  };
311
+ let state_socket = runtime_tmux_socket(state);
312
+ let agent_socket = tmux_socket_field(agent).or(state_socket);
311
313
  // Check leader anchor.
312
314
  for key in ["leader_receiver", "team_owner"] {
313
- if state
314
- .get(key)
315
- .and_then(|v| v.get("pane_id"))
316
- .and_then(serde_json::Value::as_str)
317
- .is_some_and(|lp| lp == pane_id)
318
- {
315
+ if state.get(key).and_then(pane_socket_binding).is_some_and(|leader| {
316
+ pane_conflicts_on_same_socket(pane_id, agent_socket, leader)
317
+ }) {
319
318
  return true;
320
319
  }
321
320
  }
@@ -325,10 +324,13 @@ fn pane_conflicts_with_leader_or_other(
325
324
  if id == agent_id.as_str() {
326
325
  continue;
327
326
  }
327
+ let other_socket = tmux_socket_field(other).or(state_socket);
328
328
  if other
329
329
  .get("pane_id")
330
330
  .and_then(serde_json::Value::as_str)
331
- .is_some_and(|op| op == pane_id)
331
+ .is_some_and(|op| {
332
+ op == pane_id && !tmux_sockets_known_different(agent_socket, other_socket)
333
+ })
332
334
  {
333
335
  return true;
334
336
  }
@@ -337,6 +339,53 @@ fn pane_conflicts_with_leader_or_other(
337
339
  false
338
340
  }
339
341
 
342
+ #[derive(Clone, Copy)]
343
+ struct PaneSocketBinding<'a> {
344
+ pane_id: &'a str,
345
+ tmux_socket: Option<&'a str>,
346
+ }
347
+
348
+ fn pane_conflicts_on_same_socket(
349
+ pane_id: &str,
350
+ agent_socket: Option<&str>,
351
+ other: PaneSocketBinding<'_>,
352
+ ) -> bool {
353
+ other.pane_id == pane_id && !tmux_sockets_known_different(agent_socket, other.tmux_socket)
354
+ }
355
+
356
+ fn tmux_sockets_known_different(left: Option<&str>, right: Option<&str>) -> bool {
357
+ let (Some(left), Some(right)) = (left, right) else {
358
+ return false;
359
+ };
360
+ if left == right {
361
+ return false;
362
+ }
363
+ std::path::Path::new(left).is_absolute() && std::path::Path::new(right).is_absolute()
364
+ }
365
+
366
+ fn runtime_tmux_socket(state: &serde_json::Value) -> Option<&str> {
367
+ tmux_socket_field(state)
368
+ }
369
+
370
+ fn tmux_socket_field(value: &serde_json::Value) -> Option<&str> {
371
+ value
372
+ .get("tmux_endpoint")
373
+ .or_else(|| value.get("tmux_socket"))
374
+ .and_then(serde_json::Value::as_str)
375
+ .filter(|value| !value.is_empty())
376
+ }
377
+
378
+ fn pane_socket_binding(value: &serde_json::Value) -> Option<PaneSocketBinding<'_>> {
379
+ let pane_id = value
380
+ .get("pane_id")
381
+ .and_then(serde_json::Value::as_str)
382
+ .filter(|s| !s.is_empty() && *s != "__team_agent_unbound__")?;
383
+ Some(PaneSocketBinding {
384
+ pane_id,
385
+ tmux_socket: tmux_socket_field(value),
386
+ })
387
+ }
388
+
340
389
  fn agent_pane_live(
341
390
  transport: &dyn crate::transport::Transport,
342
391
  agent: &serde_json::Value,
@@ -950,3 +999,81 @@ fn write_reset_complete_event(
950
999
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
951
1000
  Ok(())
952
1001
  }
1002
+
1003
+ #[cfg(test)]
1004
+ mod tests {
1005
+ use super::*;
1006
+
1007
+ #[test]
1008
+ fn e51_restart_allows_leader_same_pane_id_on_different_tmux_sockets() {
1009
+ let state = serde_json::json!({
1010
+ "tmux_endpoint": "/private/tmp/tmux-501/ta-worker",
1011
+ "leader_receiver": {
1012
+ "pane_id": "%0",
1013
+ "tmux_socket": "/private/tmp/tmux-501/default"
1014
+ },
1015
+ "agents": {
1016
+ "architect": {
1017
+ "pane_id": "%0",
1018
+ "tmux_socket": "/private/tmp/tmux-501/ta-worker"
1019
+ }
1020
+ }
1021
+ });
1022
+ let agent_id = AgentId::new("architect");
1023
+ let agent = state["agents"]["architect"].clone();
1024
+
1025
+ assert!(
1026
+ !pane_conflicts_with_leader_or_other(&state, &agent_id, &agent),
1027
+ "same pane id on different tmux sockets must not force a fresh restart"
1028
+ );
1029
+ }
1030
+
1031
+ #[test]
1032
+ fn e51_restart_keeps_leader_conflict_on_same_tmux_socket() {
1033
+ let socket = "/private/tmp/tmux-501/default";
1034
+ let state = serde_json::json!({
1035
+ "tmux_endpoint": socket,
1036
+ "leader_receiver": {
1037
+ "pane_id": "%0",
1038
+ "tmux_socket": socket
1039
+ },
1040
+ "agents": {
1041
+ "architect": {
1042
+ "pane_id": "%0",
1043
+ "tmux_socket": socket
1044
+ }
1045
+ }
1046
+ });
1047
+ let agent_id = AgentId::new("architect");
1048
+ let agent = state["agents"]["architect"].clone();
1049
+
1050
+ assert!(
1051
+ pane_conflicts_with_leader_or_other(&state, &agent_id, &agent),
1052
+ "same pane id on the same tmux socket must keep the E51 guard"
1053
+ );
1054
+ }
1055
+
1056
+ #[test]
1057
+ fn e51_restart_allows_other_agent_same_pane_id_on_different_tmux_sockets() {
1058
+ let state = serde_json::json!({
1059
+ "tmux_endpoint": "/private/tmp/tmux-501/ta-worker",
1060
+ "agents": {
1061
+ "architect": {
1062
+ "pane_id": "%0",
1063
+ "tmux_socket": "/private/tmp/tmux-501/ta-worker"
1064
+ },
1065
+ "reviewer": {
1066
+ "pane_id": "%0",
1067
+ "tmux_socket": "/private/tmp/tmux-501/default"
1068
+ }
1069
+ }
1070
+ });
1071
+ let agent_id = AgentId::new("architect");
1072
+ let agent = state["agents"]["architect"].clone();
1073
+
1074
+ assert!(
1075
+ !pane_conflicts_with_leader_or_other(&state, &agent_id, &agent),
1076
+ "other-agent collision checks must also include tmux_socket"
1077
+ );
1078
+ }
1079
+ }
@@ -587,8 +587,10 @@ fn resolve_inject_target(
587
587
  // is in lease.rs (E51 guard #1) which prevents the conflation; this is the
588
588
  // defence-in-depth in the delivery layer.
589
589
  if let Some(pane) = cached_pane.as_ref() {
590
- let leader_pane = leader_receiver_pane_id(state);
591
- if leader_pane.is_some_and(|lp| lp == pane.as_str()) {
590
+ let worker_socket = worker_tmux_socket(state, agent, transport);
591
+ if leader_receiver_pane_binding(state).is_some_and(|leader| {
592
+ pane_conflicts_on_same_socket(pane.as_str(), worker_socket.as_deref(), leader)
593
+ }) {
592
594
  return Target::SessionWindow {
593
595
  session: SessionName::new(session),
594
596
  window: WindowName::new(format!("{recipient}_pane_conflicts_with_leader")),
@@ -639,6 +641,57 @@ fn cached_pane_known_dead(transport: &dyn Transport, pane: &PaneId) -> bool {
639
641
  matches!(transport.liveness(pane), Ok(PaneLiveness::Dead))
640
642
  }
641
643
 
644
+ #[derive(Clone, Copy)]
645
+ struct PaneSocketBinding<'a> {
646
+ pane_id: &'a str,
647
+ tmux_socket: Option<&'a str>,
648
+ }
649
+
650
+ fn pane_conflicts_on_same_socket(
651
+ pane_id: &str,
652
+ worker_socket: Option<&str>,
653
+ leader: PaneSocketBinding<'_>,
654
+ ) -> bool {
655
+ leader.pane_id == pane_id
656
+ && !tmux_sockets_known_different(worker_socket, leader.tmux_socket)
657
+ }
658
+
659
+ fn tmux_sockets_known_different(left: Option<&str>, right: Option<&str>) -> bool {
660
+ let (Some(left), Some(right)) = (left, right) else {
661
+ return false;
662
+ };
663
+ if left == right {
664
+ return false;
665
+ }
666
+ std::path::Path::new(left).is_absolute() && std::path::Path::new(right).is_absolute()
667
+ }
668
+
669
+ fn worker_tmux_socket(
670
+ state: &serde_json::Value,
671
+ agent: Option<&serde_json::Value>,
672
+ transport: &dyn Transport,
673
+ ) -> Option<String> {
674
+ agent
675
+ .and_then(tmux_socket_field)
676
+ .or_else(|| runtime_tmux_socket(state))
677
+ .map(str::to_string)
678
+ .or_else(|| transport.tmux_endpoint())
679
+ }
680
+
681
+ fn runtime_tmux_socket(state: &serde_json::Value) -> Option<&str> {
682
+ tmux_socket_field(state)
683
+ .or_else(|| active_team_entry(state).and_then(tmux_socket_field))
684
+ .or_else(|| only_team_entry(state).and_then(tmux_socket_field))
685
+ }
686
+
687
+ fn tmux_socket_field(value: &serde_json::Value) -> Option<&str> {
688
+ value
689
+ .get("tmux_endpoint")
690
+ .or_else(|| value.get("tmux_socket"))
691
+ .and_then(serde_json::Value::as_str)
692
+ .filter(|value| !value.is_empty())
693
+ }
694
+
642
695
  /// Read the bound leader pane id off the projected or team-scoped runtime state.
643
696
  fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
644
697
  leader_receiver_pane_id_in_state(state)
@@ -646,6 +699,12 @@ fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
646
699
  .or_else(|| only_team_entry(state).and_then(leader_receiver_pane_id_in_state))
647
700
  }
648
701
 
702
+ fn leader_receiver_pane_binding(state: &serde_json::Value) -> Option<PaneSocketBinding<'_>> {
703
+ leader_receiver_pane_binding_in_state(state)
704
+ .or_else(|| active_team_entry(state).and_then(leader_receiver_pane_binding_in_state))
705
+ .or_else(|| only_team_entry(state).and_then(leader_receiver_pane_binding_in_state))
706
+ }
707
+
649
708
  /// `state` is "usable" for leader delivery when the bound `leader_receiver.pane_id`
650
709
  /// is present and alive (in `live_targets` or `liveness` probe returns not-Dead).
651
710
  ///
@@ -761,6 +820,25 @@ fn leader_receiver_pane_id_in_state(state: &serde_json::Value) -> Option<&str> {
761
820
  })
762
821
  }
763
822
 
823
+ fn leader_receiver_pane_binding_in_state(
824
+ state: &serde_json::Value,
825
+ ) -> Option<PaneSocketBinding<'_>> {
826
+ ["leader_receiver", "team_owner"]
827
+ .into_iter()
828
+ .find_map(|key| state.get(key).and_then(pane_socket_binding))
829
+ }
830
+
831
+ fn pane_socket_binding(value: &serde_json::Value) -> Option<PaneSocketBinding<'_>> {
832
+ let pane_id = value
833
+ .get("pane_id")
834
+ .and_then(serde_json::Value::as_str)
835
+ .filter(|s| !s.is_empty() && *s != "__team_agent_unbound__")?;
836
+ Some(PaneSocketBinding {
837
+ pane_id,
838
+ tmux_socket: tmux_socket_field(value),
839
+ })
840
+ }
841
+
764
842
  fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
765
843
  leader_receiver_field(state, "tmux_socket")
766
844
  }
@@ -1812,6 +1812,88 @@ fn u1_resolve_inject_target_uses_live_pane_when_cached_pane_drifted() {
1812
1812
  );
1813
1813
  }
1814
1814
 
1815
+ #[test]
1816
+ fn e51_delivery_allows_same_pane_id_on_different_tmux_sockets() {
1817
+ let ws = tmp_ws("e51diffsocket");
1818
+ let store = store_for(&ws);
1819
+ let log = EventLog::new(&ws);
1820
+ let state = serde_json::json!({
1821
+ "session_name": "team-e51",
1822
+ "tmux_endpoint": "/private/tmp/tmux-501/ta-worker",
1823
+ "leader_receiver": {
1824
+ "pane_id": "%0",
1825
+ "tmux_socket": "/private/tmp/tmux-501/default"
1826
+ },
1827
+ "agents": {
1828
+ "architect": {
1829
+ "provider": "fake",
1830
+ "pane_id": "%0",
1831
+ "window": "architect"
1832
+ }
1833
+ }
1834
+ });
1835
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
1836
+ let _ = store
1837
+ .create_message(None, "peer", "architect", "socket-aware", None, false, None)
1838
+ .unwrap();
1839
+ let transport = OfflineTransport::new()
1840
+ .with_tmux_endpoint("/private/tmp/tmux-501/ta-worker")
1841
+ .with_targets(vec![pane_info("%0", "team-e51", "architect")]);
1842
+
1843
+ let delivered = deliver_pending_messages(&ws, &state, &transport, &log)
1844
+ .expect("same pane id on different sockets must not poison delivery");
1845
+
1846
+ assert_eq!(delivered.len(), 1);
1847
+ assert_eq!(
1848
+ transport.inject_targets(),
1849
+ vec![Target::Pane(PaneId::new("%0"))],
1850
+ "E51 must compare pane_id with tmux_socket; different sockets are not a leader conflict"
1851
+ );
1852
+ }
1853
+
1854
+ #[test]
1855
+ fn e51_delivery_keeps_same_socket_pane_conflict_guard() {
1856
+ let ws = tmp_ws("e51samesocket");
1857
+ let store = store_for(&ws);
1858
+ let log = EventLog::new(&ws);
1859
+ let socket = "/private/tmp/tmux-501/default";
1860
+ let state = serde_json::json!({
1861
+ "session_name": "team-e51",
1862
+ "tmux_endpoint": socket,
1863
+ "leader_receiver": {
1864
+ "pane_id": "%0",
1865
+ "tmux_socket": socket
1866
+ },
1867
+ "agents": {
1868
+ "architect": {
1869
+ "provider": "fake",
1870
+ "pane_id": "%0",
1871
+ "window": "architect"
1872
+ }
1873
+ }
1874
+ });
1875
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
1876
+ let _ = store
1877
+ .create_message(None, "peer", "architect", "same-socket", None, false, None)
1878
+ .unwrap();
1879
+ let transport = OfflineTransport::new()
1880
+ .with_tmux_endpoint(socket)
1881
+ .with_targets(vec![pane_info("%0", "team-e51", "architect")]);
1882
+
1883
+ let delivered = deliver_pending_messages(&ws, &state, &transport, &log)
1884
+ .expect("same-socket E51 guard should still resolve to a structured target");
1885
+
1886
+ assert_eq!(delivered.len(), 1);
1887
+ assert_eq!(
1888
+ transport.inject_targets(),
1889
+ vec![Target::SessionWindow {
1890
+ session: SessionName::new("team-e51"),
1891
+ window: WindowName::new("architect_pane_conflicts_with_leader"),
1892
+ }],
1893
+ "same pane id on the same socket must keep the E51 loop guard"
1894
+ );
1895
+ }
1896
+
1815
1897
  #[test]
1816
1898
  fn u1_multi_team_send_does_not_backfill_top_level_leader_binding() {
1817
1899
  let ws = tmp_ws("u1backfill");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.36",
3
+ "version": "0.3.37",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.3.36",
24
- "@team-agent/cli-darwin-x64": "0.3.36",
25
- "@team-agent/cli-linux-x64": "0.3.36"
23
+ "@team-agent/cli-darwin-arm64": "0.3.37",
24
+ "@team-agent/cli-darwin-x64": "0.3.37",
25
+ "@team-agent/cli-linux-x64": "0.3.37"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",