@team-agent/installer 0.5.34 → 0.5.36

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.
@@ -223,7 +223,22 @@ pub fn execute_leader_plan(
223
223
  && !std::io::stdin().is_terminal()
224
224
  && insert_detach_flag(&mut argv);
225
225
  if plan.mode == LeaderStartMode::ExecProvider && !plan.is_external_leader {
226
- persist_exec_provider_leader_binding(plan, workspace)?;
226
+ // 0.5.35 (`.team/artifacts/managed-leader-provider-reentry-locate.md`
227
+ // §5/§6): three-way classify BEFORE persist. Same physical pane =
228
+ // provider process replacement (refresh only). Different pane with
229
+ // an existing canonical binding = no canonical rewrite (claim /
230
+ // takeover is the recovery path, §7). Unbound = first-time write.
231
+ match classify_exec_provider_binding(workspace, plan)? {
232
+ ExecProviderBinding::ManagedReentry => {
233
+ refresh_managed_leader_provider_binding(plan, workspace)?;
234
+ }
235
+ ExecProviderBinding::DifferentPaneAlreadyBound => {
236
+ // No canonical state write. Provider still runs below.
237
+ }
238
+ ExecProviderBinding::Unbound => {
239
+ persist_exec_provider_leader_binding(plan, workspace)?;
240
+ }
241
+ }
227
242
  } else if plan.is_external_leader {
228
243
  persist_external_leader_topology_marker(plan, workspace)?;
229
244
  }
@@ -904,6 +919,184 @@ fn persist_exec_provider_leader_binding(
904
919
  Ok(())
905
920
  }
906
921
 
922
+ /// 0.5.35 (`.team/artifacts/managed-leader-provider-reentry-locate.md` §5/§6):
923
+ /// three-way classification of an in-tmux ExecProvider invocation. Physical-
924
+ /// channel-first — never consults `pane_current_command` or gates on
925
+ /// `leader_session_uuid` equality (§6 forbids uuid-mismatch hard refusal).
926
+ enum ExecProviderBinding {
927
+ ManagedReentry,
928
+ DifferentPaneAlreadyBound,
929
+ Unbound,
930
+ }
931
+
932
+ fn classify_exec_provider_binding(
933
+ workspace: &Path,
934
+ plan: &LeaderStartPlan,
935
+ ) -> Result<ExecProviderBinding, LeaderError> {
936
+ let Some(pane) = std::env::var("TMUX_PANE")
937
+ .ok()
938
+ .filter(|value| !value.trim().is_empty())
939
+ else {
940
+ return Ok(ExecProviderBinding::Unbound);
941
+ };
942
+ let state = match crate::state::persist::load_runtime_state(workspace) {
943
+ Ok(state) => state,
944
+ Err(_) => return Ok(ExecProviderBinding::Unbound),
945
+ };
946
+ let team_key = plan
947
+ .identity
948
+ .as_ref()
949
+ .map(|identity| identity.team_id.as_str().to_string())
950
+ .or_else(|| {
951
+ state
952
+ .get("active_team_key")
953
+ .and_then(serde_json::Value::as_str)
954
+ .filter(|s| !s.is_empty())
955
+ .map(str::to_string)
956
+ });
957
+ let Some(team_key) = team_key else {
958
+ return Ok(ExecProviderBinding::Unbound);
959
+ };
960
+ let Some(receiver) = state
961
+ .pointer(&format!("/teams/{team_key}/leader_receiver"))
962
+ .and_then(serde_json::Value::as_object)
963
+ else {
964
+ return Ok(ExecProviderBinding::Unbound);
965
+ };
966
+ let receiver_pane = receiver
967
+ .get("pane_id")
968
+ .or_else(|| receiver.get("pane"))
969
+ .and_then(serde_json::Value::as_str)
970
+ .unwrap_or("");
971
+ if receiver_pane.is_empty() {
972
+ return Ok(ExecProviderBinding::Unbound);
973
+ }
974
+ if receiver_pane != pane {
975
+ return Ok(ExecProviderBinding::DifferentPaneAlreadyBound);
976
+ }
977
+ let pane_id = PaneId::new(pane.clone());
978
+ let target = current_tmux_pane_info(&pane_id);
979
+ let leader_prefixed_session = target
980
+ .as_ref()
981
+ .map(|t| t.session.as_str().starts_with(LEADER_SESSION_PREFIX))
982
+ .unwrap_or(false);
983
+ if !leader_prefixed_session {
984
+ return Ok(ExecProviderBinding::DifferentPaneAlreadyBound);
985
+ }
986
+ let recorded_socket = receiver
987
+ .get("tmux_socket")
988
+ .and_then(serde_json::Value::as_str)
989
+ .filter(|s| !s.is_empty());
990
+ if let Some(recorded_socket) = recorded_socket {
991
+ let caller_socket = crate::tmux_backend::socket_name_from_tmux_env();
992
+ if caller_socket.as_deref() != Some(recorded_socket) {
993
+ return Ok(ExecProviderBinding::DifferentPaneAlreadyBound);
994
+ }
995
+ }
996
+ Ok(ExecProviderBinding::ManagedReentry)
997
+ }
998
+
999
+ /// 0.5.35: same-pane provider re-entry refresh path. Preserves canonical
1000
+ /// team runtime identity (`session_name`/`team_dir`/`spec_path`/`agents`/
1001
+ /// `tasks`) and epoch non-regressively; updates only diagnostic receiver
1002
+ /// fields. Stage 3 strip preserved (root `team_owner`/`leader_receiver`/
1003
+ /// `owner_epoch` are NOT reintroduced — writes go through
1004
+ /// `state::ownership::write_owner` into the canonical teams.<key> slot).
1005
+ fn refresh_managed_leader_provider_binding(
1006
+ plan: &LeaderStartPlan,
1007
+ workspace: &Path,
1008
+ ) -> Result<(), LeaderError> {
1009
+ let identity = plan
1010
+ .identity
1011
+ .as_ref()
1012
+ .ok_or_else(|| LeaderError::Start("managed leader re-entry identity missing".to_string()))?;
1013
+ let pane = std::env::var("TMUX_PANE")
1014
+ .ok()
1015
+ .filter(|value| !value.trim().is_empty())
1016
+ .ok_or_else(|| LeaderError::Start("managed leader re-entry pane missing".to_string()))?;
1017
+ let pane_id = PaneId::new(pane.clone());
1018
+ let target = current_tmux_pane_info(&pane_id);
1019
+ let mut state = crate::state::persist::load_runtime_state(workspace)
1020
+ .unwrap_or_else(|_| serde_json::json!({}));
1021
+ let team_key = identity.team_id.as_str();
1022
+ let existing_receiver = state
1023
+ .pointer(&format!("/teams/{team_key}/leader_receiver"))
1024
+ .cloned()
1025
+ .unwrap_or_else(|| serde_json::json!({}));
1026
+ let existing_owner = crate::state::ownership::read_owner_value(&state, team_key)
1027
+ .cloned()
1028
+ .unwrap_or_else(|| serde_json::json!({}));
1029
+ let existing_epoch = state
1030
+ .pointer(&format!("/teams/{team_key}/owner_epoch"))
1031
+ .and_then(serde_json::Value::as_u64)
1032
+ .or_else(|| existing_owner.get("owner_epoch").and_then(serde_json::Value::as_u64))
1033
+ .or_else(|| existing_receiver.get("owner_epoch").and_then(serde_json::Value::as_u64))
1034
+ .unwrap_or(0);
1035
+ let now = chrono::Utc::now().to_rfc3339();
1036
+ let provider = serde_json::to_value(plan.provider)?;
1037
+ let mut receiver = match existing_receiver.as_object() {
1038
+ Some(map) => serde_json::Value::Object(map.clone()),
1039
+ None => serde_json::json!({}),
1040
+ };
1041
+ if let Some(obj) = receiver.as_object_mut() {
1042
+ obj.insert("mode".to_string(), serde_json::json!("direct_tmux"));
1043
+ obj.insert("status".to_string(), serde_json::json!("attached"));
1044
+ obj.insert("provider".to_string(), provider.clone());
1045
+ obj.insert("pane_id".to_string(), serde_json::json!(pane));
1046
+ obj.insert("pane".to_string(), serde_json::json!(pane));
1047
+ obj.insert("attached_at".to_string(), serde_json::json!(now.clone()));
1048
+ obj.insert("discovery".to_string(), serde_json::json!("managed_leader"));
1049
+ obj.insert("owner_epoch".to_string(), serde_json::json!(existing_epoch));
1050
+ obj.entry("leader_session_uuid".to_string())
1051
+ .or_insert_with(|| serde_json::json!(identity.leader_session_uuid.as_str()));
1052
+ if let Some(target) = target.as_ref() {
1053
+ obj.insert("session_name".to_string(), serde_json::json!(target.session.as_str()));
1054
+ if let Some(window_name) = target.window_name.as_ref() {
1055
+ obj.insert("window_name".to_string(), serde_json::json!(window_name.as_str()));
1056
+ }
1057
+ if let Some(pane_pid) = target.pane_pid {
1058
+ obj.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
1059
+ }
1060
+ }
1061
+ }
1062
+ let mut owner = match existing_owner.as_object() {
1063
+ Some(map) => serde_json::Value::Object(map.clone()),
1064
+ None => serde_json::json!({}),
1065
+ };
1066
+ if let Some(obj) = owner.as_object_mut() {
1067
+ obj.insert("pane_id".to_string(), serde_json::json!(pane));
1068
+ obj.insert("provider".to_string(), provider);
1069
+ obj.insert("owner_epoch".to_string(), serde_json::json!(existing_epoch));
1070
+ obj.entry("machine_fingerprint".to_string())
1071
+ .or_insert_with(|| serde_json::json!(identity.machine_fingerprint.as_str()));
1072
+ obj.entry("leader_session_uuid".to_string())
1073
+ .or_insert_with(|| serde_json::json!(identity.leader_session_uuid.as_str()));
1074
+ obj.entry("claimed_via".to_string())
1075
+ .or_insert_with(|| serde_json::json!("claim-leader"));
1076
+ obj.entry("os_user".to_string())
1077
+ .or_insert_with(|| serde_json::json!(identity.os_user.as_str()));
1078
+ }
1079
+ if let Some(obj) = state.as_object_mut() {
1080
+ obj.insert("active_team_key".to_string(), serde_json::json!(team_key));
1081
+ obj.insert(
1082
+ "leader_client".to_string(),
1083
+ serde_json::json!({
1084
+ "diagnostic_only": true,
1085
+ "attach_mode": "exec-provider",
1086
+ "tmux": std::env::var("TMUX").ok(),
1087
+ "reentry": true,
1088
+ }),
1089
+ );
1090
+ }
1091
+ let record = crate::state::ownership::OwnershipWrite::new()
1092
+ .with_leader_receiver(receiver)
1093
+ .with_team_owner(owner)
1094
+ .with_owner_epoch(existing_epoch);
1095
+ crate::state::ownership::write_owner(&mut state, team_key, record);
1096
+ crate::state::persist::save_runtime_state(workspace, &state)?;
1097
+ Ok(())
1098
+ }
1099
+
907
1100
  fn current_tmux_pane_info(pane_id: &PaneId) -> Option<crate::transport::PaneInfo> {
908
1101
  tmux_transport_for_current_pane()
909
1102
  .list_targets()
@@ -795,6 +795,71 @@ pub fn stop_agent_with_transport(
795
795
  )
796
796
  }
797
797
 
798
+ /// 0.5.36 (`.team/artifacts/supermarket-api-error-recovery-locate.md` §7.3/§7.4):
799
+ /// api_error recovery entry point. Runs post-atomic_save from coordinator
800
+ /// tick to replace the stuck provider process on a retryable outage. It
801
+ /// resolves lifecycle paths, force-stops the live pane if the worker is
802
+ /// still alive (so `start_agent_at_paths(force=true)` cannot be a Noop —
803
+ /// see R3 contract), then starts the agent with `allow_fresh=false` to
804
+ /// preserve session context. Returns a small, typed outcome so the
805
+ /// caller only needs the delta for event emission; the underlying
806
+ /// lifecycle helpers own their state saves.
807
+ pub(crate) fn start_agent_at_paths_for_recovery(
808
+ workspace: &Path,
809
+ agent_id: &AgentId,
810
+ team: Option<&str>,
811
+ transport: &dyn crate::transport::Transport,
812
+ ) -> Result<
813
+ crate::coordinator::steps::abnormal::RecoveryLifecycleOutcome,
814
+ crate::coordinator::steps::abnormal::RecoveryError,
815
+ > {
816
+ use crate::coordinator::steps::abnormal::{RecoveryError, RecoveryLifecycleOutcome};
817
+ let paths = match lifecycle_paths(workspace, team) {
818
+ Ok(paths) => paths,
819
+ Err(_) if team.is_none() => LifecyclePaths {
820
+ run_workspace: workspace.to_path_buf(),
821
+ spec_workspace: workspace.to_path_buf(),
822
+ },
823
+ Err(error) => return Err(RecoveryError::Lifecycle(error.to_string())),
824
+ };
825
+ // Best-effort stop: if the live pane still exists we tear it down so the
826
+ // subsequent start creates a fresh provider process instead of a Noop.
827
+ // Stop errors are absorbed into the start attempt result — the important
828
+ // invariant is that a successful start returns Running, never Noop.
829
+ let _ = stop_agent_with_transport(
830
+ &paths.run_workspace,
831
+ agent_id,
832
+ team,
833
+ transport,
834
+ );
835
+ let start_result = start_agent_with_transport(
836
+ &paths.run_workspace,
837
+ agent_id,
838
+ /* force */ true,
839
+ /* open_display */ false,
840
+ /* allow_fresh */ false,
841
+ team,
842
+ transport,
843
+ );
844
+ match start_result {
845
+ Ok(StartAgentOutcome::Running {
846
+ start_mode,
847
+ target,
848
+ env,
849
+ ..
850
+ }) => Ok(RecoveryLifecycleOutcome {
851
+ start_mode: format!("{:?}", start_mode),
852
+ target,
853
+ coordinator_started: env.coordinator_started,
854
+ }),
855
+ Ok(StartAgentOutcome::Noop { .. }) => Err(RecoveryError::NoopBlocked),
856
+ Ok(StartAgentOutcome::Paused { .. }) => Err(RecoveryError::Lifecycle(
857
+ "agent is paused; recovery skipped".to_string(),
858
+ )),
859
+ Err(error) => Err(RecoveryError::Lifecycle(error.to_string())),
860
+ }
861
+ }
862
+
798
863
  pub(super) fn stop_agent_at_paths(
799
864
  workspace: &Path,
800
865
  spec_workspace: &Path,
@@ -33,6 +33,10 @@ mod selection;
33
33
  mod team_state;
34
34
 
35
35
  pub(crate) use agent::start_agent_at_paths;
36
+ // 0.5.36 supermarket api_error recovery: coordinator post-save step calls
37
+ // this to replace a stuck provider process without going through the
38
+ // public start-agent CLI path.
39
+ pub(crate) use agent::start_agent_at_paths_for_recovery;
36
40
  pub use agent::{
37
41
  reset_agent, reset_agent_with_transport, start_agent, start_agent_with_transport, stop_agent,
38
42
  stop_agent_with_transport,
@@ -168,6 +168,14 @@ pub enum StateWriteIntent<'a> {
168
168
  CoordinatorConptyShim {
169
169
  team_key: Option<&'a str>,
170
170
  },
171
+ /// 0.5.36 (`.team/artifacts/supermarket-api-error-recovery-locate.md` §7.3):
172
+ /// post-save recovery step writes back the recovery intent outcome
173
+ /// (attempts / status / last_error / blocked_reason). Distinct intent
174
+ /// so future S1b migration can route it deliberately.
175
+ CoordinatorApiErrorRecovery {
176
+ team_key: Option<&'a str>,
177
+ agent_id: &'a str,
178
+ },
171
179
  McpAssignTask {
172
180
  team_key: Option<&'a str>,
173
181
  task_id: &'a str,
@@ -311,6 +319,13 @@ fn route_direct(
311
319
  // CoordinatorConptyShim -> root save
312
320
  // (coordinator/conpty_shim.rs:426/674).
313
321
  StateWriteIntent::CoordinatorConptyShim { .. } => helper_write_root(workspace, state),
322
+ // 0.5.36 CoordinatorApiErrorRecovery -> root save. The recovery
323
+ // intent lives under `coordinator.abnormal_api_error_recovery`, a
324
+ // root-scoped bookkeeping namespace that must survive across team
325
+ // boundaries; use the same helper family as CoordinatorConptyShim.
326
+ StateWriteIntent::CoordinatorApiErrorRecovery { .. } => {
327
+ helper_write_root(workspace, state)
328
+ }
314
329
  // McpAssignTask uses the reapply variant today; direct save routes
315
330
  // to the root helper for parity.
316
331
  StateWriteIntent::McpAssignTask { .. } => helper_write_root(workspace, state),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.34",
3
+ "version": "0.5.36",
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.5.34",
24
- "@team-agent/cli-darwin-x64": "0.5.34",
25
- "@team-agent/cli-linux-x64": "0.5.34"
23
+ "@team-agent/cli-darwin-arm64": "0.5.36",
24
+ "@team-agent/cli-darwin-x64": "0.5.36",
25
+ "@team-agent/cli-linux-x64": "0.5.36"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",