@team-agent/installer 0.5.92 → 0.5.97

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 (32) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +381 -157
  4. package/crates/team-agent/src/cli/diagnose.rs +40 -10
  5. package/crates/team-agent/src/cli/emit.rs +6 -4
  6. package/crates/team-agent/src/cli/mod.rs +483 -470
  7. package/crates/team-agent/src/cli/models.rs +1 -0
  8. package/crates/team-agent/src/cli/spec.rs +2 -2
  9. package/crates/team-agent/src/cli/tests/base.rs +20 -10
  10. package/crates/team-agent/src/cli/tests/divergence.rs +12 -21
  11. package/crates/team-agent/src/cli/tests/main_preserved.rs +6 -1
  12. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +20 -11
  13. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +197 -30
  14. package/crates/team-agent/src/cli/triage.rs +31 -31
  15. package/crates/team-agent/src/cli/types.rs +3 -1
  16. package/crates/team-agent/src/coordinator/health.rs +92 -30
  17. package/crates/team-agent/src/coordinator/tick.rs +62 -8
  18. package/crates/team-agent/src/coordinator/types.rs +2 -0
  19. package/crates/team-agent/src/kill_audit.rs +41 -2
  20. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +48 -14
  21. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +4 -8
  22. package/crates/team-agent/src/lifecycle/launch.rs +17 -0
  23. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -0
  24. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +10 -4
  25. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +10 -7
  26. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +91 -4
  27. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +128 -11
  28. package/crates/team-agent/src/platform/process.rs +4 -45
  29. package/crates/team-agent/src/tmux_backend.rs +113 -3
  30. package/crates/team-agent/src/transport/test_support.rs +30 -0
  31. package/crates/team-agent/src/transport.rs +41 -0
  32. package/package.json +4 -4
@@ -5,7 +5,7 @@ use crate::cli::{
5
5
  SendArgs, StatusArgs,
6
6
  };
7
7
  use crate::transport::test_support::OfflineTransport;
8
- use crate::transport::WindowName;
8
+ use crate::transport::{PaneId, PaneInfo, SessionName, WindowName};
9
9
  use serde_json::{json, Map, Value};
10
10
  use std::collections::{BTreeMap, BTreeSet};
11
11
  use std::path::{Path, PathBuf};
@@ -229,7 +229,21 @@ fn run_phase_golden(spec: PhaseGolden) -> Value {
229
229
  let team = two_worker_team_dir(&hermetic);
230
230
  let workspace = team.parent().expect("workspace").to_path_buf();
231
231
  seed_healthy_coordinator(&workspace);
232
- let launch_transport = codex_ready_transport();
232
+ let _caller_pane = EnvVarGuard::set("TMUX_PANE", "%caller");
233
+ let launch_transport = codex_ready_transport().with_targets(vec![PaneInfo {
234
+ pane_id: PaneId::new("%caller"),
235
+ session: SessionName::new("caller"),
236
+ window_index: Some(0),
237
+ window_name: Some(WindowName::new("caller")),
238
+ pane_index: Some(0),
239
+ tty: None,
240
+ current_command: Some("codex".to_string()),
241
+ current_path: Some(workspace.clone()),
242
+ active: true,
243
+ pane_pid: None,
244
+ leader_env: BTreeMap::new(),
245
+ }])
246
+ .with_pane_current_command("%caller", "codex");
233
247
  let quick_start = quick_start_with_transport_in_workspace_with_display(
234
248
  &workspace,
235
249
  &team,
@@ -275,9 +289,49 @@ fn run_phase_golden(spec: PhaseGolden) -> Value {
275
289
  to_name: None,
276
290
  to_leader: None,
277
291
  });
292
+ let runtime_state = crate::state::persist::load_runtime_state(&workspace)
293
+ .expect("quick-start state written before shutdown fixture");
294
+ let generation = runtime_state
295
+ .get("generation")
296
+ .and_then(Value::as_str)
297
+ .or_else(|| {
298
+ runtime_state
299
+ .get("agents")
300
+ .and_then(Value::as_object)
301
+ .and_then(|agents| {
302
+ agents.values().find_map(|agent| {
303
+ agent.get("spawned_at").and_then(Value::as_str)
304
+ })
305
+ })
306
+ })
307
+ .or_else(|| {
308
+ runtime_state
309
+ .pointer("/teams/teamdir/agents")
310
+ .and_then(Value::as_object)
311
+ .and_then(|agents| {
312
+ agents.values().find_map(|agent| {
313
+ agent.get("spawned_at").and_then(Value::as_str)
314
+ })
315
+ })
316
+ })
317
+ .unwrap_or("phase-golden");
278
318
  let lifecycle_transport = codex_ready_transport()
279
319
  .with_session_present(true)
280
- .with_windows(vec![WindowName::new("w1"), WindowName::new("w2")]);
320
+ .with_windows(vec![WindowName::new("w1"), WindowName::new("w2")])
321
+ .with_targets(vec![PaneInfo {
322
+ pane_id: PaneId::new("%w1"),
323
+ session: SessionName::new("team-phasegolden"),
324
+ window_index: Some(0),
325
+ window_name: Some(WindowName::new("w1")),
326
+ pane_index: Some(0),
327
+ tty: None,
328
+ current_command: Some("codex".to_string()),
329
+ current_path: Some(workspace.clone()),
330
+ active: true,
331
+ pane_pid: None,
332
+ leader_env: BTreeMap::new(),
333
+ }])
334
+ .with_session_owner(&workspace, "teamdir", generation);
281
335
  let lifecycle = (spec.lifecycle_op)(&workspace, &lifecycle_transport, spec.team_key);
282
336
  let shutdown = lifecycle_port::shutdown_with_transport(
283
337
  &workspace,
@@ -660,9 +714,12 @@ fn normalize_value(value: Value, ctx: &mut NormalizeCtx, key: Option<&str>) -> V
660
714
 
661
715
  fn normalize_string(text: String, ctx: &mut NormalizeCtx, key: Option<&str>) -> Value {
662
716
  let key = key.unwrap_or_default();
663
- if key_is_timestamp(key) {
717
+ if key_is_timestamp(key) || key == "generation" {
664
718
  return json!("<TS>");
665
719
  }
720
+ if key == "workspace_hash" {
721
+ return json!("<ID>");
722
+ }
666
723
  if text == crate::packaging::Version::current().as_str() {
667
724
  return json!("<VERSION>");
668
725
  }
@@ -696,9 +753,39 @@ fn normalize_path_string(text: &str, ctx: &NormalizeCtx) -> String {
696
753
  out = out.replace(alias, "<TMP>");
697
754
  }
698
755
  out = normalize_tmux_socket_dir(&out);
756
+ out = normalize_hermetic_root(&out);
757
+ out = normalize_registry_hash(&out);
699
758
  normalize_socket_token(&out)
700
759
  }
701
760
 
761
+ fn normalize_registry_hash(text: &str) -> String {
762
+ let Some(start) = text.rfind("/leaders/") else {
763
+ return text.to_string();
764
+ };
765
+ let file_start = start + "/leaders/".len();
766
+ let Some(separator) = text[file_start..].find("__") else {
767
+ return text.to_string();
768
+ };
769
+ let separator = file_start + separator;
770
+ text[..file_start].to_owned() + "<ID>" + &text[separator..]
771
+ }
772
+
773
+ fn normalize_hermetic_root(text: &str) -> String {
774
+ let marker = "ta-phase-golden-";
775
+ let Some(start) = text.find(marker) else {
776
+ return text.to_string();
777
+ };
778
+ let end = text[start..]
779
+ .find('/')
780
+ .map(|offset| start + offset)
781
+ .unwrap_or(text.len());
782
+ let segment = &text[start..end];
783
+ let Some(phase) = text[start + marker.len()..end].split('-').next() else {
784
+ return text.to_string();
785
+ };
786
+ text.replacen(segment, &format!("{marker}{phase}"), 1)
787
+ }
788
+
702
789
  fn value_looks_like_endpoint_path(text: &str) -> bool {
703
790
  text.starts_with('/')
704
791
  || text.starts_with("ta-")
@@ -30,7 +30,7 @@ use team_agent::state::persist::{load_runtime_state, save_runtime_state};
30
30
  use team_agent::transport::{
31
31
  AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
32
32
  InjectStage, InjectVerification, Key, PaneField, PaneId, PaneInfo, PaneLiveness, SessionName,
33
- SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
33
+ SessionOwner, SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
34
34
  TurnVerification, WindowName,
35
35
  };
36
36
 
@@ -130,6 +130,95 @@ fn server_exited_during_replacement_spawn_does_not_cascade_session_disappeared_a
130
130
  );
131
131
  }
132
132
 
133
+ #[test]
134
+ #[serial(env)]
135
+ fn scoped_shutdown_after_restart_refreshes_owner_and_preserves_sibling() {
136
+ let case = RestartCase::new("restart-shutdown-owner", worker_ids(2), ProviderShape::Fake);
137
+ let transport = BuildBeforeDestroyTransport::recording(worker_ids(2));
138
+ for pane in transport.state.lock().unwrap().panes.values_mut() {
139
+ pane.current_path = Some(case.workspace.clone());
140
+ pane.pane_pid = None;
141
+ }
142
+ let session = SessionName::new(TEAM_SESSION);
143
+ let sibling_session = SessionName::new("team-sibling");
144
+ let mut state = case.read_state();
145
+ // Host and worker tmux servers may allocate the same pane ID. The host
146
+ // receiver must not protect the replacement worker on this transport.
147
+ state["leader_receiver"] = json!({
148
+ "pane_id": "%new-1", "tmux_socket": "/tmp/s4-host-only.sock"
149
+ });
150
+ state["team_owner"] = json!({"pane_id": "%new-1"});
151
+ state["teams"][TEAM]["leader_receiver"] = state["leader_receiver"].clone();
152
+ state["teams"][TEAM]["team_owner"] = state["team_owner"].clone();
153
+ state["teams"]["sibling"] = json!({
154
+ "team_key": "sibling",
155
+ "session_name": sibling_session.as_str(),
156
+ "tmux_socket": TMUX_ENDPOINT,
157
+ "agents": {
158
+ "sibling-worker": {
159
+ "provider": "fake", "status": "running", "pane_id": "%sibling",
160
+ "spawned_at": "sibling-generation"
161
+ }
162
+ }
163
+ });
164
+ save_runtime_state(&case.workspace, &state).unwrap();
165
+ let sibling_before = case.read_state()["teams"]["sibling"].clone();
166
+ let mut sibling_pane = pane_info(
167
+ sibling_session.clone(), WindowName::new("sibling-worker"), PaneId::new("%sibling"),
168
+ );
169
+ sibling_pane.current_path = Some(case.workspace.clone());
170
+ sibling_pane.pane_pid = None;
171
+ transport.state.lock().unwrap().panes.insert("%sibling".to_string(), sibling_pane.clone());
172
+ transport.set_session_owner_with_generation(
173
+ &session, &case.workspace, TEAM, "2026-07-14T00:00:00+00:00",
174
+ ).unwrap();
175
+ transport.set_session_owner_with_generation(
176
+ &sibling_session, &case.workspace, "sibling", "sibling-generation",
177
+ ).unwrap();
178
+ let sibling_owner = transport.session_owner(&sibling_session).unwrap();
179
+
180
+ let report = restart_with_transport(&case.workspace, true, Some(TEAM), &transport).unwrap();
181
+ assert!(matches!(report, RestartReport::Restarted { .. }), "{report:?}");
182
+ let state = case.read_state();
183
+ let expected_generation = state["teams"][TEAM]["agents"]["w1"]["spawned_at"].as_str().unwrap();
184
+ assert_ne!(expected_generation, "2026-07-14T00:00:00+00:00");
185
+ let owner = transport.session_owner(&session).unwrap().unwrap();
186
+ assert_eq!(owner.workspace, case.workspace.canonicalize().unwrap().to_string_lossy());
187
+ assert_eq!(owner.team, TEAM);
188
+ assert_eq!(owner.generation, expected_generation);
189
+ assert_no_original_session_kill(&transport.ops(), "restart retains the session");
190
+
191
+ let out = team_agent::cli::lifecycle_port::shutdown_with_transport(
192
+ &case.workspace, true, Some(TEAM), &transport,
193
+ ).unwrap();
194
+ assert_eq!(out["ok"], true, "{out}");
195
+ assert_eq!(out["session_killed"], true, "{out}");
196
+ assert_eq!(out["spared_sessions"], json!([]), "{out}");
197
+ assert_eq!(out["coordinator_stop_reason"], "scoped_live_sibling_present", "{out}");
198
+ assert!(!transport.has_session(&session).unwrap());
199
+ assert!(transport.has_session(&sibling_session).unwrap());
200
+ assert_eq!(transport.session_owner(&sibling_session).unwrap(), sibling_owner);
201
+ assert_eq!(case.read_state()["teams"]["sibling"], sibling_before);
202
+ assert_eq!(transport.state.lock().unwrap().panes.get("%sibling"), Some(&sibling_pane));
203
+ }
204
+
205
+ #[test]
206
+ #[serial(env)]
207
+ fn failed_replacement_preserves_original_session_owner() {
208
+ let case = RestartCase::new("failed-restart-owner", worker_ids(1), ProviderShape::Fake);
209
+ let transport = BuildBeforeDestroyTransport::recording(worker_ids(1))
210
+ .with_spawn_failure("w1", "injected replacement spawn failure");
211
+ let session = SessionName::new(TEAM_SESSION);
212
+ transport.set_session_owner_with_generation(
213
+ &session, &case.workspace, TEAM, "2026-07-14T00:00:00+00:00",
214
+ ).unwrap();
215
+ let owner_before = transport.session_owner(&session).unwrap();
216
+ let report = restart_with_transport(&case.workspace, true, Some(TEAM), &transport).unwrap();
217
+ assert_restart_did_not_succeed(&report);
218
+ assert_eq!(transport.session_owner(&session).unwrap(), owner_before);
219
+ assert_eq!(transport.ops().iter().filter(|op| op.starts_with("set_session_owner:")).count(), 1);
220
+ }
221
+
133
222
  struct RestartCase {
134
223
  env: hermetic_guard::HermeticTestEnv,
135
224
  workspace: PathBuf,
@@ -354,6 +443,7 @@ struct TransportState {
354
443
  session_present: bool,
355
444
  ops: Vec<String>,
356
445
  panes: BTreeMap<String, PaneInfo>,
446
+ owners: BTreeMap<String, SessionOwner>,
357
447
  next_pane: usize,
358
448
  spawns: usize,
359
449
  }
@@ -416,6 +506,7 @@ impl BuildBeforeDestroyTransport {
416
506
  kind: &'static str,
417
507
  session: &SessionName,
418
508
  window: &WindowName,
509
+ cwd: &Path,
419
510
  ) -> Result<SpawnResult, TransportError> {
420
511
  let mut state = self.state.lock().unwrap();
421
512
  state
@@ -434,10 +525,10 @@ impl BuildBeforeDestroyTransport {
434
525
  let pane_id = PaneId::new(format!("%new-{}", state.next_pane));
435
526
  state.next_pane += 1;
436
527
  state.session_present = true;
437
- state.panes.insert(
438
- pane_id.as_str().to_string(),
439
- pane_info(session.clone(), window.clone(), pane_id.clone()),
440
- );
528
+ let mut pane = pane_info(session.clone(), window.clone(), pane_id.clone());
529
+ pane.current_path = Some(cwd.to_path_buf());
530
+ pane.pane_pid = None;
531
+ state.panes.insert(pane_id.as_str().to_string(), pane);
441
532
  if self.server_exit_after_first_spawn && state.spawns == 1 {
442
533
  state.session_present = false;
443
534
  }
@@ -451,7 +542,12 @@ impl BuildBeforeDestroyTransport {
451
542
 
452
543
  fn real_tmux_has_session(&self, session: &SessionName) -> Result<bool, TransportError> {
453
544
  let Some(socket) = self.real_socket.as_ref() else {
454
- return Ok(self.state.lock().unwrap().session_present);
545
+ let state = self.state.lock().unwrap();
546
+ return Ok(if session.as_str() == TEAM_SESSION {
547
+ state.session_present
548
+ } else {
549
+ state.panes.values().any(|pane| pane.session == *session)
550
+ });
455
551
  };
456
552
  Ok(Command::new("tmux")
457
553
  .args([
@@ -535,10 +631,10 @@ impl Transport for BuildBeforeDestroyTransport {
535
631
  session: &SessionName,
536
632
  window: &WindowName,
537
633
  _argv: &[String],
538
- _cwd: &Path,
634
+ cwd: &Path,
539
635
  _env: &BTreeMap<String, String>,
540
636
  ) -> Result<SpawnResult, TransportError> {
541
- self.spawn("spawn_first", session, window)
637
+ self.spawn("spawn_first", session, window, cwd)
542
638
  }
543
639
 
544
640
  fn spawn_into(
@@ -546,10 +642,10 @@ impl Transport for BuildBeforeDestroyTransport {
546
642
  session: &SessionName,
547
643
  window: &WindowName,
548
644
  _argv: &[String],
549
- _cwd: &Path,
645
+ cwd: &Path,
550
646
  _env: &BTreeMap<String, String>,
551
647
  ) -> Result<SpawnResult, TransportError> {
552
- self.spawn("spawn_into", session, window)
648
+ self.spawn("spawn_into", session, window, cwd)
553
649
  }
554
650
 
555
651
  fn inject(
@@ -648,6 +744,27 @@ impl Transport for BuildBeforeDestroyTransport {
648
744
  Ok(windows.into_iter().collect())
649
745
  }
650
746
 
747
+ fn set_session_owner_with_generation(
748
+ &self,
749
+ session: &SessionName,
750
+ workspace: &Path,
751
+ team: &str,
752
+ generation: &str,
753
+ ) -> Result<(), TransportError> {
754
+ let mut state = self.state.lock().unwrap();
755
+ state.ops.push(format!("set_session_owner:{}", session.as_str()));
756
+ state.owners.insert(session.as_str().to_string(), SessionOwner {
757
+ workspace: workspace.canonicalize().unwrap().to_string_lossy().into_owned(),
758
+ team: team.to_string(),
759
+ generation: generation.to_string(),
760
+ });
761
+ Ok(())
762
+ }
763
+
764
+ fn session_owner(&self, session: &SessionName) -> Result<Option<SessionOwner>, TransportError> {
765
+ Ok(self.state.lock().unwrap().owners.get(session.as_str()).cloned())
766
+ }
767
+
651
768
  fn set_session_env(
652
769
  &self,
653
770
  _session: &SessionName,
@@ -665,7 +782,7 @@ impl Transport for BuildBeforeDestroyTransport {
665
782
  .push(format!("kill_session:{}", session.as_str()));
666
783
  if session.as_str() == TEAM_SESSION {
667
784
  self.state.lock().unwrap().session_present = false;
668
- self.state.lock().unwrap().panes.clear();
785
+ self.state.lock().unwrap().panes.retain(|_, pane| pane.session != *session);
669
786
  if let Some(socket) = self.real_socket.as_ref() {
670
787
  let status = Command::new("tmux")
671
788
  .args([
@@ -12,8 +12,8 @@
12
12
  //! `GetExitCodeProcess`, `TerminateProcess`, Toolhelp snapshot,
13
13
  //! Job Objects for shim-owned worker teardown)
14
14
  //!
15
- //! CR C-6: `terminate_pid` / `terminate_group` return
16
- //! `TerminationOutcome` so a caller that requested `TerminateGraceful`
15
+ //! CR C-6: `terminate_pid` returns `TerminationOutcome` so a caller
16
+ //! that requested `TerminateGraceful`
17
17
  //! can see that Windows downgraded to `TerminateForce` and emit a
18
18
  //! `platform.terminate_force_only` event (N38 交底).
19
19
  //!
@@ -41,7 +41,7 @@ pub enum SignalKind {
41
41
  TerminateForce,
42
42
  }
43
43
 
44
- /// Result of a `terminate_pid` / `terminate_group` call. `Graceful`
44
+ /// Result of a `terminate_pid` call. `Graceful`
45
45
  /// means the OS honored the request kind; `ForceOnly` means Windows
46
46
  /// downgraded a `TerminateGraceful` to `TerminateProcess` (CR C-6
47
47
  /// N38 交底 — caller must emit a `platform.terminate_force_only`
@@ -55,7 +55,7 @@ pub enum TerminationOutcome {
55
55
  /// (no grace period). Includes a machine-readable reason so
56
56
  /// callers can log it in the audit event.
57
57
  ForceOnly { reason: &'static str },
58
- /// The pid/group was already gone by the time the call resolved.
58
+ /// The pid was already gone by the time the call resolved.
59
59
  /// Not an error.
60
60
  AlreadyGone,
61
61
  }
@@ -216,34 +216,6 @@ mod unix_impl {
216
216
  }
217
217
  }
218
218
 
219
- /// Send a signal to a process group (`kill(-pgid, ...)`).
220
- /// Byte-equivalent to `cli/mod.rs:1854` (`libc::kill(-pgid, signal)`)
221
- /// used by `send_process_signal_group`.
222
- pub fn terminate_group(
223
- group_id: u32,
224
- kind: SignalKind,
225
- ) -> Result<TerminationOutcome, io::Error> {
226
- let signal = match kind {
227
- SignalKind::TerminateGraceful => libc::SIGTERM,
228
- SignalKind::TerminateForce => libc::SIGKILL,
229
- };
230
- let pgid_t = match libc::pid_t::try_from(group_id) {
231
- Ok(p) => p,
232
- Err(_) => return Ok(TerminationOutcome::AlreadyGone),
233
- };
234
- // `-pgid` targets every process in that group. Preserves the
235
- // existing shutdown semantics inline in cli/mod.rs.
236
- let rc = unsafe { libc::kill(-pgid_t, signal) };
237
- if rc == 0 {
238
- return Ok(TerminationOutcome::Requested);
239
- }
240
- let err = io::Error::last_os_error();
241
- match err.raw_os_error() {
242
- Some(code) if code == libc::ESRCH => Ok(TerminationOutcome::AlreadyGone),
243
- _ => Err(err),
244
- }
245
- }
246
-
247
219
  /// Best-effort waitpid so a killed child doesn't accumulate as a
248
220
  /// zombie. Byte-equivalent to `coordinator/health.rs:350-358` and
249
221
  /// `cli/mod.rs:1871-1879`.
@@ -434,19 +406,6 @@ mod windows_impl {
434
406
  })
435
407
  }
436
408
 
437
- pub fn terminate_group(
438
- _group_id: u32,
439
- _kind: SignalKind,
440
- ) -> Result<TerminationOutcome, io::Error> {
441
- // Windows has no `-pgid` sentinel. Design §Route B: "owned
442
- // worker teardown: prefer Job Objects in the ConPTY shim".
443
- // Job-object teardown is a shim-side concern, not a top-level
444
- // API. Return AlreadyGone honestly here so shutdown code
445
- // falls back to per-pid termination (the caller's outer
446
- // loop retries per-pid after the group attempt is a no-op).
447
- Ok(TerminationOutcome::AlreadyGone)
448
- }
449
-
450
409
  pub fn reap_child_if_possible(_pid: u32) {
451
410
  // Windows has no zombie waitpid model. Child handles are
452
411
  // owned by the spawner and closed at drop; nothing to do.
@@ -68,13 +68,16 @@ use crate::transport::{
68
68
  tmux_spawn_argv, AttachOutcome, BackendKind, BusySignalKind, CaptureRange,
69
69
  CaptureSampleOutcome, CapturedText, InjectPayload, InjectReport, InjectStage,
70
70
  InjectVerification, InputSurfaceProbe, Key, PaneField, PaneId, PaneInfo, PaneMode,
71
- QueryOutcome, SessionName, SetEnvOutcome, SpawnResult, SubmitAttemptObservation,
72
- SubmitConsumptionReason, SubmitDiagnostics, SubmitObserver, SubmitVerification, Target,
73
- Transport, TransportError, TurnVerification, WindowName,
71
+ QueryOutcome, SessionName, SessionOwner, SetEnvOutcome, SpawnResult,
72
+ SubmitAttemptObservation, SubmitConsumptionReason, SubmitDiagnostics, SubmitObserver,
73
+ SubmitVerification, Target, Transport, TransportError, TurnVerification, WindowName,
74
74
  };
75
75
 
76
76
  pub const PANE_BINDING_NONCE_METADATA_KEY: &str = "TEAM_AGENT_PANE_BINDING_NONCE";
77
77
  const TMUX_PANE_BINDING_NONCE_OPTION: &str = "@team_agent_pane_binding_nonce";
78
+ const TMUX_SESSION_OWNER_WORKSPACE_OPTION: &str = "@team_agent_owner_workspace";
79
+ const TMUX_SESSION_OWNER_TEAM_OPTION: &str = "@team_agent_owner_team";
80
+ const TMUX_SESSION_OWNER_GENERATION_OPTION: &str = "@team_agent_owner_generation";
78
81
 
79
82
  /// Result of running an external command — the typed output of the OS edge.
80
83
  #[derive(Debug, Clone)]
@@ -351,6 +354,87 @@ impl TmuxBackend {
351
354
  }
352
355
  }
353
356
 
357
+ fn set_session_owner_marker(
358
+ &self,
359
+ session: &SessionName,
360
+ workspace: &Path,
361
+ team: &str,
362
+ generation: &str,
363
+ ) -> Result<(), TransportError> {
364
+ let workspace = workspace
365
+ .canonicalize()
366
+ .unwrap_or_else(|_| workspace.to_path_buf())
367
+ .to_string_lossy()
368
+ .into_owned();
369
+ for (option, value) in [
370
+ (TMUX_SESSION_OWNER_WORKSPACE_OPTION, workspace),
371
+ (TMUX_SESSION_OWNER_TEAM_OPTION, team.to_string()),
372
+ (TMUX_SESSION_OWNER_GENERATION_OPTION, generation.to_string()),
373
+ ] {
374
+ let argv = self.tmux_argv(&[
375
+ "tmux".to_string(),
376
+ "set-option".to_string(),
377
+ "-t".to_string(),
378
+ session.as_str().to_string(),
379
+ option.to_string(),
380
+ value,
381
+ ]);
382
+ let output = self.runner.run(&argv)?;
383
+ if !output.success {
384
+ return Err(subprocess_error(argv, output));
385
+ }
386
+ }
387
+ Ok(())
388
+ }
389
+
390
+ fn read_session_owner_option(
391
+ &self,
392
+ session: &SessionName,
393
+ option: &str,
394
+ ) -> Result<Option<String>, TransportError> {
395
+ let argv = self.tmux_argv(&[
396
+ "tmux".to_string(),
397
+ "show-options".to_string(),
398
+ "-qv".to_string(),
399
+ "-t".to_string(),
400
+ session.as_str().to_string(),
401
+ option.to_string(),
402
+ ]);
403
+ let output = self.runner.run(&argv)?;
404
+ if !output.success {
405
+ return Ok(None);
406
+ }
407
+ let value = output.stdout.trim();
408
+ Ok((!value.is_empty()).then(|| value.to_string()))
409
+ }
410
+
411
+ fn read_session_owner_marker(
412
+ &self,
413
+ session: &SessionName,
414
+ ) -> Result<Option<SessionOwner>, TransportError> {
415
+ let Some(workspace) = self.read_session_owner_option(
416
+ session,
417
+ TMUX_SESSION_OWNER_WORKSPACE_OPTION,
418
+ )? else {
419
+ return Ok(None);
420
+ };
421
+ let Some(team) = self.read_session_owner_option(session, TMUX_SESSION_OWNER_TEAM_OPTION)?
422
+ else {
423
+ return Ok(None);
424
+ };
425
+ let Some(generation) = self.read_session_owner_option(
426
+ session,
427
+ TMUX_SESSION_OWNER_GENERATION_OPTION,
428
+ )? else {
429
+ return Ok(None);
430
+ };
431
+ Ok(Some(SessionOwner {
432
+ workspace,
433
+ team,
434
+ generation,
435
+ }))
436
+ }
437
+
354
438
  /// ---
355
439
  /// purpose: 把 leader 认领的绑定 nonce 写进 pane 级 tmux 用户选项,给「这个 pane 实例是不是我绑的那个」留可核凭据
356
440
  /// params:
@@ -2986,6 +3070,32 @@ impl Transport for TmuxBackend {
2986
3070
  .map(|endpoint| endpoint.as_endpoint().to_string())
2987
3071
  }
2988
3072
 
3073
+ fn set_session_owner(
3074
+ &self,
3075
+ session: &SessionName,
3076
+ workspace: &Path,
3077
+ team: &str,
3078
+ ) -> Result<(), TransportError> {
3079
+ self.set_session_owner_marker(session, workspace, team, session.as_str())
3080
+ }
3081
+
3082
+ fn set_session_owner_with_generation(
3083
+ &self,
3084
+ session: &SessionName,
3085
+ workspace: &Path,
3086
+ team: &str,
3087
+ generation: &str,
3088
+ ) -> Result<(), TransportError> {
3089
+ self.set_session_owner_marker(session, workspace, team, generation)
3090
+ }
3091
+
3092
+ fn session_owner(
3093
+ &self,
3094
+ session: &SessionName,
3095
+ ) -> Result<Option<SessionOwner>, TransportError> {
3096
+ self.read_session_owner_marker(session)
3097
+ }
3098
+
2989
3099
  fn spawn_first(
2990
3100
  &self,
2991
3101
  session: &SessionName,
@@ -55,6 +55,7 @@ struct OfflineState {
55
55
  /// 0.4.x (CR C-3 / CR C-5): pre-staged `query(PaneField::PaneCurrentCommand)`
56
56
  /// answer keyed by pane id. Used by leader provider health tests.
57
57
  pane_current_commands: BTreeMap<String, String>,
58
+ session_owner: Option<super::SessionOwner>,
58
59
  }
59
60
 
60
61
  impl Default for OfflineState {
@@ -81,6 +82,7 @@ impl Default for OfflineState {
81
82
  capture_text: BTreeMap::new(),
82
83
  capture_ranges: Vec::new(),
83
84
  pane_current_commands: BTreeMap::new(),
85
+ session_owner: None,
84
86
  }
85
87
  }
86
88
  }
@@ -159,6 +161,27 @@ impl OfflineTransport {
159
161
  self
160
162
  }
161
163
 
164
+ pub fn with_session_owner(
165
+ self,
166
+ workspace: &Path,
167
+ team: impl Into<String>,
168
+ generation: impl Into<String>,
169
+ ) -> Self {
170
+ let workspace = workspace
171
+ .canonicalize()
172
+ .unwrap_or_else(|_| workspace.to_path_buf())
173
+ .to_string_lossy()
174
+ .into_owned();
175
+ self.with_state(|state| {
176
+ state.session_owner = Some(super::SessionOwner {
177
+ workspace,
178
+ team: team.into(),
179
+ generation: generation.into(),
180
+ });
181
+ });
182
+ self
183
+ }
184
+
162
185
  /// U1-B: stage a `list_targets()` error to model tmux server jitter. While set,
163
186
  /// every call returns `Err(TransportError::MuxUnavailable{detail})`. Pass an
164
187
  /// empty string to clear via re-builder if needed.
@@ -532,6 +555,13 @@ impl Transport for OfflineTransport {
532
555
  }))
533
556
  }
534
557
 
558
+ fn session_owner(
559
+ &self,
560
+ _session: &SessionName,
561
+ ) -> Result<Option<super::SessionOwner>, TransportError> {
562
+ Ok(self.with_state(|state| state.session_owner.clone()))
563
+ }
564
+
535
565
  fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
536
566
  Ok(self.with_state(|state| {
537
567
  state.calls.push("list_windows");
@@ -325,6 +325,13 @@ pub struct SpawnResult {
325
325
 
326
326
  /// 全局枚举的一行(身份地基)。`leader_env` 在 tmux 后端靠反向读进程 env,在
327
327
  /// WezTerm/ConPTY 后端靠正向登记表投影(§4a)。
328
+ #[derive(Debug, Clone, PartialEq, Eq)]
329
+ pub struct SessionOwner {
330
+ pub workspace: String,
331
+ pub team: String,
332
+ pub generation: String,
333
+ }
334
+
328
335
  #[derive(Debug, Clone, PartialEq, Eq)]
329
336
  pub struct PaneInfo {
330
337
  pub pane_id: PaneId,
@@ -1000,6 +1007,40 @@ pub trait Transport: Send + Sync {
1000
1007
  /// wezterm=`cli list --format json` + 正向登记表;conpty=daemon 内存表(仅自有,§4a)。
1001
1008
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError>;
1002
1009
 
1010
+ /// Write an owner marker at the session scope after a new session is created.
1011
+ /// Backends without a session-local metadata primitive may no-op, but then
1012
+ /// shutdown must treat the missing marker as unknown and refuse destructive work.
1013
+ fn set_session_owner(
1014
+ &self,
1015
+ _session: &SessionName,
1016
+ _workspace: &Path,
1017
+ _team: &str,
1018
+ ) -> Result<(), TransportError> {
1019
+ Ok(())
1020
+ }
1021
+
1022
+ /// Write owner metadata including the launch generation when the backend
1023
+ /// can persist one. The default preserves compatibility for non-tmux
1024
+ /// transports, which remain Unknown on reads.
1025
+ fn set_session_owner_with_generation(
1026
+ &self,
1027
+ session: &SessionName,
1028
+ workspace: &Path,
1029
+ team: &str,
1030
+ _generation: &str,
1031
+ ) -> Result<(), TransportError> {
1032
+ self.set_session_owner(session, workspace, team)
1033
+ }
1034
+
1035
+ /// Read the session-local owner marker. `None` is an unknown/legacy session,
1036
+ /// never proof that the caller owns it.
1037
+ fn session_owner(
1038
+ &self,
1039
+ _session: &SessionName,
1040
+ ) -> Result<Option<SessionOwner>, TransportError> {
1041
+ Ok(None)
1042
+ }
1043
+
1003
1044
  fn has_session(&self, session: &SessionName) -> Result<bool, TransportError>;
1004
1045
 
1005
1046
  fn list_windows(&self, session: &SessionName) -> Result<Vec<WindowName>, TransportError>;