@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
@@ -332,6 +332,7 @@ pub fn start_coordinator_with_team(
332
332
  }
333
333
  }
334
334
  command
335
+ .current_dir(workspace.as_path())
335
336
  .stdin(Stdio::null())
336
337
  .stdout(Stdio::from(log))
337
338
  .stderr(Stdio::from(log_err));
@@ -432,6 +433,16 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
432
433
  pid: Some(pid),
433
434
  });
434
435
  }
436
+ let metadata_owned = read_coordinator_metadata(workspace)
437
+ .as_ref()
438
+ .is_some_and(|metadata| metadata.pid == pid);
439
+ if !metadata_owned || !coordinator_pid_owned_by_workspace(workspace, pid) {
440
+ return Ok(StopReport {
441
+ ok: false,
442
+ status: StopOutcome::NotOwned,
443
+ pid: Some(pid),
444
+ });
445
+ }
435
446
  if pid.get() == std::process::id() {
436
447
  remove_file_if_exists(&pid_path)?;
437
448
  remove_file_if_exists(&coordinator_meta_path(workspace))?;
@@ -441,7 +452,7 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
441
452
  pid: Some(pid),
442
453
  });
443
454
  }
444
- if !terminate_pid(pid) {
455
+ if !terminate_pid_for_workspace(pid, workspace.as_path()) {
445
456
  return Ok(StopReport {
446
457
  ok: false,
447
458
  status: StopOutcome::KillFailed,
@@ -468,7 +479,7 @@ fn stop_discovered_coordinators(
468
479
  let mut stopped = None;
469
480
  let mut failed = None;
470
481
  for pid in pids {
471
- if terminate_pid(pid) {
482
+ if terminate_pid_for_workspace(pid, workspace.as_path()) {
472
483
  stopped.get_or_insert(pid);
473
484
  } else {
474
485
  failed.get_or_insert(pid);
@@ -501,47 +512,98 @@ fn discover_coordinator_pids(workspace: &WorkspacePath) -> Vec<Pid> {
501
512
  _ => return Vec::new(),
502
513
  };
503
514
  let text = String::from_utf8_lossy(&output.stdout);
504
- let candidates = workspace_match_candidates(workspace.as_path());
515
+ let metadata_pid = read_coordinator_metadata(workspace).map(|metadata| metadata.pid.get());
505
516
  text.lines()
506
517
  .filter_map(|line| parse_ps_command_line(line))
507
- .filter(|(pid, command)| {
508
- *pid != std::process::id()
509
- && coordinator_command_matches_workspace(command, &candidates)
518
+ .filter(|(pid, _command)| {
519
+ metadata_pid == Some(*pid)
520
+ && *pid != std::process::id()
521
+ && coordinator_pid_owned_by_workspace(workspace, Pid::new(*pid))
510
522
  })
511
523
  .map(|(pid, _)| Pid::new(pid))
512
524
  .collect()
513
525
  }
514
526
 
515
- fn parse_ps_command_line(line: &str) -> Option<(u32, &str)> {
516
- let line = line.trim_start();
517
- let split = line.find(char::is_whitespace).unwrap_or(line.len());
518
- let pid = line.get(..split)?.trim().parse::<u32>().ok()?;
519
- let command = line.get(split..)?.trim();
520
- Some((pid, command))
527
+ fn coordinator_pid_owned_by_workspace(workspace: &WorkspacePath, pid: Pid) -> bool {
528
+ let workspace = workspace
529
+ .as_path()
530
+ .canonicalize()
531
+ .unwrap_or_else(|_| workspace.as_path().to_path_buf());
532
+ let Some(tokens) = crate::platform::argv::argv_tokens(pid.get()) else {
533
+ return false;
534
+ };
535
+ let is_team_agent = tokens.first().is_some_and(|token| is_team_agent_binary(token));
536
+ let is_coordinator = tokens.iter().any(|token| token == "coordinator");
537
+ let has_workspace = tokens.windows(2).any(|pair| {
538
+ pair[0] == "--workspace"
539
+ && Path::new(&pair[1])
540
+ .canonicalize()
541
+ .map(|path| path == workspace)
542
+ .unwrap_or(false)
543
+ });
544
+ is_team_agent && is_coordinator && has_workspace
521
545
  }
522
546
 
523
- fn workspace_match_candidates(workspace: &Path) -> Vec<String> {
524
- let mut candidates = vec![workspace.to_string_lossy().to_string()];
525
- if let Ok(canonical) = workspace.canonicalize() {
526
- let text = canonical.to_string_lossy().to_string();
527
- if !candidates.iter().any(|candidate| candidate == &text) {
528
- candidates.push(text);
547
+ fn terminate_pid_for_workspace(pid: Pid, workspace: &Path) -> bool {
548
+ if !coordinator_pid_owned_by_path(workspace, pid) {
549
+ return false;
550
+ }
551
+ let pids = process_tree_pids(pid)
552
+ .into_iter()
553
+ .filter(|candidate| coordinator_pid_owned_by_path(workspace, *candidate))
554
+ .collect::<Vec<_>>();
555
+ if pids.is_empty() {
556
+ return false;
557
+ }
558
+ for child in pids.iter().rev() {
559
+ let _ = crate::platform::process::terminate_pid(
560
+ child.get(),
561
+ crate::platform::process::SignalKind::TerminateGraceful,
562
+ );
563
+ }
564
+ if !wait_until_all_not_running(&pids, Duration::from_secs(5)) {
565
+ for child in pids.iter().rev() {
566
+ let _ = crate::platform::process::terminate_pid(
567
+ child.get(),
568
+ crate::platform::process::SignalKind::TerminateForce,
569
+ );
529
570
  }
530
571
  }
531
- candidates
572
+ wait_until_all_not_running(&pids, Duration::from_secs(5))
532
573
  }
533
574
 
534
- fn coordinator_command_matches_workspace(command: &str, workspaces: &[String]) -> bool {
535
- command
536
- .split_whitespace()
537
- .any(|token| token == "team-agent" || token.ends_with("/team-agent"))
538
- && command
539
- .split_whitespace()
540
- .any(|token| token == "coordinator")
541
- && command.contains("--workspace")
542
- && workspaces
543
- .iter()
544
- .any(|workspace| command.contains(workspace))
575
+ fn coordinator_pid_owned_by_path(workspace: &Path, pid: Pid) -> bool {
576
+ let workspace = workspace
577
+ .canonicalize()
578
+ .unwrap_or_else(|_| workspace.to_path_buf());
579
+ let Some(tokens) = crate::platform::argv::argv_tokens(pid.get()) else {
580
+ return false;
581
+ };
582
+ let is_team_agent = tokens.first().is_some_and(|token| is_team_agent_binary(token));
583
+ let is_coordinator = tokens.iter().any(|token| token == "coordinator");
584
+ let has_workspace = tokens.windows(2).any(|pair| {
585
+ pair[0] == "--workspace"
586
+ && Path::new(&pair[1])
587
+ .canonicalize()
588
+ .map(|path| path == workspace)
589
+ .unwrap_or(false)
590
+ });
591
+ is_team_agent && is_coordinator && has_workspace
592
+ }
593
+
594
+ fn is_team_agent_binary(token: &str) -> bool {
595
+ let Some(name) = Path::new(token).file_name().and_then(|name| name.to_str()) else {
596
+ return false;
597
+ };
598
+ name == "team-agent" || name == "team_agent" || name.starts_with("team-agent-")
599
+ }
600
+
601
+ fn parse_ps_command_line(line: &str) -> Option<(u32, &str)> {
602
+ let line = line.trim_start();
603
+ let split = line.find(char::is_whitespace).unwrap_or(line.len());
604
+ let pid = line.get(..split)?.trim().parse::<u32>().ok()?;
605
+ let command = line.get(split..)?.trim();
606
+ Some((pid, command))
545
607
  }
546
608
 
547
609
  fn terminate_pid(pid: Pid) -> bool {
@@ -160,6 +160,40 @@ fn coordinator_team_scoped_state(
160
160
  crate::state::projection::select_runtime_state(workspace, Some(selected)).ok()
161
161
  }
162
162
 
163
+ fn live_sibling_team_state(
164
+ state: &Value,
165
+ missing_session: &str,
166
+ transport: &dyn crate::transport::Transport,
167
+ ) -> Result<Option<(String, Value)>, crate::transport::TransportError> {
168
+ let Some(teams) = state.get("teams").and_then(Value::as_object) else {
169
+ return Ok(None);
170
+ };
171
+ for (team_key, team_state) in teams {
172
+ let status = team_state
173
+ .get("status")
174
+ .and_then(Value::as_str)
175
+ .map(|status| status.to_ascii_lowercase());
176
+ if status.is_some_and(|status| status != "alive" && status != "running") {
177
+ continue;
178
+ }
179
+ let Some(session_name) = team_state
180
+ .get("session_name")
181
+ .and_then(Value::as_str)
182
+ .filter(|session| !session.is_empty() && *session != missing_session)
183
+ else {
184
+ continue;
185
+ };
186
+ let session = crate::transport::SessionName::new(session_name);
187
+ if transport.has_session(&session)? {
188
+ return Ok(Some((
189
+ team_key.clone(),
190
+ crate::state::projection::project_top_level_view(state, team_key),
191
+ )));
192
+ }
193
+ }
194
+ Ok(None)
195
+ }
196
+
163
197
  // ===========================================================================
164
198
  // Coordinator struct(daemon lifecycle + tick orchestration)
165
199
  // ===========================================================================
@@ -330,20 +364,40 @@ impl Coordinator {
330
364
  .get("session_name")
331
365
  .and_then(Value::as_str)
332
366
  .filter(|s| !s.is_empty())
367
+ .map(str::to_owned)
333
368
  {
334
- let session = crate::transport::SessionName::new(session_name);
369
+ let session = crate::transport::SessionName::new(&session_name);
335
370
  if !self.transport.has_session(&session)? {
336
371
  event_log.write(
337
372
  "coordinator.session_missing",
338
373
  serde_json::json!({"session": session_name}),
339
374
  )?;
340
- notify_session_missing(self.workspace.as_path(), &state, &event_log, session_name)?;
341
- return Ok(empty_tick_report(
342
- false,
343
- true,
344
- Some(TickStopReason::TmuxSessionMissing),
345
- None,
346
- ));
375
+ if let Some((sibling_team, sibling_state)) =
376
+ live_sibling_team_state(&state, &session_name, self.transport.as_ref())?
377
+ {
378
+ event_log.write(
379
+ "coordinator.session_missing_sibling_alive",
380
+ serde_json::json!({
381
+ "session": session_name,
382
+ "sibling_team": sibling_team,
383
+ "action": "continue_with_sibling_team",
384
+ }),
385
+ )?;
386
+ state = sibling_state;
387
+ } else {
388
+ notify_session_missing(
389
+ self.workspace.as_path(),
390
+ &state,
391
+ &event_log,
392
+ &session_name,
393
+ )?;
394
+ return Ok(empty_tick_report(
395
+ false,
396
+ true,
397
+ Some(TickStopReason::TmuxSessionMissing),
398
+ None,
399
+ ));
400
+ }
347
401
  }
348
402
  }
349
403
 
@@ -164,6 +164,8 @@ pub enum StopOutcome {
164
164
  Missing,
165
165
  /// pid 非整数 → 清文件返回(`lifecycle.py:238`)。
166
166
  InvalidPidRemoved,
167
+ /// The pid file points to a live process owned by another workspace.
168
+ NotOwned,
167
169
  /// SIGTERM 失败(`lifecycle.py:243`)。
168
170
  KillFailed,
169
171
  /// SIGTERM + 清 pid/meta 成功(`lifecycle.py:247`)。
@@ -2,7 +2,7 @@
2
2
 
3
3
  use std::path::Path;
4
4
 
5
- use serde_json::json;
5
+ use serde_json::{json, Value};
6
6
 
7
7
  use crate::event_log::{EventLog, EventLogError};
8
8
  use crate::transport::{BackendKind, Transport};
@@ -29,6 +29,45 @@ pub(crate) fn pre_kill_audit(
29
29
  "argv": argv,
30
30
  "endpoint": endpoint,
31
31
  "targets": targets,
32
+ "workspace": Value::Null,
33
+ "team": Value::Null,
34
+ "generation": Value::Null,
35
+ "owner_evidence": "legacy_unscoped",
36
+ "reason": "legacy_callsite",
37
+ }),
38
+ )?;
39
+ Ok(())
40
+ }
41
+
42
+ pub(crate) fn pre_kill_audit_scoped(
43
+ event_log: &EventLog,
44
+ transport: &dyn Transport,
45
+ caller: &str,
46
+ action: &str,
47
+ targets: &[String],
48
+ workspace: &Path,
49
+ team: Option<&str>,
50
+ generation: Option<&str>,
51
+ owner_evidence: &str,
52
+ reason: &str,
53
+ ) -> Result<(), EventLogError> {
54
+ let endpoint = transport.tmux_endpoint();
55
+ let argv = destructive_argv(transport.kind(), endpoint.as_deref(), action, targets);
56
+ event_log.write(
57
+ "transport.pre_kill_audit",
58
+ json!({
59
+ "phase": "pre_call",
60
+ "caller": caller,
61
+ "caller_pid": std::process::id(),
62
+ "action": action,
63
+ "argv": argv,
64
+ "endpoint": endpoint,
65
+ "targets": targets,
66
+ "workspace": workspace.to_string_lossy(),
67
+ "team": team,
68
+ "generation": generation,
69
+ "owner_evidence": owner_evidence,
70
+ "reason": reason,
32
71
  }),
33
72
  )?;
34
73
  Ok(())
@@ -57,7 +96,7 @@ fn destructive_argv(
57
96
  vec![format!("{kind:?}").to_ascii_lowercase()]
58
97
  };
59
98
  argv.push(action.to_string());
60
- if action == KILL_SESSION {
99
+ if action == KILL_SESSION || action == "kill-pane" {
61
100
  for target in targets {
62
101
  argv.push("-t".to_string());
63
102
  argv.push(target.clone());
@@ -124,11 +124,7 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
124
124
  }
125
125
 
126
126
  fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
127
- self.transport
128
- .query(&Target::Pane(pane.clone()), PaneField::PaneCurrentCommand)
129
- .ok()
130
- .flatten()
131
- .filter(|command| !command.trim().is_empty())
127
+ query_caller_pane_command(self.transport, pane)
132
128
  }
133
129
 
134
130
  fn attach(
@@ -142,7 +138,7 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
142
138
  self.applied_grant = None;
143
139
  self.registry_receipt = None;
144
140
  let event_log = crate::event_log::EventLog::new(workspace);
145
- let targets = match self.transport.list_targets() {
141
+ let targets = match list_caller_targets(self.transport) {
146
142
  Ok(targets) => targets,
147
143
  Err(_) => {
148
144
  self.last_failure_reason = Some("caller_pane_unobservable");
@@ -290,11 +286,19 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
290
286
  self.last_failure_reason = Some("receiver_missing");
291
287
  return false;
292
288
  };
289
+ let leader_transport = receiver
290
+ .get("tmux_socket")
291
+ .and_then(serde_json::Value::as_str)
292
+ .filter(|endpoint| !endpoint.is_empty())
293
+ .map(crate::transport_factory::leader_endpoint_transport);
294
+ let live_channel_transport: &dyn Transport = leader_transport
295
+ .as_deref()
296
+ .unwrap_or(self.transport);
293
297
  if !matches!(
294
298
  crate::messaging::resolve_live_leader_channel(
295
299
  workspace,
296
300
  receiver,
297
- self.transport,
301
+ live_channel_transport,
298
302
  ),
299
303
  crate::messaging::LeaderChannelResolution::Live(_)
300
304
  ) {
@@ -875,6 +879,42 @@ fn existing_runtime_identity_view(
875
879
  state.clone()
876
880
  }
877
881
 
882
+ fn caller_read_transport() -> Option<Box<dyn Transport>> {
883
+ let endpoint = crate::tmux_backend::socket_name_from_tmux_env()?;
884
+ // Unit tests use an injected transport even when they seed a synthetic TMUX
885
+ // tuple. Never make those hermetic tests depend on a host tmux server; the
886
+ // production path below remains a read-only endpoint probe.
887
+ #[cfg(test)]
888
+ if !Path::new(&endpoint).exists() {
889
+ return None;
890
+ }
891
+ Some(Box::new(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(
892
+ &endpoint,
893
+ )))
894
+ }
895
+
896
+ fn query_caller_pane_command(transport: &dyn Transport, pane: &PaneId) -> Option<String> {
897
+ let caller_transport = caller_read_transport();
898
+ let query_transport: &dyn Transport = caller_transport
899
+ .as_deref()
900
+ .unwrap_or(transport);
901
+ query_transport
902
+ .query(&Target::Pane(pane.clone()), PaneField::PaneCurrentCommand)
903
+ .ok()
904
+ .flatten()
905
+ .filter(|command| !command.trim().is_empty())
906
+ }
907
+
908
+ fn list_caller_targets(
909
+ transport: &dyn Transport,
910
+ ) -> Result<Vec<crate::transport::PaneInfo>, crate::transport::TransportError> {
911
+ if let Some(caller_transport) = caller_read_transport() {
912
+ caller_transport.list_targets()
913
+ } else {
914
+ transport.list_targets()
915
+ }
916
+ }
917
+
878
918
  fn preflight_fresh_leader_identity(
879
919
  transport: &dyn Transport,
880
920
  ) -> Option<(String, Vec<String>, Vec<String>)> {
@@ -900,13 +940,7 @@ fn preflight_fresh_leader_identity(
900
940
  let Some(pane) = pane else {
901
941
  return None;
902
942
  };
903
- let command = transport
904
- .query(
905
- &Target::Pane(PaneId::new(pane)),
906
- PaneField::PaneCurrentCommand,
907
- )
908
- .ok()
909
- .flatten()
943
+ let command = query_caller_pane_command(transport, &PaneId::new(pane))
910
944
  .unwrap_or_default();
911
945
  let explicit = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
912
946
  .ok()
@@ -3,7 +3,7 @@
3
3
  //! contract:
4
4
  //! provides:
5
5
  //! - name: quick_start_tmux_backend
6
- //! what: 优先复用调用方所在 tmux socket,否则按 workspace 派生
6
+ //! what: 按 workspace 派生私有 socket;调用方 tmux 只用于 leader receiver 绑定
7
7
  //! - name: annotate_runtime_transport
8
8
  //! what: 把 transport 种类与来源写进 state,tmux 时再写 endpoint 三字段
9
9
  //! - name: attach_commands_for_runtime_windows
@@ -37,15 +37,11 @@ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest}
37
37
  use super::*;
38
38
 
39
39
  /// ---
40
- /// purpose: 定出 quick-start 使用的 tmux 后端
41
- /// returns: 调用方环境里有 socket 就复用它,否则按 workspace 派生一个
40
+ /// purpose: 定出 quick-start 使用的私有 tmux 后端
41
+ /// returns: 始终按 workspace 派生 endpoint;调用方 $TMUX 仅供 leader receiver 身份绑定
42
42
  /// ---
43
43
  pub(crate) fn quick_start_tmux_backend(workspace: &Path) -> crate::tmux_backend::TmuxBackend {
44
- if let Some(endpoint) = crate::tmux_backend::socket_name_from_tmux_env() {
45
- crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&endpoint)
46
- } else {
47
- crate::tmux_backend::TmuxBackend::for_workspace(workspace)
48
- }
44
+ crate::tmux_backend::TmuxBackend::for_workspace(workspace)
49
45
  }
50
46
 
51
47
  /// ---
@@ -146,6 +146,23 @@ pub fn launch_with_transport_in_workspace(
146
146
  transport,
147
147
  &started,
148
148
  )?;
149
+ let owner_team = load_runtime_state(workspace)
150
+ .ok()
151
+ .map(|state| crate::state::projection::team_state_key(&state))
152
+ .filter(|team| !team.is_empty())
153
+ .unwrap_or_else(|| session_name.as_str().to_string());
154
+ let generation = started
155
+ .first()
156
+ .map(|agent| agent.spawned_at.as_str())
157
+ .unwrap_or(session_name.as_str());
158
+ transport
159
+ .set_session_owner_with_generation(
160
+ &session_name,
161
+ workspace,
162
+ &owner_team,
163
+ generation,
164
+ )
165
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?;
149
166
  // 0.5.38: per-worker timing tags (source="launch") so operators can
150
167
  // trace which worker's spawn dominates wall time. Zeros for now on
151
168
  // the sub-timings — Step 1 first enables shape assertion; a later
@@ -1041,6 +1041,32 @@ fn restart_with_selected_team_and_transport(
1041
1041
  attach_commands,
1042
1042
  });
1043
1043
  }
1044
+ // Respawn changes the generation used by shutdown's ownership check, even
1045
+ // when build-before-destroy keeps the tmux session itself alive. Stamp only
1046
+ // the selected session, after its replacement state is durable.
1047
+ if let Some(generation) = state
1048
+ .get("generation")
1049
+ .and_then(serde_json::Value::as_str)
1050
+ .or_else(|| {
1051
+ state
1052
+ .get("agents")
1053
+ .and_then(serde_json::Value::as_object)
1054
+ .and_then(|agents| {
1055
+ agents.values().find_map(|agent| {
1056
+ agent.get("spawned_at").and_then(serde_json::Value::as_str)
1057
+ })
1058
+ })
1059
+ })
1060
+ {
1061
+ transport
1062
+ .set_session_owner_with_generation(
1063
+ &session_name,
1064
+ &selected.run_workspace,
1065
+ &selected.team_key,
1066
+ generation,
1067
+ )
1068
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?;
1069
+ }
1044
1070
  // RM-039-SESS-001 step 2 (architect verdict 2026-06-22): post-respawn
1045
1071
  // resume backing validation.
1046
1072
  //
@@ -168,8 +168,10 @@ fn launch_restart_start_add_and_fork_all_delegate_to_the_profile_launch_resolver
168
168
  }
169
169
 
170
170
  #[test]
171
+ #[serial_test::serial(env)]
171
172
  fn diagnose_missing_profile_is_rc1_ok_false_and_zero_token() {
172
- let ws = tmp_dir("diagnose-missing-profile");
173
+ let env = hermetic_guard::HermeticTestEnv::enter("diagnose-missing-profile");
174
+ let ws = env.workspace("missing-profile");
173
175
  let team = write_claude_profile_team(&ws, "diagteam", "clauder");
174
176
  std::fs::remove_file(
175
177
  ws.join(".team")
@@ -180,7 +182,7 @@ fn diagnose_missing_profile_is_rc1_ok_false_and_zero_token() {
180
182
  .unwrap();
181
183
 
182
184
  let output = Command::new(crate::lifecycle::tests::test_binary_path())
183
- .args(["diagnose", "--workspace", ws.to_str().unwrap(), "--json"])
185
+ .args(["diagnose", team.to_str().unwrap(), "--workspace", ws.to_str().unwrap(), "--json"])
184
186
  .current_dir(&team)
185
187
  .output()
186
188
  .expect("diagnose subprocess should run");
@@ -197,10 +199,14 @@ fn diagnose_missing_profile_is_rc1_ok_false_and_zero_token() {
197
199
  json!(false),
198
200
  "diagnose missing profile must emit ok:false JSON; stdout={stdout} stderr={stderr}"
199
201
  );
202
+ assert_eq!(json["profile_smoke"]["ok"], false, "{json}");
200
203
  assert!(
201
- stdout.contains("profile") || stderr.contains("profile"),
202
- "diagnose missing profile must name the profile problem without invoking Claude; stdout={stdout} stderr={stderr}"
204
+ json["profile_smoke"].to_string().contains("profile"),
205
+ "diagnose missing profile must identify the scoped configuration failure: {json}"
203
206
  );
207
+ assert!(json["providers"].as_object().unwrap().values().all(|provider| {
208
+ provider["probe_status"] == "not_run"
209
+ }));
204
210
  assert!(
205
211
  !stdout.contains("tokens") && !stderr.contains("tokens"),
206
212
  "diagnose dry-run must not consume provider tokens or report token usage; stdout={stdout} stderr={stderr}"
@@ -2,6 +2,7 @@
2
2
  mod hermetic_guard;
3
3
 
4
4
  use super::*;
5
+ use crate::tmux_backend::TmuxBackend;
5
6
  use crate::transport::test_support::OfflineTransport;
6
7
  use serde_json::json;
7
8
  use serial_test::serial;
@@ -1428,19 +1429,21 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1428
1429
 
1429
1430
  #[test]
1430
1431
  #[serial(env)]
1431
- fn quick_start_tmux_backend_prefers_absolute_tmux_env_endpoint() {
1432
+ fn quick_start_tmux_backend_uses_workspace_private_endpoint() {
1432
1433
  let leader_socket = "/tmp/team-agent-layout-leader-socket";
1433
1434
  let _tmux = EnvVarGuard::set("TMUX", &format!("{leader_socket},123,0"));
1434
- let backend = quick_start_tmux_backend(std::path::Path::new("/tmp/layout-workspace"));
1435
+ let workspace = std::path::Path::new("/tmp/layout-workspace");
1436
+ let backend = quick_start_tmux_backend(workspace);
1437
+ let expected = TmuxBackend::for_workspace(workspace);
1435
1438
 
1436
1439
  assert_eq!(
1437
- crate::transport::Transport::tmux_endpoint(&backend).as_deref(),
1438
- Some(leader_socket),
1439
- "quick-start from inside tmux must use the caller's full $TMUX socket endpoint"
1440
+ crate::transport::Transport::tmux_endpoint(&backend),
1441
+ crate::transport::Transport::tmux_endpoint(&expected),
1442
+ "quick-start workers must use the workspace-private socket, not ambient $TMUX"
1440
1443
  );
1441
1444
  assert_eq!(
1442
- selected_tmux_socket_source(&backend, std::path::Path::new("/tmp/layout-workspace")),
1443
- Some("leader_env")
1445
+ selected_tmux_socket_source(&backend, workspace),
1446
+ Some("workspace")
1444
1447
  );
1445
1448
  }
1446
1449