@team-agent/installer 0.5.70 → 0.5.72

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.
@@ -30,12 +30,302 @@ use crate::model::ids::AgentId;
30
30
  use crate::model::permissions::{self, AgentPermissionInput};
31
31
  use crate::model::yaml::{self, Value};
32
32
  use crate::state::persist::load_runtime_state;
33
- use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
33
+ use crate::transport::{PaneField, PaneId, SessionName, Target, Transport, WindowName};
34
34
 
35
35
  use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
36
36
 
37
37
  use super::*;
38
38
 
39
+ trait FreshQuickStartLeaderBindingOps {
40
+ fn caller_pane(&mut self) -> Option<String>;
41
+ fn explicit_provider(&mut self) -> Option<String>;
42
+ fn tmux_endpoint(&mut self) -> Option<String>;
43
+ fn observe_command(&mut self, pane: &PaneId) -> Option<String>;
44
+ fn live_binding_elsewhere(&mut self, pane: &PaneId, workspace: &Path, team_key: &str) -> bool;
45
+ fn attach(
46
+ &mut self,
47
+ workspace: &Path,
48
+ state: &mut serde_json::Value,
49
+ pane: &PaneId,
50
+ provider: crate::provider::Provider,
51
+ ) -> bool;
52
+ fn register(&mut self, workspace: &Path, team_key: &str) -> bool;
53
+ fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool;
54
+ }
55
+
56
+ struct RuntimeFreshQuickStartLeaderBindingOps<'a> {
57
+ transport: &'a dyn Transport,
58
+ }
59
+
60
+ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<'_> {
61
+ fn caller_pane(&mut self) -> Option<String> {
62
+ std::env::var("TMUX_PANE")
63
+ .ok()
64
+ .filter(|pane| !pane.is_empty())
65
+ }
66
+
67
+ fn explicit_provider(&mut self) -> Option<String> {
68
+ std::env::var("TEAM_AGENT_LEADER_PROVIDER")
69
+ .ok()
70
+ .filter(|provider| !provider.is_empty())
71
+ }
72
+
73
+ fn tmux_endpoint(&mut self) -> Option<String> {
74
+ self.transport.tmux_endpoint()
75
+ }
76
+
77
+ fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
78
+ self.transport
79
+ .query(&Target::Pane(pane.clone()), PaneField::PaneCurrentCommand)
80
+ .ok()
81
+ .flatten()
82
+ .filter(|command| !command.trim().is_empty())
83
+ }
84
+
85
+ fn live_binding_elsewhere(&mut self, pane: &PaneId, workspace: &Path, team_key: &str) -> bool {
86
+ crate::leader::registry::live_same_pane_binding_elsewhere(
87
+ pane.as_str(),
88
+ workspace,
89
+ team_key,
90
+ )
91
+ }
92
+
93
+ fn attach(
94
+ &mut self,
95
+ workspace: &Path,
96
+ state: &mut serde_json::Value,
97
+ pane: &PaneId,
98
+ provider: crate::provider::Provider,
99
+ ) -> bool {
100
+ let event_log = crate::event_log::EventLog::new(workspace);
101
+ crate::leader::attach_leader_to_state(
102
+ workspace,
103
+ state,
104
+ Some(pane),
105
+ provider,
106
+ &event_log,
107
+ crate::leader::LeaseSource::QuickStart,
108
+ true,
109
+ )
110
+ .is_ok()
111
+ }
112
+
113
+ fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
114
+ crate::leader::registry::register_binding_from_state_best_effort(
115
+ workspace,
116
+ Some(team_key),
117
+ "quick-start",
118
+ )
119
+ .is_some_and(|outcome| outcome.status == "registered" && outcome.path.is_some())
120
+ }
121
+
122
+ fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
123
+ launched_team_receiver_is_attached(workspace, team_key)
124
+ }
125
+ }
126
+
127
+ struct BindingFileSnapshot {
128
+ path: PathBuf,
129
+ bytes: Option<Vec<u8>>,
130
+ }
131
+
132
+ impl BindingFileSnapshot {
133
+ fn capture(path: PathBuf) -> Self {
134
+ let bytes = std::fs::read(&path).ok();
135
+ Self { path, bytes }
136
+ }
137
+
138
+ fn restore(&self) {
139
+ match &self.bytes {
140
+ Some(bytes) => {
141
+ let tmp = self.path.with_extension(format!(
142
+ "rollback-{}",
143
+ std::process::id()
144
+ ));
145
+ if std::fs::write(&tmp, bytes).is_ok()
146
+ && std::fs::rename(&tmp, &self.path).is_err()
147
+ {
148
+ let _ = std::fs::remove_file(tmp);
149
+ }
150
+ }
151
+ None => {
152
+ let _ = std::fs::remove_file(&self.path);
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ fn binding_snapshots(workspace: &Path, team_key: &str) -> Vec<BindingFileSnapshot> {
159
+ let mut snapshots = vec![BindingFileSnapshot::capture(
160
+ crate::state::persist::runtime_state_path(workspace),
161
+ )];
162
+ if let Some(dir) = crate::leader::registry::registry_dir() {
163
+ snapshots.push(BindingFileSnapshot::capture(dir.join(format!(
164
+ "{}__{team_key}.json",
165
+ crate::leader::registry::workspace_hash(workspace)
166
+ ))));
167
+ }
168
+ snapshots
169
+ }
170
+
171
+ fn same_workspace(left: &Path, right: &Path) -> bool {
172
+ std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
173
+ == std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
174
+ }
175
+
176
+ fn persisted_binding_matches_verified_pane(
177
+ state: &serde_json::Value,
178
+ workspace: &Path,
179
+ pane: &PaneId,
180
+ provider: crate::provider::Provider,
181
+ endpoint: Option<&str>,
182
+ ) -> bool {
183
+ let Some(endpoint) = endpoint.filter(|value| !value.is_empty()) else {
184
+ return false;
185
+ };
186
+ if state
187
+ .get("workspace")
188
+ .and_then(serde_json::Value::as_str)
189
+ .filter(|value| !value.is_empty())
190
+ .is_some_and(|value| !same_workspace(Path::new(value), workspace))
191
+ {
192
+ return false;
193
+ }
194
+ let Some(owner) = state.get("team_owner").and_then(serde_json::Value::as_object) else {
195
+ return false;
196
+ };
197
+ let Some(receiver) = state
198
+ .get("leader_receiver")
199
+ .and_then(serde_json::Value::as_object)
200
+ else {
201
+ return false;
202
+ };
203
+ let provider = serde_json::to_value(provider)
204
+ .ok()
205
+ .and_then(|value| value.as_str().map(str::to_string));
206
+ let Some(provider) = provider.as_deref() else {
207
+ return false;
208
+ };
209
+ let owner_epoch = owner.get("owner_epoch").and_then(serde_json::Value::as_u64);
210
+ let receiver_epoch = receiver
211
+ .get("owner_epoch")
212
+ .and_then(serde_json::Value::as_u64);
213
+ let owner_uuid = owner
214
+ .get("leader_session_uuid")
215
+ .and_then(serde_json::Value::as_str)
216
+ .filter(|value| !value.is_empty());
217
+ let receiver_uuid = receiver
218
+ .get("leader_session_uuid")
219
+ .and_then(serde_json::Value::as_str)
220
+ .filter(|value| !value.is_empty());
221
+ receiver.get("status").and_then(serde_json::Value::as_str) == Some("attached")
222
+ && receiver.get("mode").and_then(serde_json::Value::as_str) == Some("direct_tmux")
223
+ && owner.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane.as_str())
224
+ && receiver.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane.as_str())
225
+ && owner.get("provider").and_then(serde_json::Value::as_str) == Some(provider)
226
+ && receiver.get("provider").and_then(serde_json::Value::as_str) == Some(provider)
227
+ && owner_epoch.is_some_and(|epoch| epoch > 0)
228
+ && owner_epoch == receiver_epoch
229
+ && owner_uuid.is_some()
230
+ && owner_uuid == receiver_uuid
231
+ && receiver
232
+ .get("tmux_socket")
233
+ .and_then(serde_json::Value::as_str)
234
+ == Some(endpoint)
235
+ && receiver
236
+ .get("authorized_team_workspace")
237
+ .and_then(serde_json::Value::as_str)
238
+ .filter(|value| !value.is_empty())
239
+ .is_none_or(|value| same_workspace(Path::new(value), workspace))
240
+ }
241
+
242
+ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
243
+ workspace: &Path,
244
+ team_key: &str,
245
+ ops: &mut O,
246
+ ) -> bool {
247
+ let Ok(resolved) = crate::state::projection::resolve_runtime_team_scope(
248
+ workspace,
249
+ Some(team_key),
250
+ ) else {
251
+ return false;
252
+ };
253
+ if resolved.canonical_team_key != team_key {
254
+ return false;
255
+ }
256
+ let mut state = resolved.state;
257
+ let Some(pane) = ops.caller_pane().filter(|pane| !pane.is_empty()) else {
258
+ return false;
259
+ };
260
+ let pane = PaneId::new(pane);
261
+ let Some(command) = ops
262
+ .observe_command(&pane)
263
+ .filter(|command| !command.trim().is_empty())
264
+ else {
265
+ return false;
266
+ };
267
+ let explicit_provider = ops.explicit_provider();
268
+ let Some(provider) = crate::leader::owner_bind::strict_owner_bind_provider(
269
+ explicit_provider.as_deref(),
270
+ &command,
271
+ ) else {
272
+ return false;
273
+ };
274
+ if ops.live_binding_elsewhere(&pane, workspace, team_key) {
275
+ return false;
276
+ }
277
+ let endpoint = ops.tmux_endpoint();
278
+ // initial_runtime_state seeds the verified caller into the launched team's
279
+ // dual state before spawn. That is the transaction's candidate, not an
280
+ // already-owned-team refusal: it still needs the canonical registry commit.
281
+ let has_persisted_binding = ["team_owner", "leader_receiver"]
282
+ .iter()
283
+ .any(|key| state.get(*key).is_some_and(|value| !value.is_null()));
284
+ if has_persisted_binding
285
+ && !persisted_binding_matches_verified_pane(
286
+ &state,
287
+ workspace,
288
+ &pane,
289
+ provider,
290
+ endpoint.as_deref(),
291
+ )
292
+ {
293
+ return false;
294
+ }
295
+ // Registry publication is the commit point. Restore both persisted surfaces
296
+ // byte for byte when attach, publication, or canonical readback does not finish.
297
+ let snapshots = binding_snapshots(workspace, team_key);
298
+ let attached = has_persisted_binding || ops.attach(workspace, &mut state, &pane, provider);
299
+ let committed = attached
300
+ && ops.register(workspace, team_key)
301
+ && ops.canonical_readback(workspace, team_key);
302
+ if !committed {
303
+ for snapshot in snapshots.iter().rev() {
304
+ snapshot.restore();
305
+ }
306
+ }
307
+ committed
308
+ }
309
+
310
+ fn should_emit_workspace_socket_missing_hint(
311
+ selected_source: Option<&str>,
312
+ workspace_socket_missing: bool,
313
+ ) -> bool {
314
+ workspace_socket_missing && selected_source != Some("leader_env")
315
+ }
316
+
317
+ fn bind_fresh_quick_start_leader(
318
+ workspace: &Path,
319
+ team_key: &str,
320
+ transport: &dyn Transport,
321
+ ) -> bool {
322
+ bind_fresh_quick_start_leader_with(
323
+ workspace,
324
+ team_key,
325
+ &mut RuntimeFreshQuickStartLeaderBindingOps { transport },
326
+ )
327
+ }
328
+
39
329
  /// ---
40
330
  /// purpose: 由角色目录推出 workspace 后一键起队
41
331
  /// params:
@@ -281,23 +571,57 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display(
281
571
  transport: &dyn Transport,
282
572
  open_display: bool,
283
573
  ) -> Result<QuickStartReport, LifecycleError> {
284
- // B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
285
- // 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
286
- // owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
287
- // 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
288
- // 或修 env)/log(env var 名)。
289
- let team_workspace = team_workspace(agents_dir);
290
- let warning_workspaces = [workspace, team_workspace.as_path()];
291
- validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
574
+ let mut discover = |requested: &str| {
575
+ crate::lifecycle::launch::pi_mcp::pi_model_candidates(requested).map_err(|_| ())
576
+ };
577
+ quick_start_with_transport_in_workspace_with_display_pi_preflight(
578
+ workspace,
579
+ agents_dir,
580
+ name,
581
+ yes,
582
+ team_id,
583
+ transport,
584
+ open_display,
585
+ &mut discover,
586
+ )
587
+ }
588
+
589
+ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
590
+ workspace: &Path,
591
+ agents_dir: &Path,
592
+ name: Option<&str>,
593
+ yes: bool,
594
+ team_id: Option<&str>,
595
+ transport: &dyn Transport,
596
+ open_display: bool,
597
+ discover: &mut dyn FnMut(&str) -> Result<Vec<String>, ()>,
598
+ ) -> Result<QuickStartReport, LifecycleError> {
292
599
  if !agents_dir.exists() {
293
600
  return Err(LifecycleError::Compile(format!(
294
601
  "agents dir not found: {}",
295
602
  agents_dir.display()
296
603
  )));
297
604
  }
605
+ crate::compiler::preflight_pi_models_in_team_with(agents_dir, discover).map_err(|error| {
606
+ LifecycleError::PiModelPreflight {
607
+ requested: error.requested,
608
+ candidates: error.candidates,
609
+ action: error.action,
610
+ not_ready: error.not_ready,
611
+ }
612
+ })?;
298
613
  let workspace = workspace.to_path_buf();
299
614
  let mut spec = crate::compiler::compile_team(agents_dir)
300
615
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
616
+ // B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
617
+ // 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
618
+ // owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
619
+ // 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
620
+ // 或修 env)/log(env var 名)。 Role/schema and Pi model admission above is pure and
621
+ // must finish before this validation can emit a warning event.
622
+ let team_workspace = team_workspace(agents_dir);
623
+ let warning_workspaces = [workspace.as_path(), team_workspace.as_path()];
624
+ validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
301
625
  override_spec_workspace(&mut spec, &workspace);
302
626
  if !open_display {
303
627
  override_spec_display_backend(&mut spec, "none");
@@ -370,7 +694,12 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display(
370
694
  .to_string(),
371
695
  ];
372
696
  if session_name.is_some() {
373
- if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
697
+ if should_emit_workspace_socket_missing_hint(
698
+ state
699
+ .get("tmux_socket_source")
700
+ .and_then(serde_json::Value::as_str),
701
+ crate::tmux_backend::socket_probe_missing_for_workspace(&workspace),
702
+ ) {
374
703
  next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(
375
704
  &workspace,
376
705
  ));
@@ -446,8 +775,11 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display(
446
775
  team_depth.parent_team_key.as_deref(),
447
776
  team_depth.team_depth,
448
777
  )?;
778
+ // Fresh initialization owns this one fail-closed bind attempt. It is
779
+ // independent of display layout, so --no-display never suppresses receiver
780
+ // binding. Readiness receives true only after canonical registry readback.
449
781
  launch.leader_receiver_attached =
450
- launched_team_receiver_is_attached(&workspace, &state_team_key);
782
+ bind_fresh_quick_start_leader(&workspace, &state_team_key, transport);
451
783
  launch.session_capture_incomplete_agents =
452
784
  quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
453
785
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
@@ -498,7 +830,10 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display(
498
830
  already-initialised teams)"
499
831
  .to_string(),
500
832
  );
501
- if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
833
+ if should_emit_workspace_socket_missing_hint(
834
+ selected_tmux_socket_source(transport, &workspace),
835
+ crate::tmux_backend::socket_probe_missing_for_workspace(&workspace),
836
+ ) {
502
837
  next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(
503
838
  &workspace,
504
839
  ));
@@ -519,3 +854,515 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display(
519
854
  worker_readiness,
520
855
  })
521
856
  }
857
+
858
+ #[cfg(test)]
859
+ mod fresh_quick_start_leader_binding_tests {
860
+ use super::*;
861
+ use serde_json::json;
862
+ use std::sync::atomic::{AtomicU64, Ordering};
863
+
864
+ static SEQUENCE: AtomicU64 = AtomicU64::new(0);
865
+
866
+ fn workspace(tag: &str) -> PathBuf {
867
+ let path = std::env::temp_dir().join(format!(
868
+ "ta-quick-start-bind-{tag}-{}-{}",
869
+ std::process::id(),
870
+ SEQUENCE.fetch_add(1, Ordering::Relaxed)
871
+ ));
872
+ std::fs::create_dir_all(&path).unwrap();
873
+ crate::state::persist::save_runtime_state(
874
+ &path,
875
+ &json!({
876
+ "active_team_key": "fresh",
877
+ "team_key": "fresh",
878
+ "session_name": "team-fresh",
879
+ "agents": {"sol": {"status": "running", "provider": "pi"}}
880
+ }),
881
+ )
882
+ .unwrap();
883
+ path
884
+ }
885
+
886
+ struct MockOps {
887
+ pane: Option<String>,
888
+ explicit_provider: Option<String>,
889
+ endpoint: Option<String>,
890
+ command: Option<String>,
891
+ live_elsewhere: bool,
892
+ attach_ok: bool,
893
+ register_ok: bool,
894
+ readback_ok: bool,
895
+ attach_calls: usize,
896
+ register_calls: usize,
897
+ readback_calls: usize,
898
+ attached_provider: Option<crate::provider::Provider>,
899
+ }
900
+
901
+ impl Default for MockOps {
902
+ fn default() -> Self {
903
+ Self {
904
+ pane: Some("%42".to_string()),
905
+ explicit_provider: Some("pi".to_string()),
906
+ endpoint: Some("/private/tmp/tmux-test/default".to_string()),
907
+ command: Some("pi".to_string()),
908
+ live_elsewhere: false,
909
+ attach_ok: true,
910
+ register_ok: true,
911
+ readback_ok: true,
912
+ attach_calls: 0,
913
+ register_calls: 0,
914
+ readback_calls: 0,
915
+ attached_provider: None,
916
+ }
917
+ }
918
+ }
919
+
920
+ impl FreshQuickStartLeaderBindingOps for MockOps {
921
+ fn caller_pane(&mut self) -> Option<String> {
922
+ self.pane.clone()
923
+ }
924
+
925
+ fn explicit_provider(&mut self) -> Option<String> {
926
+ self.explicit_provider.clone()
927
+ }
928
+
929
+ fn tmux_endpoint(&mut self) -> Option<String> {
930
+ self.endpoint.clone()
931
+ }
932
+
933
+ fn observe_command(&mut self, _pane: &PaneId) -> Option<String> {
934
+ self.command.clone()
935
+ }
936
+
937
+ fn live_binding_elsewhere(
938
+ &mut self,
939
+ _pane: &PaneId,
940
+ _workspace: &Path,
941
+ _team_key: &str,
942
+ ) -> bool {
943
+ self.live_elsewhere
944
+ }
945
+
946
+ fn attach(
947
+ &mut self,
948
+ workspace: &Path,
949
+ state: &mut serde_json::Value,
950
+ pane: &PaneId,
951
+ provider: crate::provider::Provider,
952
+ ) -> bool {
953
+ self.attach_calls += 1;
954
+ self.attached_provider = Some(provider);
955
+ if !self.attach_ok {
956
+ state["failed_attach_mutation"] = json!(true);
957
+ let _ = crate::state::persist::save_runtime_state(workspace, state);
958
+ return false;
959
+ }
960
+ state["leader_receiver"] = json!({
961
+ "mode": "direct_tmux",
962
+ "pane_id": pane.as_str(),
963
+ "status": "attached",
964
+ "provider": "pi",
965
+ "leader_session_uuid": "uuid-fresh",
966
+ "owner_epoch": 1,
967
+ "tmux_socket": self.endpoint
968
+ });
969
+ state["team_owner"] = json!({
970
+ "pane_id": pane.as_str(),
971
+ "provider": "pi",
972
+ "leader_session_uuid": "uuid-fresh",
973
+ "owner_epoch": 1
974
+ });
975
+ crate::state::persist::save_runtime_state(workspace, state).is_ok()
976
+ }
977
+
978
+ fn register(&mut self, workspace: &Path, _team_key: &str) -> bool {
979
+ self.register_calls += 1;
980
+ let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
981
+ assert_eq!(
982
+ persisted
983
+ .pointer("/leader_receiver/pane_id")
984
+ .and_then(serde_json::Value::as_str),
985
+ Some("%42")
986
+ );
987
+ if !self.register_ok {
988
+ persisted["failed_registry_mutation"] = json!(true);
989
+ crate::state::persist::save_runtime_state(workspace, &persisted).unwrap();
990
+ }
991
+ self.register_ok
992
+ }
993
+
994
+ fn canonical_readback(&mut self, workspace: &Path, _team_key: &str) -> bool {
995
+ self.readback_calls += 1;
996
+ let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
997
+ if !self.readback_ok {
998
+ persisted["failed_readback_mutation"] = json!(true);
999
+ crate::state::persist::save_runtime_state(workspace, &persisted).unwrap();
1000
+ }
1001
+ self.readback_ok
1002
+ && persisted
1003
+ .get("leader_receiver")
1004
+ .is_some_and(|receiver| !receiver.is_null())
1005
+ }
1006
+ }
1007
+
1008
+ #[test]
1009
+ fn fresh_binding_persists_then_registers_then_requires_canonical_readback() {
1010
+ let workspace = workspace("positive");
1011
+ let mut ops = MockOps::default();
1012
+ assert!(bind_fresh_quick_start_leader_with(
1013
+ &workspace,
1014
+ "fresh",
1015
+ &mut ops
1016
+ ));
1017
+ assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
1018
+ assert_eq!(ops.attach_calls, 1);
1019
+ assert_eq!(ops.register_calls, 1);
1020
+ assert_eq!(ops.readback_calls, 1);
1021
+ let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
1022
+ assert_eq!(
1023
+ state
1024
+ .pointer("/leader_receiver/pane_id")
1025
+ .and_then(serde_json::Value::as_str),
1026
+ Some("%42")
1027
+ );
1028
+ }
1029
+
1030
+ #[test]
1031
+ fn every_pre_attach_refusal_is_byte_preserving_and_never_registers() {
1032
+ for case in [
1033
+ "missing_pane",
1034
+ "unverifiable_pane",
1035
+ "empty_command",
1036
+ "unknown_provider",
1037
+ "unknown_explicit_provider",
1038
+ "team_mismatch",
1039
+ "existing_owner",
1040
+ "existing_receiver",
1041
+ "provider_mismatch",
1042
+ "socket_mismatch",
1043
+ "workspace_mismatch",
1044
+ "dual_state_mismatch",
1045
+ "live_other_scope",
1046
+ ] {
1047
+ let workspace = workspace(case);
1048
+ let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
1049
+ let mut ops = MockOps::default();
1050
+ let team_key = if case == "team_mismatch" {
1051
+ "other"
1052
+ } else {
1053
+ "fresh"
1054
+ };
1055
+ match case {
1056
+ "missing_pane" => ops.pane = None,
1057
+ "unverifiable_pane" => ops.command = None,
1058
+ "empty_command" => ops.command = Some(" ".to_string()),
1059
+ "unknown_provider" => {
1060
+ ops.explicit_provider = None;
1061
+ ops.command = Some("node".to_string());
1062
+ }
1063
+ "unknown_explicit_provider" => {
1064
+ ops.explicit_provider = Some("unknown".to_string());
1065
+ ops.command = Some("codex".to_string());
1066
+ }
1067
+ "existing_owner" => state["team_owner"] = json!({"pane_id": "%old"}),
1068
+ "existing_receiver" => {
1069
+ state["leader_receiver"] = json!({"pane_id": "%old"})
1070
+ }
1071
+ "provider_mismatch" | "socket_mismatch" | "workspace_mismatch"
1072
+ | "dual_state_mismatch" => {
1073
+ state["workspace"] = json!(workspace);
1074
+ state["team_owner"] = json!({
1075
+ "pane_id": "%42", "provider": "pi",
1076
+ "leader_session_uuid": "uuid-fresh", "owner_epoch": 1
1077
+ });
1078
+ state["leader_receiver"] = json!({
1079
+ "mode": "direct_tmux", "status": "attached", "pane_id": "%42",
1080
+ "provider": "pi", "leader_session_uuid": "uuid-fresh", "owner_epoch": 1,
1081
+ "tmux_socket": "/private/tmp/tmux-test/default"
1082
+ });
1083
+ match case {
1084
+ "provider_mismatch" => state["leader_receiver"]["provider"] = json!("codex"),
1085
+ "socket_mismatch" => state["leader_receiver"]["tmux_socket"] = json!("/tmp/other"),
1086
+ "workspace_mismatch" => state["workspace"] = json!(workspace.join("other")),
1087
+ "dual_state_mismatch" => state["team_owner"]["owner_epoch"] = json!(2),
1088
+ _ => unreachable!(),
1089
+ }
1090
+ }
1091
+ "live_other_scope" => ops.live_elsewhere = true,
1092
+ "team_mismatch" => {}
1093
+ _ => unreachable!(),
1094
+ }
1095
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
1096
+ let before = std::fs::read(crate::state::persist::runtime_state_path(&workspace))
1097
+ .unwrap();
1098
+ assert!(
1099
+ !bind_fresh_quick_start_leader_with(&workspace, team_key, &mut ops),
1100
+ "{case} must refuse"
1101
+ );
1102
+ assert_eq!(
1103
+ before,
1104
+ std::fs::read(crate::state::persist::runtime_state_path(&workspace)).unwrap(),
1105
+ "{case} changed state bytes"
1106
+ );
1107
+ assert_eq!(ops.attach_calls, 0, "{case} reached attach");
1108
+ assert_eq!(ops.register_calls, 0, "{case} reached registry write");
1109
+ assert_eq!(ops.readback_calls, 0, "{case} reached readback");
1110
+ }
1111
+ }
1112
+
1113
+ struct HomeGuard(Option<std::ffi::OsString>);
1114
+
1115
+ impl HomeGuard {
1116
+ fn set(path: &Path) -> Self {
1117
+ let old = std::env::var_os("HOME");
1118
+ std::env::set_var("HOME", path);
1119
+ Self(old)
1120
+ }
1121
+ }
1122
+
1123
+ impl Drop for HomeGuard {
1124
+ fn drop(&mut self) {
1125
+ if let Some(value) = self.0.take() {
1126
+ std::env::set_var("HOME", value);
1127
+ } else {
1128
+ std::env::remove_var("HOME");
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ fn ambient_dual_state_workspace(tag: &str) -> PathBuf {
1134
+ let workspace = workspace(tag);
1135
+ let ambient = "/private/tmp/tmux-test/default";
1136
+ crate::state::persist::save_runtime_state(
1137
+ &workspace,
1138
+ &json!({
1139
+ "workspace": workspace,
1140
+ "active_team_key": "fresh",
1141
+ "team_key": "fresh",
1142
+ "session_name": "team-fresh",
1143
+ "agents": {"sol": {"status": "running", "provider": "pi"}},
1144
+ "teams": {
1145
+ "current": {
1146
+ "team_key": "current",
1147
+ "session_name": "team-parent",
1148
+ "agents": {}
1149
+ },
1150
+ "fresh": {
1151
+ "workspace": workspace,
1152
+ "team_key": "fresh",
1153
+ "session_name": "team-fresh",
1154
+ "agents": {"sol": {"status": "running", "provider": "pi"}},
1155
+ "team_owner": {
1156
+ "pane_id": "%42",
1157
+ "provider": "pi",
1158
+ "leader_session_uuid": "uuid-fresh",
1159
+ "owner_epoch": 1
1160
+ },
1161
+ "leader_receiver": {
1162
+ "mode": "direct_tmux",
1163
+ "status": "attached",
1164
+ "pane_id": "%42",
1165
+ "provider": "pi",
1166
+ "leader_session_uuid": "uuid-fresh",
1167
+ "owner_epoch": 1,
1168
+ "tmux_socket": ambient
1169
+ },
1170
+ "owner_epoch": 1
1171
+ }
1172
+ }
1173
+ }),
1174
+ )
1175
+ .unwrap();
1176
+ workspace
1177
+ }
1178
+
1179
+ struct AmbientRegistryOps {
1180
+ readback_ok: bool,
1181
+ attach_calls: usize,
1182
+ }
1183
+
1184
+ impl FreshQuickStartLeaderBindingOps for AmbientRegistryOps {
1185
+ fn caller_pane(&mut self) -> Option<String> {
1186
+ Some("%42".to_string())
1187
+ }
1188
+
1189
+ fn explicit_provider(&mut self) -> Option<String> {
1190
+ Some("pi".to_string())
1191
+ }
1192
+
1193
+ fn tmux_endpoint(&mut self) -> Option<String> {
1194
+ Some("/private/tmp/tmux-test/default".to_string())
1195
+ }
1196
+
1197
+ fn observe_command(&mut self, _pane: &PaneId) -> Option<String> {
1198
+ Some("pi".to_string())
1199
+ }
1200
+
1201
+ fn live_binding_elsewhere(
1202
+ &mut self,
1203
+ _pane: &PaneId,
1204
+ _workspace: &Path,
1205
+ _team_key: &str,
1206
+ ) -> bool {
1207
+ false
1208
+ }
1209
+
1210
+ fn attach(
1211
+ &mut self,
1212
+ _workspace: &Path,
1213
+ _state: &mut serde_json::Value,
1214
+ _pane: &PaneId,
1215
+ _provider: crate::provider::Provider,
1216
+ ) -> bool {
1217
+ self.attach_calls += 1;
1218
+ false
1219
+ }
1220
+
1221
+ fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
1222
+ crate::leader::registry::register_binding_from_state_best_effort(
1223
+ workspace,
1224
+ Some(team_key),
1225
+ "quick-start",
1226
+ )
1227
+ .is_some_and(|outcome| outcome.status == "registered" && outcome.path.is_some())
1228
+ }
1229
+
1230
+ fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
1231
+ self.readback_ok && launched_team_receiver_is_attached(workspace, team_key)
1232
+ }
1233
+ }
1234
+
1235
+ #[test]
1236
+ #[serial_test::serial(env)]
1237
+ fn ambient_dual_state_registers_and_reads_back_when_workspace_socket_is_missing() {
1238
+ let workspace = ambient_dual_state_workspace("ambient-dual-state");
1239
+ let home = workspace.join("home");
1240
+ std::fs::create_dir_all(&home).unwrap();
1241
+ let _home = HomeGuard::set(&home);
1242
+ assert!(crate::tmux_backend::socket_probe_missing_for_workspace(
1243
+ &workspace
1244
+ ));
1245
+ let mut ops = AmbientRegistryOps {
1246
+ readback_ok: true,
1247
+ attach_calls: 0,
1248
+ };
1249
+ assert!(bind_fresh_quick_start_leader_with(
1250
+ &workspace,
1251
+ "fresh",
1252
+ &mut ops
1253
+ ));
1254
+ assert_eq!(ops.attach_calls, 0, "preseeded dual state must not reattach");
1255
+ let path = crate::leader::registry::registry_dir().unwrap().join(format!(
1256
+ "{}__fresh.json",
1257
+ crate::leader::registry::workspace_hash(&workspace)
1258
+ ));
1259
+ let entry: crate::leader::registry::LeaderRegistryEntry =
1260
+ serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1261
+ assert_eq!(
1262
+ entry.channel.get("tmux_socket").and_then(serde_json::Value::as_str),
1263
+ Some("/private/tmp/tmux-test/default")
1264
+ );
1265
+ }
1266
+
1267
+ #[test]
1268
+ fn attach_registry_or_readback_failure_rolls_back_exact_state_bytes() {
1269
+ for (case, mut ops) in [
1270
+ (
1271
+ "attach-failure",
1272
+ MockOps {
1273
+ attach_ok: false,
1274
+ ..MockOps::default()
1275
+ },
1276
+ ),
1277
+ (
1278
+ "registry-failure",
1279
+ MockOps {
1280
+ register_ok: false,
1281
+ ..MockOps::default()
1282
+ },
1283
+ ),
1284
+ (
1285
+ "readback-failure",
1286
+ MockOps {
1287
+ readback_ok: false,
1288
+ ..MockOps::default()
1289
+ },
1290
+ ),
1291
+ ] {
1292
+ let workspace = workspace(case);
1293
+ let state_path = crate::state::persist::runtime_state_path(&workspace);
1294
+ let before = std::fs::read(&state_path).unwrap();
1295
+ assert!(!bind_fresh_quick_start_leader_with(
1296
+ &workspace,
1297
+ "fresh",
1298
+ &mut ops
1299
+ ));
1300
+ assert_eq!(
1301
+ std::fs::read(&state_path).unwrap(),
1302
+ before,
1303
+ "{case} left partial state bytes"
1304
+ );
1305
+ match case {
1306
+ "attach-failure" => {
1307
+ assert_eq!(ops.attach_calls, 1);
1308
+ assert_eq!(ops.register_calls, 0);
1309
+ assert_eq!(ops.readback_calls, 0);
1310
+ }
1311
+ "registry-failure" => {
1312
+ assert_eq!(ops.attach_calls, 1);
1313
+ assert_eq!(ops.register_calls, 1);
1314
+ assert_eq!(ops.readback_calls, 0);
1315
+ }
1316
+ "readback-failure" => {
1317
+ assert_eq!(ops.attach_calls, 1);
1318
+ assert_eq!(ops.register_calls, 1);
1319
+ assert_eq!(ops.readback_calls, 1);
1320
+ }
1321
+ _ => unreachable!(),
1322
+ }
1323
+ }
1324
+ }
1325
+
1326
+ #[test]
1327
+ fn socket_missing_hint_is_suppressed_only_for_selected_leader_env_transport() {
1328
+ assert!(!should_emit_workspace_socket_missing_hint(
1329
+ Some("leader_env"),
1330
+ true
1331
+ ));
1332
+ assert!(should_emit_workspace_socket_missing_hint(
1333
+ Some("workspace"),
1334
+ true
1335
+ ));
1336
+ assert!(should_emit_workspace_socket_missing_hint(None, true));
1337
+ assert!(!should_emit_workspace_socket_missing_hint(
1338
+ Some("workspace"),
1339
+ false
1340
+ ));
1341
+ }
1342
+
1343
+ #[test]
1344
+ #[serial_test::serial(env)]
1345
+ fn failed_canonical_readback_removes_new_registry_bytes_and_preserves_dual_state_bytes() {
1346
+ let workspace = ambient_dual_state_workspace("ambient-readback-rollback");
1347
+ let home = workspace.join("home");
1348
+ std::fs::create_dir_all(&home).unwrap();
1349
+ let _home = HomeGuard::set(&home);
1350
+ let state_path = crate::state::persist::runtime_state_path(&workspace);
1351
+ let before = std::fs::read(&state_path).unwrap();
1352
+ let registry_path = crate::leader::registry::registry_dir().unwrap().join(format!(
1353
+ "{}__fresh.json",
1354
+ crate::leader::registry::workspace_hash(&workspace)
1355
+ ));
1356
+ let mut ops = AmbientRegistryOps {
1357
+ readback_ok: false,
1358
+ attach_calls: 0,
1359
+ };
1360
+ assert!(!bind_fresh_quick_start_leader_with(
1361
+ &workspace,
1362
+ "fresh",
1363
+ &mut ops
1364
+ ));
1365
+ assert_eq!(std::fs::read(state_path).unwrap(), before);
1366
+ assert!(!registry_path.exists(), "failed readback left registry bytes");
1367
+ }
1368
+ }