@team-agent/installer 0.3.0 → 0.3.2

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 (39) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +38 -7
  4. package/crates/team-agent/src/cli/emit.rs +182 -54
  5. package/crates/team-agent/src/cli/mod.rs +703 -35
  6. package/crates/team-agent/src/cli/status_port.rs +170 -44
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  8. package/crates/team-agent/src/cli/types.rs +1 -0
  9. package/crates/team-agent/src/coordinator/health.rs +130 -0
  10. package/crates/team-agent/src/leader/lease.rs +23 -2
  11. package/crates/team-agent/src/leader/rediscover/tests.rs +1 -0
  12. package/crates/team-agent/src/leader/rediscover.rs +2 -0
  13. package/crates/team-agent/src/leader/tests/byte_findings.rs +9 -6
  14. package/crates/team-agent/src/leader/tests/idle.rs +1 -0
  15. package/crates/team-agent/src/leader/tests/lease_claim.rs +157 -0
  16. package/crates/team-agent/src/leader/types.rs +2 -0
  17. package/crates/team-agent/src/lifecycle/launch.rs +554 -65
  18. package/crates/team-agent/src/lifecycle/restart/common.rs +65 -0
  19. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +57 -15
  20. package/crates/team-agent/src/lifecycle/restart/remove.rs +5 -1
  21. package/crates/team-agent/src/lifecycle/restart.rs +20 -0
  22. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +52 -0
  23. package/crates/team-agent/src/lifecycle/types.rs +25 -0
  24. package/crates/team-agent/src/mcp_server/tests/wire.rs +28 -0
  25. package/crates/team-agent/src/mcp_server/wire.rs +81 -1
  26. package/crates/team-agent/src/messaging/delivery.rs +574 -12
  27. package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
  28. package/crates/team-agent/src/messaging/mod.rs +1 -1
  29. package/crates/team-agent/src/messaging/results.rs +218 -49
  30. package/crates/team-agent/src/messaging/send.rs +15 -19
  31. package/crates/team-agent/src/provider/adapter.rs +95 -10
  32. package/crates/team-agent/src/provider/helpers.rs +10 -1
  33. package/crates/team-agent/src/state/identity.rs +3 -0
  34. package/crates/team-agent/src/state/persist.rs +113 -1
  35. package/crates/team-agent/src/state/projection.rs +127 -3
  36. package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
  37. package/crates/team-agent/src/tmux_backend.rs +124 -12
  38. package/npm/install.mjs +29 -7
  39. package/package.json +4 -4
@@ -1,11 +1,25 @@
1
1
  //! status_port extracted from cli::mod inline placeholder.
2
2
  use super::*;
3
+ use crate::state::projection::OwnerTeamResolution;
3
4
  use crate::transport::Transport;
5
+ use rusqlite::params;
4
6
 
5
7
  /// `status.status(workspace, as_json, compact)`(`queries.py:33`,**有副作用**:capture→refresh→save)。
6
8
  pub fn status(workspace: &Path, compact: bool, detail: bool) -> Result<Value, CliError> {
7
- let _ = detail;
8
9
  let state = read_runtime_state(workspace);
10
+ status_scoped(workspace, &state, None, compact, detail)
11
+ }
12
+
13
+ pub fn status_scoped(
14
+ workspace: &Path,
15
+ state: &Value,
16
+ owner_team_id: Option<&str>,
17
+ compact: bool,
18
+ detail: bool,
19
+ ) -> Result<Value, CliError> {
20
+ let _ = detail;
21
+ let resolved_owner_team_id = resolve_status_owner_team(workspace, owner_team_id)?;
22
+ let owner_team_id = resolved_owner_team_id.as_deref().or(owner_team_id);
9
23
  let health = crate::coordinator::coordinator_health(
10
24
  &crate::coordinator::WorkspacePath::new(workspace.to_path_buf()),
11
25
  );
@@ -30,11 +44,11 @@ use crate::transport::Transport;
30
44
  "leader_receiver": leader_receiver,
31
45
  "teams": state.get("teams").cloned().unwrap_or_else(|| json!({})),
32
46
  "agents": agents,
33
- "agent_health": agent_health(&conn)?,
47
+ "agent_health": agent_health(&conn, owner_team_id)?,
34
48
  "tasks": tasks,
35
- "messages": message_counts(&conn)?,
36
- "queued_messages": queued_messages(&conn, 8)?,
37
- "results": result_counts(&conn)?,
49
+ "messages": message_counts(&conn, owner_team_id)?,
50
+ "queued_messages": queued_messages(&conn, owner_team_id, 8)?,
51
+ "results": result_counts(&conn, owner_team_id)?,
38
52
  "latest_results": json!([]),
39
53
  "coordinator": coordinator_health_value(health),
40
54
  "last_events": Value::Array(
@@ -51,7 +65,17 @@ use crate::transport::Transport;
51
65
  }
52
66
  /// `status.format_status(workspace, agent)`(人读)。
53
67
  pub fn format_status(workspace: &Path, agent: Option<&str>) -> Result<String, CliError> {
54
- let status = status(workspace, true, false)?;
68
+ let state = read_runtime_state(workspace);
69
+ format_status_scoped(workspace, &state, None, agent)
70
+ }
71
+
72
+ pub fn format_status_scoped(
73
+ workspace: &Path,
74
+ state: &Value,
75
+ owner_team_id: Option<&str>,
76
+ agent: Option<&str>,
77
+ ) -> Result<String, CliError> {
78
+ let status = status_scoped(workspace, state, owner_team_id, true, false)?;
55
79
  Ok(match agent {
56
80
  Some(agent) => format!("agent {agent}: {}", status.pointer("/agents").is_some()),
57
81
  None => crate::cli::format_status_summary(&status),
@@ -165,6 +189,32 @@ use crate::transport::Transport;
165
189
  .unwrap_or_else(|| json!({}))
166
190
  }
167
191
 
192
+ fn resolve_status_owner_team(
193
+ workspace: &Path,
194
+ owner_team_id: Option<&str>,
195
+ ) -> Result<Option<String>, CliError> {
196
+ let Some(requested) = owner_team_id.filter(|team| !team.is_empty()) else {
197
+ return Ok(None);
198
+ };
199
+ let state = read_runtime_state(workspace);
200
+ match crate::state::projection::resolve_owner_team_id(&state, requested) {
201
+ OwnerTeamResolution::Canonical(canonical) => Ok(Some(canonical)),
202
+ OwnerTeamResolution::LegacyAlias { requested, canonical } => {
203
+ let log = crate::event_log::EventLog::new(workspace);
204
+ crate::messaging::delivery::normalize_owner_team_id_rows(
205
+ workspace,
206
+ &requested,
207
+ &canonical,
208
+ None,
209
+ Some(&log),
210
+ )
211
+ .map_err(CliError::from)?;
212
+ Ok(Some(canonical))
213
+ }
214
+ OwnerTeamResolution::Unresolved { .. } | OwnerTeamResolution::Ambiguous { .. } => Ok(None),
215
+ }
216
+ }
217
+
168
218
  fn agent_window(agent_id: &str, agent_state: &Value) -> String {
169
219
  ["window", "window_name"]
170
220
  .iter()
@@ -224,15 +274,15 @@ use crate::transport::Transport;
224
274
  .unwrap_or(false)
225
275
  }
226
276
 
227
- fn message_counts(conn: &rusqlite::Connection) -> Result<Value, CliError> {
228
- status_counts(conn, "messages")
277
+ fn message_counts(conn: &rusqlite::Connection, owner_team_id: Option<&str>) -> Result<Value, CliError> {
278
+ status_counts(conn, "messages", owner_team_id)
229
279
  }
230
280
 
231
- fn result_counts(conn: &rusqlite::Connection) -> Result<Value, CliError> {
232
- let by_status = result_status_counts(conn)?;
233
- let total = count_rows(conn, "results")?;
234
- let invalid = count_where_status(conn, "results", "invalid")?;
235
- let collected = count_where_status(conn, "results", "collected")?;
281
+ fn result_counts(conn: &rusqlite::Connection, owner_team_id: Option<&str>) -> Result<Value, CliError> {
282
+ let by_status = result_status_counts(conn, owner_team_id)?;
283
+ let total = count_rows(conn, "results", owner_team_id)?;
284
+ let invalid = count_where_status(conn, "results", owner_team_id, "invalid")?;
285
+ let collected = count_where_status(conn, "results", owner_team_id, "collected")?;
236
286
  let uncollected = total.saturating_sub(collected).saturating_sub(invalid);
237
287
  Ok(json!({
238
288
  "total": total,
@@ -243,10 +293,24 @@ use crate::transport::Transport;
243
293
  }))
244
294
  }
245
295
 
246
- fn status_counts(conn: &rusqlite::Connection, table: &str) -> Result<Value, CliError> {
247
- let sql = format!("select status, count(*) from {table} group by status order by status");
296
+ fn status_counts(
297
+ conn: &rusqlite::Connection,
298
+ table: &str,
299
+ owner_team_id: Option<&str>,
300
+ ) -> Result<Value, CliError> {
301
+ let sql = match owner_team_id {
302
+ Some(_) => format!(
303
+ "select status, count(*) from {table}
304
+ where owner_team_id = ?1
305
+ group by status order by status"
306
+ ),
307
+ None => format!("select status, count(*) from {table} group by status order by status"),
308
+ };
248
309
  let mut stmt = conn.prepare(&sql).map_err(|e| CliError::Runtime(e.to_string()))?;
249
- let mut rows = stmt.query([]).map_err(|e| CliError::Runtime(e.to_string()))?;
310
+ let mut rows = match owner_team_id {
311
+ Some(team) => stmt.query(params![team]).map_err(|e| CliError::Runtime(e.to_string()))?,
312
+ None => stmt.query([]).map_err(|e| CliError::Runtime(e.to_string()))?,
313
+ };
250
314
  let mut out = Map::new();
251
315
  while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
252
316
  let status: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
@@ -256,16 +320,28 @@ use crate::transport::Transport;
256
320
  Ok(Value::Object(out))
257
321
  }
258
322
 
259
- fn result_status_counts(conn: &rusqlite::Connection) -> Result<Value, CliError> {
260
- let mut stmt = conn
261
- .prepare(
323
+ fn result_status_counts(conn: &rusqlite::Connection, owner_team_id: Option<&str>) -> Result<Value, CliError> {
324
+ let sql = match owner_team_id {
325
+ Some(_) => {
326
+ "select status, count(*) from results
327
+ where status not in ('collected', 'invalid') and owner_team_id = ?1
328
+ group by status
329
+ order by status"
330
+ }
331
+ None => {
262
332
  "select status, count(*) from results
263
333
  where status not in ('collected', 'invalid')
264
334
  group by status
265
- order by status",
266
- )
335
+ order by status"
336
+ }
337
+ };
338
+ let mut stmt = conn
339
+ .prepare(sql)
267
340
  .map_err(|e| CliError::Runtime(e.to_string()))?;
268
- let mut rows = stmt.query([]).map_err(|e| CliError::Runtime(e.to_string()))?;
341
+ let mut rows = match owner_team_id {
342
+ Some(team) => stmt.query(params![team]).map_err(|e| CliError::Runtime(e.to_string()))?,
343
+ None => stmt.query([]).map_err(|e| CliError::Runtime(e.to_string()))?,
344
+ };
269
345
  let mut out = Map::new();
270
346
  while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
271
347
  let status: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
@@ -275,19 +351,32 @@ use crate::transport::Transport;
275
351
  Ok(Value::Object(out))
276
352
  }
277
353
 
278
- fn queued_messages(conn: &rusqlite::Connection, limit: usize) -> Result<Value, CliError> {
354
+ fn queued_messages(
355
+ conn: &rusqlite::Connection,
356
+ owner_team_id: Option<&str>,
357
+ limit: usize,
358
+ ) -> Result<Value, CliError> {
279
359
  let limit = i64::try_from(limit).unwrap_or(i64::MAX);
280
- let mut stmt = conn
281
- .prepare(
360
+ let sql = match owner_team_id {
361
+ Some(_) => {
362
+ "select message_id, recipient, status, created_at, delivery_attempts
363
+ from messages
364
+ where status like 'queued%' and owner_team_id = ?1
365
+ order by created_at desc
366
+ limit ?2"
367
+ }
368
+ None => {
282
369
  "select message_id, recipient, status, created_at, delivery_attempts
283
370
  from messages
284
371
  where status like 'queued%'
285
372
  order by created_at desc
286
- limit ?1",
287
- )
373
+ limit ?1"
374
+ }
375
+ };
376
+ let mut stmt = conn
377
+ .prepare(sql)
288
378
  .map_err(|e| CliError::Runtime(e.to_string()))?;
289
- let rows = stmt
290
- .query_map([limit], |row| {
379
+ let map_row = |row: &rusqlite::Row<'_>| {
291
380
  Ok(json!({
292
381
  "message_id": row.get::<_, String>(0)?,
293
382
  "recipient": row.get::<_, Option<String>>(1)?,
@@ -295,8 +384,12 @@ use crate::transport::Transport;
295
384
  "created_at": row.get::<_, Option<String>>(3)?,
296
385
  "delivery_attempts": row.get::<_, i64>(4)?,
297
386
  }))
298
- })
299
- .map_err(|e| CliError::Runtime(e.to_string()))?;
387
+ };
388
+ let rows = match owner_team_id {
389
+ Some(team) => stmt.query_map(params![team, limit], map_row),
390
+ None => stmt.query_map(params![limit], map_row),
391
+ }
392
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
300
393
  let values = rows
301
394
  .collect::<Result<Vec<_>, _>>()
302
395
  .map_err(|e| CliError::Runtime(e.to_string()))?;
@@ -410,30 +503,63 @@ use crate::transport::Transport;
410
503
  Value::Array(items.iter().skip(start).cloned().collect())
411
504
  }
412
505
 
413
- fn count_rows(conn: &rusqlite::Connection, table: &str) -> Result<i64, CliError> {
414
- let sql = format!("select count(*) from {table}");
415
- conn.query_row(&sql, [], |row| row.get::<_, i64>(0))
416
- .map_err(|e| CliError::Runtime(e.to_string()))
506
+ fn count_rows(
507
+ conn: &rusqlite::Connection,
508
+ table: &str,
509
+ owner_team_id: Option<&str>,
510
+ ) -> Result<i64, CliError> {
511
+ match owner_team_id {
512
+ Some(team) => {
513
+ let sql = format!("select count(*) from {table} where owner_team_id = ?1");
514
+ conn.query_row(&sql, [team], |row| row.get::<_, i64>(0))
515
+ .map_err(|e| CliError::Runtime(e.to_string()))
516
+ }
517
+ None => {
518
+ let sql = format!("select count(*) from {table}");
519
+ conn.query_row(&sql, [], |row| row.get::<_, i64>(0))
520
+ .map_err(|e| CliError::Runtime(e.to_string()))
521
+ }
522
+ }
417
523
  }
418
524
 
419
525
  fn count_where_status(
420
526
  conn: &rusqlite::Connection,
421
527
  table: &str,
528
+ owner_team_id: Option<&str>,
422
529
  status: &str,
423
530
  ) -> Result<i64, CliError> {
424
- let sql = format!("select count(*) from {table} where status = ?1");
425
- conn.query_row(&sql, [status], |row| row.get::<_, i64>(0))
426
- .map_err(|e| CliError::Runtime(e.to_string()))
531
+ match owner_team_id {
532
+ Some(team) => {
533
+ let sql = format!("select count(*) from {table} where status = ?1 and owner_team_id = ?2");
534
+ conn.query_row(&sql, params![status, team], |row| row.get::<_, i64>(0))
535
+ .map_err(|e| CliError::Runtime(e.to_string()))
536
+ }
537
+ None => {
538
+ let sql = format!("select count(*) from {table} where status = ?1");
539
+ conn.query_row(&sql, [status], |row| row.get::<_, i64>(0))
540
+ .map_err(|e| CliError::Runtime(e.to_string()))
541
+ }
542
+ }
427
543
  }
428
544
 
429
- fn agent_health(conn: &rusqlite::Connection) -> Result<Value, CliError> {
430
- let mut stmt = conn
431
- .prepare(
545
+ fn agent_health(conn: &rusqlite::Connection, owner_team_id: Option<&str>) -> Result<Value, CliError> {
546
+ let sql = match owner_team_id {
547
+ Some(_) => {
548
+ "select agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at, owner_team_id
549
+ from agent_health where owner_team_id = ?1 order by agent_id"
550
+ }
551
+ None => {
432
552
  "select agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at, owner_team_id
433
- from agent_health order by agent_id",
434
- )
553
+ from agent_health order by agent_id"
554
+ }
555
+ };
556
+ let mut stmt = conn
557
+ .prepare(sql)
435
558
  .map_err(|e| CliError::Runtime(e.to_string()))?;
436
- let mut rows = stmt.query([]).map_err(|e| CliError::Runtime(e.to_string()))?;
559
+ let mut rows = match owner_team_id {
560
+ Some(team) => stmt.query(params![team]).map_err(|e| CliError::Runtime(e.to_string()))?,
561
+ None => stmt.query([]).map_err(|e| CliError::Runtime(e.to_string()))?,
562
+ };
437
563
  let mut out = Map::new();
438
564
  while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
439
565
  let agent_id: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
@@ -152,6 +152,7 @@ fn current_uid() -> Option<String> {
152
152
  let ws = crate::model::paths::team_workspace(&team).unwrap();
153
153
  let _guard = TeamSocketGuard { ws };
154
154
  let args = QuickStartArgs {
155
+ workspace: crate::model::paths::team_workspace(&team).unwrap(),
155
156
  agents_dir: team.clone(),
156
157
  name: None,
157
158
  team_id: None,
@@ -177,6 +178,7 @@ fn current_uid() -> Option<String> {
177
178
  std::fs::write(team.join("TEAM.md"), DELEG_TEAM_MD).unwrap();
178
179
  std::fs::write(team.join("agents").join("broken.md"), DELEG_INVALID_ROLE).unwrap();
179
180
  let args = QuickStartArgs {
181
+ workspace: crate::model::paths::team_workspace(&team).unwrap(),
180
182
  agents_dir: team,
181
183
  name: None,
182
184
  team_id: None,
@@ -253,6 +253,7 @@ pub struct InteractionCounts {
253
253
  /// `quick-start`(`parser.py:105`)。
254
254
  #[derive(Debug, Clone, PartialEq, Eq)]
255
255
  pub struct QuickStartArgs {
256
+ pub workspace: PathBuf,
256
257
  pub agents_dir: PathBuf,
257
258
  pub name: Option<String>,
258
259
  pub team_id: Option<String>,
@@ -3,6 +3,7 @@
3
3
  use std::io::{Read, Seek, SeekFrom};
4
4
  use std::path::{Path, PathBuf};
5
5
  use std::process::{Command, Stdio};
6
+ use std::time::Duration;
6
7
 
7
8
  use serde_json::Value;
8
9
  use thiserror::Error;
@@ -119,6 +120,9 @@ pub fn start_coordinator(workspace: &WorkspacePath) -> Result<StartReport, Start
119
120
  pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopError> {
120
121
  let pid_path = coordinator_pid_path(workspace);
121
122
  if !pid_path.exists() {
123
+ if let Some(report) = stop_discovered_coordinators(workspace)? {
124
+ return Ok(report);
125
+ }
122
126
  return Ok(StopReport {
123
127
  ok: true,
124
128
  status: StopOutcome::Missing,
@@ -134,6 +138,15 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
134
138
  pid: None,
135
139
  });
136
140
  };
141
+ if pid_is_running(pid).ok() == Some(false) {
142
+ remove_file_if_exists(&pid_path)?;
143
+ remove_file_if_exists(&coordinator_meta_path(workspace))?;
144
+ return Ok(StopReport {
145
+ ok: true,
146
+ status: StopOutcome::Missing,
147
+ pid: Some(pid),
148
+ });
149
+ }
137
150
  let Ok(pid_t) = libc::pid_t::try_from(pid.get()) else {
138
151
  return Ok(StopReport {
139
152
  ok: false,
@@ -158,6 +171,123 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
158
171
  })
159
172
  }
160
173
 
174
+ fn stop_discovered_coordinators(
175
+ workspace: &WorkspacePath,
176
+ ) -> Result<Option<StopReport>, StopError> {
177
+ let pids = discover_coordinator_pids(workspace);
178
+ if pids.is_empty() {
179
+ return Ok(None);
180
+ }
181
+
182
+ let mut stopped = None;
183
+ let mut failed = None;
184
+ for pid in pids {
185
+ if terminate_pid(pid) {
186
+ stopped.get_or_insert(pid);
187
+ } else {
188
+ failed.get_or_insert(pid);
189
+ }
190
+ }
191
+ remove_file_if_exists(&coordinator_meta_path(workspace))?;
192
+
193
+ if let Some(pid) = stopped {
194
+ Ok(Some(StopReport {
195
+ ok: true,
196
+ status: StopOutcome::Stopped,
197
+ pid: Some(pid),
198
+ }))
199
+ } else {
200
+ Ok(Some(StopReport {
201
+ ok: false,
202
+ status: StopOutcome::KillFailed,
203
+ pid: failed,
204
+ }))
205
+ }
206
+ }
207
+
208
+ fn discover_coordinator_pids(workspace: &WorkspacePath) -> Vec<Pid> {
209
+ let output = match Command::new("ps")
210
+ .args(["-axo", "pid=,command="])
211
+ .output()
212
+ {
213
+ Ok(output) if output.status.success() => output,
214
+ _ => return Vec::new(),
215
+ };
216
+ let text = String::from_utf8_lossy(&output.stdout);
217
+ let candidates = workspace_match_candidates(workspace.as_path());
218
+ text.lines()
219
+ .filter_map(|line| parse_ps_command_line(line))
220
+ .filter(|(pid, command)| {
221
+ *pid != std::process::id()
222
+ && coordinator_command_matches_workspace(command, &candidates)
223
+ })
224
+ .map(|(pid, _)| Pid::new(pid))
225
+ .collect()
226
+ }
227
+
228
+ fn parse_ps_command_line(line: &str) -> Option<(u32, &str)> {
229
+ let line = line.trim_start();
230
+ let split = line
231
+ .find(char::is_whitespace)
232
+ .unwrap_or(line.len());
233
+ let pid = line.get(..split)?.trim().parse::<u32>().ok()?;
234
+ let command = line.get(split..)?.trim();
235
+ Some((pid, command))
236
+ }
237
+
238
+ fn workspace_match_candidates(workspace: &Path) -> Vec<String> {
239
+ let mut candidates = vec![workspace.to_string_lossy().to_string()];
240
+ if let Ok(canonical) = workspace.canonicalize() {
241
+ let text = canonical.to_string_lossy().to_string();
242
+ if !candidates.iter().any(|candidate| candidate == &text) {
243
+ candidates.push(text);
244
+ }
245
+ }
246
+ candidates
247
+ }
248
+
249
+ fn coordinator_command_matches_workspace(command: &str, workspaces: &[String]) -> bool {
250
+ command
251
+ .split_whitespace()
252
+ .any(|token| token == "team-agent" || token.ends_with("/team-agent"))
253
+ && command.split_whitespace().any(|token| token == "coordinator")
254
+ && command.contains("--workspace")
255
+ && workspaces.iter().any(|workspace| command.contains(workspace))
256
+ }
257
+
258
+ fn terminate_pid(pid: Pid) -> bool {
259
+ if pid_is_running(pid).ok() == Some(false) {
260
+ return true;
261
+ }
262
+ if !send_signal(pid, libc::SIGTERM) {
263
+ return false;
264
+ }
265
+ if wait_until_not_running(pid, Duration::from_millis(750)) {
266
+ return true;
267
+ }
268
+ send_signal(pid, libc::SIGKILL) && wait_until_not_running(pid, Duration::from_millis(750))
269
+ }
270
+
271
+ fn send_signal(pid: Pid, signal: libc::c_int) -> bool {
272
+ let Ok(pid_t) = libc::pid_t::try_from(pid.get()) else {
273
+ return false;
274
+ };
275
+ unsafe { libc::kill(pid_t, signal) == 0 }
276
+ }
277
+
278
+ fn wait_until_not_running(pid: Pid, timeout: Duration) -> bool {
279
+ let start = std::time::Instant::now();
280
+ loop {
281
+ if pid_is_running(pid).ok() != Some(true) {
282
+ return true;
283
+ }
284
+ if start.elapsed() >= timeout {
285
+ return false;
286
+ }
287
+ std::thread::sleep(Duration::from_millis(25));
288
+ }
289
+ }
290
+
161
291
  // ===========================================================================
162
292
  // metadata 身份原语(metadata.py)—— 自由函数面
163
293
  // ===========================================================================
@@ -424,7 +424,8 @@ fn claim_lease_no_incident_with_target(
424
424
  ));
425
425
  }
426
426
  let non_empty_caller_pane = NonEmptyPaneId::try_from_pane(caller_pane)?;
427
- if bound_pane_id.as_deref() == Some(caller_pane.as_str()) {
427
+ let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
428
+ if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
428
429
  return Ok(LeaseResult {
429
430
  ok: true,
430
431
  status: LeaseStatus::AlreadyBound,
@@ -438,7 +439,12 @@ fn claim_lease_no_incident_with_target(
438
439
  }
439
440
  let owner_live = bound_pane_id
440
441
  .as_deref()
441
- .is_some_and(|pane| liveness.liveness(pane) == PaneLiveness::Live);
442
+ .is_some_and(|pane| {
443
+ if pane == caller_pane.as_str() && !bound_endpoint_matches_caller {
444
+ return false;
445
+ }
446
+ liveness.liveness(pane) == PaneLiveness::Live
447
+ });
442
448
  if owner_live && !confirm {
443
449
  emit_lease_refusal(
444
450
  event_log,
@@ -596,6 +602,20 @@ fn bound_pane(state: &Value) -> Option<String> {
596
602
  .or_else(|| get_path_str(state, &["team_owner", "pane_id"]).filter(|v| !v.is_empty()))
597
603
  }
598
604
 
605
+ fn bound_endpoint_matches_current_process(state: &Value) -> bool {
606
+ let Some(bound) = get_path_str(state, &["leader_receiver", "tmux_socket"]).filter(|v| !v.is_empty()) else {
607
+ return true;
608
+ };
609
+ let Some(current) = crate::tmux_backend::socket_name_from_tmux_env() else {
610
+ return false;
611
+ };
612
+ tmux_endpoints_match(&bound, &current)
613
+ }
614
+
615
+ fn tmux_endpoints_match(bound: &str, current: &str) -> bool {
616
+ bound == current
617
+ }
618
+
599
619
  fn prior_provider(state: &Value) -> Provider {
600
620
  get_path_str(state, &["leader_receiver", "provider"])
601
621
  .or_else(|| get_path_str(state, &["team_owner", "provider"]))
@@ -844,6 +864,7 @@ fn make_receiver(
844
864
  pane_index: target.as_ref().and_then(|t| t.pane_index.map(|v| v.to_string())),
845
865
  pane_tty: target.as_ref().and_then(|t| t.tty.clone()),
846
866
  pane_current_command: target.as_ref().and_then(|t| t.current_command.clone()),
867
+ tmux_socket: crate::tmux_backend::socket_name_from_tmux_env(),
847
868
  fingerprint: target.as_ref().map(receiver_fingerprint),
848
869
  leader_session_uuid: Some(uuid.clone()),
849
870
  owner_epoch: Some(epoch),
@@ -31,6 +31,7 @@ fn receiver(pane: &str, uuid: &str, epoch: u64) -> LeaderReceiver {
31
31
  pane_index: None,
32
32
  pane_tty: None,
33
33
  pane_current_command: None,
34
+ tmux_socket: None,
34
35
  fingerprint: None,
35
36
  leader_session_uuid: serde_json::from_value(Value::String(uuid.to_string())).ok(),
36
37
  owner_epoch: Some(OwnerEpoch(epoch)),
@@ -1013,6 +1013,7 @@ fn receiver_from_candidate(
1013
1013
  pane_index: target.pane_index.clone(),
1014
1014
  pane_tty: target.tty.clone(),
1015
1015
  pane_current_command: target.current_command.clone(),
1016
+ tmux_socket: prior.tmux_socket.clone(),
1016
1017
  fingerprint: target.fingerprint.clone(),
1017
1018
  leader_session_uuid: uuid,
1018
1019
  owner_epoch: Some(epoch),
@@ -1035,6 +1036,7 @@ fn empty_prior(provider: Provider, epoch: OwnerEpoch) -> LeaderReceiver {
1035
1036
  pane_index: None,
1036
1037
  pane_tty: None,
1037
1038
  pane_current_command: None,
1039
+ tmux_socket: None,
1038
1040
  fingerprint: None,
1039
1041
  leader_session_uuid: None,
1040
1042
  owner_epoch: Some(epoch),
@@ -18,7 +18,7 @@ use super::*;
18
18
  "golden new_owner includes os_user");
19
19
  }
20
20
 
21
- // D2 [BLOCK] — claim-path leader_receiver is golden's 14 keys in golden order; NO
21
+ // D2 [BLOCK] — claim-path leader_receiver is golden's 15 keys in golden order; NO
22
22
  // fingerprint/requested_provider/warning. Golden _receiver_from_claim_target (__init__.py:861-877).
23
23
  // Rust LeaderReceiver serializes all 17 (no skip_serializing_if) -> 3 always-null extras leak. RED.
24
24
  // (The POPULATED tmux values session_name/window_*/pane_* come from the caller-target scan — a
@@ -26,9 +26,12 @@ use super::*;
26
26
  // locked here are unchanged by that scan.)
27
27
  #[test]
28
28
  #[serial_test::serial(env)]
29
- fn d2_claim_leader_receiver_is_fourteen_golden_keys_in_order_no_extras() {
29
+ fn d2_claim_leader_receiver_is_fifteen_golden_keys_in_order_no_extras() {
30
30
  let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
31
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
31
+ let _e = EnvGuard::apply(&[
32
+ ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
33
+ ("TMUX", Some("/tmp/tmux-501/default,123,0")),
34
+ ]);
32
35
  let ws = p2_temp_ws("d2_recv_keys");
33
36
  let mut state = serde_json::json!({"session_name": "team-agent-x"});
34
37
  let r = claim_lease_no_incident(&ws, &mut state, None, &TeamKey::new("current"),
@@ -42,9 +45,9 @@ use super::*;
42
45
  let keys: Vec<&str> = recv.keys().map(String::as_str).collect();
43
46
  assert_eq!(keys, vec![
44
47
  "mode","status","provider","pane_id","session_name","window_index","window_name",
45
- "pane_index","pane_tty","pane_current_command","leader_session_uuid","owner_epoch",
46
- "attached_at","discovery",
47
- ], "golden _receiver_from_claim_target 14-key set + ORDER (__init__.py:861-877)");
48
+ "pane_index","pane_tty","pane_current_command","tmux_socket","leader_session_uuid",
49
+ "owner_epoch","attached_at","discovery",
50
+ ], "golden _receiver_from_claim_target 15-key set + ORDER (__init__.py:861-877 + BUG-4 socket-qualified receiver)");
48
51
  }
49
52
 
50
53
  // D2 seam — the caller-target SCAN that fills session_name/window_index/window_name/pane_index/
@@ -197,6 +197,7 @@ use super::*;
197
197
  pane_index: Some("2".into()),
198
198
  pane_tty: Some("/dev/ttys001".into()),
199
199
  pane_current_command: Some("claude".into()),
200
+ tmux_socket: None,
200
201
  fingerprint: Some("fp".into()),
201
202
  leader_session_uuid: Some(uuid("fp", "/ws", "u", "default")),
202
203
  owner_epoch: Some(OwnerEpoch(3)),