@team-agent/installer 0.5.91 → 0.5.94

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.
@@ -74,7 +74,19 @@ use super::types::{
74
74
  /// returns: HealthReport。ok = 进程在跑 ∧ metadata 三元全等 ∧ 二进制身份一致 ∧ schema 兼容;service_available 刻意排除二进制身份,表示「这个 daemon 还能处理本队队列」;status 区分 Missing / InvalidPid / Running / Stale
75
75
  /// ---
76
76
  pub fn coordinator_health(workspace: &WorkspacePath) -> HealthReport {
77
- let schema = message_store_schema_health(workspace);
77
+ coordinator_health_with_schema(workspace, message_store_schema_health(workspace))
78
+ }
79
+
80
+ /// Read-only coordinator health for diagnose. Unlike the lifecycle path, this
81
+ /// never opens MessageStore and therefore cannot initialize or rewrite team.db.
82
+ pub fn coordinator_health_read_only(workspace: &WorkspacePath) -> HealthReport {
83
+ coordinator_health_with_schema(workspace, message_store_schema_health_read_only(workspace))
84
+ }
85
+
86
+ fn coordinator_health_with_schema(
87
+ workspace: &WorkspacePath,
88
+ schema: SchemaHealth,
89
+ ) -> HealthReport {
78
90
  let current_binary_identity = current_coordinator_binary_identity();
79
91
  let pid_path = coordinator_pid_path(workspace);
80
92
  let pid = read_pid_file(&pid_path);
@@ -320,6 +332,7 @@ pub fn start_coordinator_with_team(
320
332
  }
321
333
  }
322
334
  command
335
+ .current_dir(workspace.as_path())
323
336
  .stdin(Stdio::null())
324
337
  .stdout(Stdio::from(log))
325
338
  .stderr(Stdio::from(log_err));
@@ -420,6 +433,16 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
420
433
  pid: Some(pid),
421
434
  });
422
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
+ }
423
446
  if pid.get() == std::process::id() {
424
447
  remove_file_if_exists(&pid_path)?;
425
448
  remove_file_if_exists(&coordinator_meta_path(workspace))?;
@@ -429,7 +452,7 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
429
452
  pid: Some(pid),
430
453
  });
431
454
  }
432
- if !terminate_pid(pid) {
455
+ if !terminate_pid_for_workspace(pid, workspace.as_path()) {
433
456
  return Ok(StopReport {
434
457
  ok: false,
435
458
  status: StopOutcome::KillFailed,
@@ -456,7 +479,7 @@ fn stop_discovered_coordinators(
456
479
  let mut stopped = None;
457
480
  let mut failed = None;
458
481
  for pid in pids {
459
- if terminate_pid(pid) {
482
+ if terminate_pid_for_workspace(pid, workspace.as_path()) {
460
483
  stopped.get_or_insert(pid);
461
484
  } else {
462
485
  failed.get_or_insert(pid);
@@ -489,47 +512,98 @@ fn discover_coordinator_pids(workspace: &WorkspacePath) -> Vec<Pid> {
489
512
  _ => return Vec::new(),
490
513
  };
491
514
  let text = String::from_utf8_lossy(&output.stdout);
492
- let candidates = workspace_match_candidates(workspace.as_path());
515
+ let metadata_pid = read_coordinator_metadata(workspace).map(|metadata| metadata.pid.get());
493
516
  text.lines()
494
517
  .filter_map(|line| parse_ps_command_line(line))
495
- .filter(|(pid, command)| {
496
- *pid != std::process::id()
497
- && 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))
498
522
  })
499
523
  .map(|(pid, _)| Pid::new(pid))
500
524
  .collect()
501
525
  }
502
526
 
503
- fn parse_ps_command_line(line: &str) -> Option<(u32, &str)> {
504
- let line = line.trim_start();
505
- let split = line.find(char::is_whitespace).unwrap_or(line.len());
506
- let pid = line.get(..split)?.trim().parse::<u32>().ok()?;
507
- let command = line.get(split..)?.trim();
508
- 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
509
545
  }
510
546
 
511
- fn workspace_match_candidates(workspace: &Path) -> Vec<String> {
512
- let mut candidates = vec![workspace.to_string_lossy().to_string()];
513
- if let Ok(canonical) = workspace.canonicalize() {
514
- let text = canonical.to_string_lossy().to_string();
515
- if !candidates.iter().any(|candidate| candidate == &text) {
516
- 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
+ );
517
570
  }
518
571
  }
519
- candidates
572
+ wait_until_all_not_running(&pids, Duration::from_secs(5))
520
573
  }
521
574
 
522
- fn coordinator_command_matches_workspace(command: &str, workspaces: &[String]) -> bool {
523
- command
524
- .split_whitespace()
525
- .any(|token| token == "team-agent" || token.ends_with("/team-agent"))
526
- && command
527
- .split_whitespace()
528
- .any(|token| token == "coordinator")
529
- && command.contains("--workspace")
530
- && workspaces
531
- .iter()
532
- .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))
533
607
  }
534
608
 
535
609
  fn terminate_pid(pid: Pid) -> bool {
@@ -1004,6 +1078,34 @@ pub(crate) fn message_store_schema_health(workspace: &WorkspacePath) -> SchemaHe
1004
1078
  }
1005
1079
  }
1006
1080
 
1081
+ fn message_store_schema_health_read_only(workspace: &WorkspacePath) -> SchemaHealth {
1082
+ let action = Some("run team-agent doctor --fix-schema --json".to_string());
1083
+ match crate::db::migration::schema_diagnosis_workspace_read_only(workspace.as_path()) {
1084
+ Ok(diagnosis) if diagnosis.ok && diagnosis.status == "ok" => SchemaHealth {
1085
+ ok: true,
1086
+ schema_version: crate::db::schema::SCHEMA_VERSION,
1087
+ error: None,
1088
+ action: None,
1089
+ },
1090
+ Ok(diagnosis) => SchemaHealth {
1091
+ ok: false,
1092
+ schema_version: crate::db::schema::SCHEMA_VERSION,
1093
+ error: Some(SchemaError::InitFailed {
1094
+ message: format!("team.db status: {}", diagnosis.status),
1095
+ }),
1096
+ action,
1097
+ },
1098
+ Err(error) => SchemaHealth {
1099
+ ok: false,
1100
+ schema_version: crate::db::schema::SCHEMA_VERSION,
1101
+ error: Some(SchemaError::InitFailed {
1102
+ message: error.to_string(),
1103
+ }),
1104
+ action,
1105
+ },
1106
+ }
1107
+ }
1108
+
1007
1109
  fn read_pid_file(path: &Path) -> Option<Pid> {
1008
1110
  let text = std::fs::read_to_string(path).ok()?;
1009
1111
  let raw = text.trim().parse::<u32>().ok()?;
@@ -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`)。
@@ -8,7 +8,7 @@
8
8
 
9
9
  use std::path::{Path, PathBuf};
10
10
 
11
- use rusqlite::Connection;
11
+ use rusqlite::{Connection, OpenFlags};
12
12
 
13
13
  use crate::db::schema::{ensure_schema_indexes, table_layout, SCHEMA_VERSION};
14
14
  use crate::db::DbError;
@@ -423,22 +423,33 @@ pub fn ensure_table_layout(
423
423
  /// `schema_migration.py:schema_diagnosis`:只读判定(不变更 DB)。
424
424
  fn schema_diagnosis(db_path: &Path, schema_version: i64) -> Result<Diagnosis, DbError> {
425
425
  if !db_path.exists() {
426
- // T3-3 cr verdict (A parity lock, 2026-06-10): a missing db is the LEGAL
427
- // first-use state — ok:true is layered with the explicit status axis and the
428
- // recommended_action guidance (Python schema_migration.py:180-190 verbatim),
429
- // never a silent fake-green.
430
- return Ok(Diagnosis {
431
- ok: true,
432
- status: "missing".to_string(),
433
- user_version: 0,
434
- layout_diffs: vec![],
435
- recommended_action:
436
- "No team.db exists yet; initialize_schema will create it on first use.".to_string(),
437
- });
426
+ return Ok(missing_schema_diagnosis());
438
427
  }
439
428
  let conn = Connection::open(db_path)?;
440
- let uv = pragma_user_version(&conn)?;
441
- let diffs = layout_diffs(&conn)?;
429
+ schema_diagnosis_from_connection(&conn, schema_version)
430
+ }
431
+
432
+ fn missing_schema_diagnosis() -> Diagnosis {
433
+ // T3-3 cr verdict (A parity lock, 2026-06-10): a missing db is the LEGAL
434
+ // first-use state — ok:true is layered with the explicit status axis and the
435
+ // recommended_action guidance (Python schema_migration.py:180-190 verbatim),
436
+ // never a silent fake-green.
437
+ Diagnosis {
438
+ ok: true,
439
+ status: "missing".to_string(),
440
+ user_version: 0,
441
+ layout_diffs: vec![],
442
+ recommended_action:
443
+ "No team.db exists yet; initialize_schema will create it on first use.".to_string(),
444
+ }
445
+ }
446
+
447
+ fn schema_diagnosis_from_connection(
448
+ conn: &Connection,
449
+ schema_version: i64,
450
+ ) -> Result<Diagnosis, DbError> {
451
+ let uv = pragma_user_version(conn)?;
452
+ let diffs = layout_diffs(conn)?;
442
453
  let diff_tables: Vec<String> = diffs.iter().map(|d| d.table.to_string()).collect();
443
454
  let ok = diffs.is_empty() && uv == schema_version;
444
455
  Ok(Diagnosis {
@@ -466,6 +477,16 @@ pub fn schema_diagnosis_workspace(workspace: &Path) -> Result<Diagnosis, DbError
466
477
  )
467
478
  }
468
479
 
480
+ /// 对 workspace 的 team.db 执行严格只读 diagnosis,不创建或初始化缺失/空文件。
481
+ pub fn schema_diagnosis_workspace_read_only(workspace: &Path) -> Result<Diagnosis, DbError> {
482
+ let db_path = workspace.join(".team").join("runtime").join("team.db");
483
+ if !db_path.exists() {
484
+ return Ok(missing_schema_diagnosis());
485
+ }
486
+ let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
487
+ schema_diagnosis_from_connection(&conn, SCHEMA_VERSION)
488
+ }
489
+
469
490
  /// `schema_migration.py:_workspace_from_db_path`:`<ws>/.team/runtime/team.db` → `<ws>`。
470
491
  fn workspace_from_db_path(db_path: &Path) -> Option<PathBuf> {
471
492
  let parts: Vec<_> = db_path.iter().collect();
@@ -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
@@ -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