@team-agent/installer 0.3.19 → 0.3.20

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.
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.19"
569
+ version = "0.3.20"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.19"
12
+ version = "0.3.20"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -34,21 +34,42 @@ pub struct DaemonArgs {
34
34
  /// §10:返 `Result`(顶层 bin 用 anyhow 收;§12 边界)。
35
35
  pub fn run_daemon(args: DaemonArgs) -> Result<(), DaemonError> {
36
36
  // CP-1: the daemon's whole tick surface (has_session / capture / inject / list_windows / kill)
37
- // runs through this backend bind it to the per-team socket so a dying shared `default` server
38
- // can no longer tear the team down. The daemon knows its --workspace.
37
+ // runs through this backend. Prefer the persisted runtime endpoint so attached explicit-socket
38
+ // teams are checked on the same socket as lifecycle worker operations.
39
+ let state = crate::state::persist::load_runtime_state(args.workspace.as_path()).ok();
40
+ let tmux_selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
41
+ args.workspace.as_path(),
42
+ state.as_ref(),
43
+ );
44
+ let tmux_metadata = DaemonTmuxEndpointMetadata {
45
+ tmux_endpoint_used: tmux_selection.tmux_endpoint_used.clone(),
46
+ tmux_endpoint_source: tmux_selection.tmux_endpoint_source.as_str(),
47
+ };
39
48
  let coordinator = Coordinator::new(
40
49
  args.workspace.clone(),
41
50
  Box::new(RealProviderRegistry),
42
- Box::new(crate::tmux_backend::TmuxBackend::for_workspace(
43
- args.workspace.as_path(),
44
- )),
51
+ Box::new(tmux_selection.backend),
45
52
  );
46
- run_daemon_with_coordinator(&args, &coordinator)
53
+ run_daemon_with_coordinator_and_boot_tmux(&args, &coordinator, Some(tmux_metadata))
47
54
  }
48
55
 
49
56
  pub fn run_daemon_with_coordinator(
50
57
  args: &DaemonArgs,
51
58
  coordinator: &Coordinator,
59
+ ) -> Result<(), DaemonError> {
60
+ run_daemon_with_coordinator_and_boot_tmux(args, coordinator, None)
61
+ }
62
+
63
+ #[derive(Debug, Clone)]
64
+ struct DaemonTmuxEndpointMetadata {
65
+ tmux_endpoint_used: Option<String>,
66
+ tmux_endpoint_source: &'static str,
67
+ }
68
+
69
+ fn run_daemon_with_coordinator_and_boot_tmux(
70
+ args: &DaemonArgs,
71
+ coordinator: &Coordinator,
72
+ tmux_metadata: Option<DaemonTmuxEndpointMetadata>,
52
73
  ) -> Result<(), DaemonError> {
53
74
  let runtime_dir = crate::model::paths::runtime_dir(args.workspace.as_path());
54
75
  std::fs::create_dir_all(&runtime_dir)?;
@@ -57,13 +78,26 @@ pub fn run_daemon_with_coordinator(
57
78
  write_coordinator_metadata(&args.workspace, pid, MetadataSource::Boot)?;
58
79
 
59
80
  let event_log = EventLog::new(args.workspace.as_path());
60
- event_log.write(
61
- "coordinator.boot",
62
- serde_json::json!({
63
- "workspace": args.workspace.as_path().to_string_lossy(),
64
- "once": args.once,
65
- }),
66
- )?;
81
+ let mut boot_event = serde_json::json!({
82
+ "workspace": args.workspace.as_path().to_string_lossy(),
83
+ "once": args.once,
84
+ });
85
+ if let Some(metadata) = tmux_metadata {
86
+ if let Some(object) = boot_event.as_object_mut() {
87
+ object.insert(
88
+ "tmux_endpoint_source".to_string(),
89
+ serde_json::Value::String(metadata.tmux_endpoint_source.to_string()),
90
+ );
91
+ object.insert(
92
+ "tmux_endpoint_used".to_string(),
93
+ metadata
94
+ .tmux_endpoint_used
95
+ .map(serde_json::Value::String)
96
+ .unwrap_or(serde_json::Value::Null),
97
+ );
98
+ }
99
+ }
100
+ event_log.write("coordinator.boot", boot_event)?;
67
101
  let tick_interval = match args.tick_interval_sec {
68
102
  Some(v) if v > 0.0 => v,
69
103
  _ => resolve_tick_interval(&args.workspace)?,
@@ -209,6 +209,156 @@ fn coord_over_staged_tmux(
209
209
  (coord, dir, seen)
210
210
  }
211
211
 
212
+ fn coord_over_runtime_state_tmux_endpoint(
213
+ session_name: &str,
214
+ endpoint: &str,
215
+ steps: Vec<RunnerStep>,
216
+ last: RunnerStep,
217
+ ) -> (
218
+ Coordinator,
219
+ std::path::PathBuf,
220
+ std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
221
+ Option<String>,
222
+ &'static str,
223
+ ) {
224
+ use std::sync::atomic::{AtomicU64, Ordering};
225
+ static N: AtomicU64 = AtomicU64::new(0);
226
+ let dir = std::env::temp_dir().join(format!(
227
+ "ta-rs-e27-coord-explicit-{}-{}",
228
+ std::process::id(),
229
+ N.fetch_add(1, Ordering::Relaxed)
230
+ ));
231
+ std::fs::create_dir_all(crate::model::paths::runtime_dir(&dir)).unwrap();
232
+ let _ = crate::message_store::MessageStore::open(&dir).unwrap();
233
+ let state = serde_json::json!({
234
+ "session_name": session_name,
235
+ "tmux_endpoint": endpoint,
236
+ "tmux_socket": endpoint,
237
+ "agents": {},
238
+ });
239
+ crate::state::persist::save_runtime_state(&dir, &state).unwrap();
240
+ let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
241
+ let runner = StagedTmuxRunner {
242
+ steps: std::sync::Mutex::new(steps.into_iter().collect()),
243
+ last,
244
+ seen: std::sync::Arc::clone(&seen),
245
+ };
246
+ let selection = crate::tmux_backend::tmux_backend_with_runner_for_runtime_state_or_workspace(
247
+ Box::new(runner),
248
+ &dir,
249
+ Some(&state),
250
+ );
251
+ let endpoint_used = selection.tmux_endpoint_used.clone();
252
+ let endpoint_source = selection.tmux_endpoint_source.as_str();
253
+ let reg: Box<dyn ProviderRegistry> = Box::new(MockRegistry::new(&[], &[]));
254
+ let coord = Coordinator::for_test(
255
+ WorkspacePath::new(dir.clone()),
256
+ reg,
257
+ Box::new(selection.backend),
258
+ None,
259
+ None,
260
+ );
261
+ (coord, dir, seen, endpoint_used, endpoint_source)
262
+ }
263
+
264
+ #[test]
265
+ fn e27_coordinator_tick_uses_runtime_explicit_endpoint_for_session_gate() {
266
+ let session = "team-e27-explicit-restart";
267
+ let endpoint = "/tmp/e27-explicit-restart-test.sock";
268
+ let (coord, dir, seen, endpoint_used, endpoint_source) = coord_over_runtime_state_tmux_endpoint(
269
+ session,
270
+ endpoint,
271
+ vec![RunnerStep::Exit(true)],
272
+ RunnerStep::Exit(true),
273
+ );
274
+ assert_eq!(endpoint_used.as_deref(), Some(endpoint));
275
+ assert_eq!(endpoint_source, "state.tmux_endpoint");
276
+
277
+ let report = coord
278
+ .tick()
279
+ .expect("explicit endpoint with present session should tick");
280
+ assert!(
281
+ report.ok,
282
+ "present explicit endpoint session should keep tick ok"
283
+ );
284
+ assert!(
285
+ !report.stop,
286
+ "present explicit endpoint session must not trip the session-missing stop gate"
287
+ );
288
+ let calls = seen.lock().unwrap().clone();
289
+ assert!(
290
+ calls
291
+ .iter()
292
+ .all(|argv| !argv.iter().any(|part| part == "-L")),
293
+ "explicit endpoint coordinator must not fall back to workspace -L socket; got {calls:?}"
294
+ );
295
+ assert_eq!(
296
+ calls.first(),
297
+ Some(&vec![
298
+ "tmux".to_string(),
299
+ "-S".to_string(),
300
+ endpoint.to_string(),
301
+ "has-session".to_string(),
302
+ "-t".to_string(),
303
+ session.to_string(),
304
+ ]),
305
+ "session gate must probe the persisted explicit endpoint"
306
+ );
307
+ let events = read_event_log_dir(&dir);
308
+ assert!(
309
+ events
310
+ .iter()
311
+ .all(|event| event.get("event").and_then(|v| v.as_str())
312
+ != Some("coordinator.session_missing")),
313
+ "present explicit endpoint session must not emit coordinator.session_missing; got {events:?}"
314
+ );
315
+ }
316
+
317
+ #[test]
318
+ fn e27_coordinator_tick_still_stops_when_explicit_endpoint_session_is_missing() {
319
+ let session = "team-e27-explicit-restart";
320
+ let endpoint = "/tmp/e27-explicit-restart-test.sock";
321
+ let (coord, dir, seen, endpoint_used, endpoint_source) = coord_over_runtime_state_tmux_endpoint(
322
+ session,
323
+ endpoint,
324
+ vec![RunnerStep::Exit(false)],
325
+ RunnerStep::Exit(false),
326
+ );
327
+ assert_eq!(endpoint_used.as_deref(), Some(endpoint));
328
+ assert_eq!(endpoint_source, "state.tmux_endpoint");
329
+
330
+ let report = coord
331
+ .tick()
332
+ .expect("definitive missing session is a typed stop report");
333
+ assert!(!report.ok, "missing explicit endpoint session => ok=false");
334
+ assert!(
335
+ report.stop,
336
+ "genuine missing session on the selected explicit endpoint must still stop"
337
+ );
338
+ assert_eq!(report.reason, Some(TickStopReason::TmuxSessionMissing));
339
+ let calls = seen.lock().unwrap().clone();
340
+ assert_eq!(
341
+ calls.first(),
342
+ Some(&vec![
343
+ "tmux".to_string(),
344
+ "-S".to_string(),
345
+ endpoint.to_string(),
346
+ "has-session".to_string(),
347
+ "-t".to_string(),
348
+ session.to_string(),
349
+ ]),
350
+ "negative control must also probe the persisted explicit endpoint"
351
+ );
352
+ let events = read_event_log_dir(&dir);
353
+ assert!(
354
+ events
355
+ .iter()
356
+ .any(|event| event.get("event").and_then(|v| v.as_str())
357
+ == Some("coordinator.session_missing")),
358
+ "genuine explicit endpoint miss must still emit coordinator.session_missing; got {events:?}"
359
+ );
360
+ }
361
+
212
362
  // ── 2(a) tick TOLERATES a has-session TIMEOUT as Err (NOT a definitive miss) — LOCK ───────────────
213
363
  #[test]
214
364
  fn tick_tolerates_has_session_timeout_as_transport_err_not_session_missing() {
@@ -17,6 +17,8 @@ pub fn start_agent(
17
17
  team: Option<&str>,
18
18
  ) -> Result<StartAgentOutcome, LifecycleError> {
19
19
  let paths = lifecycle_paths(workspace, team)?;
20
+ let transport =
21
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
20
22
  start_agent_at_paths(
21
23
  &paths.run_workspace,
22
24
  &paths.spec_workspace,
@@ -25,7 +27,7 @@ pub fn start_agent(
25
27
  open_display,
26
28
  allow_fresh,
27
29
  team,
28
- &crate::tmux_backend::TmuxBackend::for_workspace(&paths.run_workspace),
30
+ &transport,
29
31
  )
30
32
  }
31
33
 
@@ -267,11 +269,15 @@ pub fn stop_agent(
267
269
  agent_id: &AgentId,
268
270
  team: Option<&str>,
269
271
  ) -> Result<StopAgentReport, LifecycleError> {
270
- stop_agent_with_transport(
271
- workspace,
272
+ let paths = lifecycle_paths(workspace, team)?;
273
+ let transport =
274
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
275
+ stop_agent_at_paths(
276
+ &paths.run_workspace,
277
+ &paths.spec_workspace,
272
278
  agent_id,
273
279
  team,
274
- &crate::tmux_backend::TmuxBackend::for_workspace(&lifecycle_run_workspace(workspace)?),
280
+ &transport,
275
281
  )
276
282
  }
277
283
 
@@ -473,13 +479,22 @@ pub fn reset_agent(
473
479
  open_display: bool,
474
480
  team: Option<&str>,
475
481
  ) -> Result<ResetAgentOutcome, LifecycleError> {
476
- reset_agent_with_transport(
477
- workspace,
482
+ if !discard_session {
483
+ return Ok(ResetAgentOutcome::Refused {
484
+ reason: ResetRefusal::DiscardSessionRequired,
485
+ });
486
+ }
487
+ let paths = lifecycle_paths(workspace, team)?;
488
+ let transport =
489
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
490
+ reset_agent_at_paths(
491
+ &paths.run_workspace,
492
+ &paths.spec_workspace,
478
493
  agent_id,
479
494
  discard_session,
480
495
  open_display,
481
496
  team,
482
- &crate::tmux_backend::TmuxBackend::for_workspace(&lifecycle_run_workspace(workspace)?),
497
+ &transport,
483
498
  )
484
499
  }
485
500
 
@@ -208,6 +208,38 @@ pub(super) fn start_coordinator_for_workspace(workspace: &Path) -> Result<bool,
208
208
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
209
209
  }
210
210
 
211
+ pub(super) fn lifecycle_worker_tmux_backend_for_selected_state(
212
+ run_workspace: &Path,
213
+ team: Option<&str>,
214
+ ) -> Result<crate::tmux_backend::TmuxBackend, LifecycleError> {
215
+ let (state, refusal) = crate::state::projection::resolve_team_scoped_state(run_workspace, team)
216
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
217
+ if let Some(refusal) = refusal {
218
+ let reason = refusal
219
+ .get("reason")
220
+ .and_then(|v| v.as_str())
221
+ .unwrap_or("team_target_unresolved");
222
+ let detail = refusal
223
+ .get("error")
224
+ .or_else(|| refusal.get("message"))
225
+ .and_then(|v| v.as_str())
226
+ .unwrap_or("");
227
+ return Err(LifecycleError::TeamSelect(format!("{reason}: {detail}")));
228
+ }
229
+ Ok(state
230
+ .as_ref()
231
+ .map(|state| lifecycle_worker_tmux_backend_for_state(run_workspace, state))
232
+ .unwrap_or_else(|| crate::tmux_backend::TmuxBackend::for_workspace(run_workspace)))
233
+ }
234
+
235
+ pub(super) fn lifecycle_worker_tmux_backend_for_state(
236
+ run_workspace: &Path,
237
+ state: &serde_json::Value,
238
+ ) -> crate::tmux_backend::TmuxBackend {
239
+ crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(run_workspace, Some(state))
240
+ .backend
241
+ }
242
+
211
243
  pub(super) fn persist_effective_approval_policy_for_restart(
212
244
  agent: &mut serde_json::Map<String, serde_json::Value>,
213
245
  safety: &DangerousApproval,
@@ -22,12 +22,14 @@ pub fn restart_with_session_convergence_deadline(
22
22
  team: Option<&str>,
23
23
  session_converge_deadline_ms: Option<u64>,
24
24
  ) -> Result<RestartReport, LifecycleError> {
25
- let run_ws = lifecycle_run_workspace(workspace)?;
25
+ let paths = lifecycle_paths(workspace, team)?;
26
+ let transport =
27
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
26
28
  restart_with_transport_with_session_convergence_deadline(
27
29
  workspace,
28
30
  allow_fresh,
29
31
  team,
30
- &crate::tmux_backend::TmuxBackend::for_workspace(&run_ws),
32
+ &transport,
31
33
  session_converge_deadline_ms,
32
34
  None,
33
35
  )
@@ -13,13 +13,17 @@ pub fn remove_agent(
13
13
  force: bool,
14
14
  team: Option<&str>,
15
15
  ) -> Result<RemoveAgentOutcome, LifecycleError> {
16
- remove_agent_with_transport(
17
- workspace,
16
+ let paths = lifecycle_paths(workspace, team)?;
17
+ let transport =
18
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
19
+ remove_agent_at_paths(
20
+ &paths.run_workspace,
21
+ &paths.spec_workspace,
18
22
  agent_id,
19
23
  from_spec,
20
24
  force,
21
25
  team,
22
- &crate::tmux_backend::TmuxBackend::for_workspace(&lifecycle_run_workspace(workspace)?),
26
+ &transport,
23
27
  )
24
28
  }
25
29
 
@@ -260,6 +260,228 @@ fn bug_a_stop_agent_team_dir_input_kills_existing_window_real_machine() {
260
260
  assert!(report.stopped, "existing worker window must be killed, not silently reported absent");
261
261
  }
262
262
 
263
+ struct EnvVarGuard {
264
+ key: &'static str,
265
+ previous: Option<String>,
266
+ }
267
+
268
+ impl EnvVarGuard {
269
+ fn set(key: &'static str, value: &str) -> Self {
270
+ let previous = std::env::var(key).ok();
271
+ unsafe {
272
+ std::env::set_var(key, value);
273
+ }
274
+ Self { key, previous }
275
+ }
276
+ }
277
+
278
+ impl Drop for EnvVarGuard {
279
+ fn drop(&mut self) {
280
+ unsafe {
281
+ if let Some(value) = self.previous.take() {
282
+ std::env::set_var(self.key, value);
283
+ } else {
284
+ std::env::remove_var(self.key);
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ struct TmuxShim {
291
+ log: std::path::PathBuf,
292
+ _path: EnvVarGuard,
293
+ _log: EnvVarGuard,
294
+ _endpoint: EnvVarGuard,
295
+ _session: EnvVarGuard,
296
+ _pane: EnvVarGuard,
297
+ _real_tmux: EnvVarGuard,
298
+ }
299
+
300
+ fn install_e27_tmux_shim(expected_endpoint: &str, session_name: &str) -> TmuxShim {
301
+ use std::os::unix::fs::PermissionsExt;
302
+
303
+ let root = temp_ws().join("e27_tmux_shim");
304
+ let bin = root.join("bin");
305
+ std::fs::create_dir_all(&bin).unwrap();
306
+ let log = root.join("tmux-argv.log");
307
+ let tmux = bin.join("tmux");
308
+ let real_tmux = std::process::Command::new("sh")
309
+ .arg("-lc")
310
+ .arg("command -v tmux || true")
311
+ .output()
312
+ .ok()
313
+ .and_then(|out| String::from_utf8(out.stdout).ok())
314
+ .map(|s| s.trim().to_string())
315
+ .filter(|s| !s.is_empty())
316
+ .unwrap_or_default();
317
+ std::fs::write(
318
+ &tmux,
319
+ r#"#!/bin/sh
320
+ set -eu
321
+ log="${TEAM_AGENT_E27_TMUX_LOG}"
322
+ expected="${TEAM_AGENT_E27_EXPECTED_ENDPOINT}"
323
+ session="${TEAM_AGENT_E27_SESSION_NAME}"
324
+ pane="${TEAM_AGENT_E27_PANE_ID}"
325
+ track=0
326
+ case "$*" in
327
+ *"$expected"*|*"$session"*|*"$pane"*) track=1 ;;
328
+ esac
329
+ if [ "$track" != 1 ]; then
330
+ if [ -n "${TEAM_AGENT_E27_REAL_TMUX}" ]; then
331
+ exec "${TEAM_AGENT_E27_REAL_TMUX}" "$@"
332
+ fi
333
+ exit 127
334
+ fi
335
+ printf '%s\n' "$*" >> "$log"
336
+ case "$*" in
337
+ *"-S $expected"*) ;;
338
+ *)
339
+ echo "missing expected socket $expected: $*" >&2
340
+ exit 18
341
+ ;;
342
+ esac
343
+ case "$*" in
344
+ *"display-message -p -t $pane #{pane_id}"*)
345
+ printf '%s\n' "$pane"
346
+ exit 0
347
+ ;;
348
+ *"display-message -p -t $session:alpha #{pane_id}"*)
349
+ printf '%%9288\n'
350
+ exit 0
351
+ ;;
352
+ *"list-windows -t $session -F #{window_name}"*)
353
+ exit 0
354
+ ;;
355
+ *"has-session -t $session"*)
356
+ exit 0
357
+ ;;
358
+ *)
359
+ exit 0
360
+ ;;
361
+ esac
362
+ "#,
363
+ )
364
+ .unwrap();
365
+ let mut perms = std::fs::metadata(&tmux).unwrap().permissions();
366
+ perms.set_mode(0o755);
367
+ std::fs::set_permissions(&tmux, perms).unwrap();
368
+ let old_path = std::env::var("PATH").unwrap_or_default();
369
+ TmuxShim {
370
+ log: log.clone(),
371
+ _path: EnvVarGuard::set("PATH", &format!("{}:{old_path}", bin.display())),
372
+ _log: EnvVarGuard::set("TEAM_AGENT_E27_TMUX_LOG", &log.to_string_lossy()),
373
+ _endpoint: EnvVarGuard::set("TEAM_AGENT_E27_EXPECTED_ENDPOINT", expected_endpoint),
374
+ _session: EnvVarGuard::set("TEAM_AGENT_E27_SESSION_NAME", session_name),
375
+ _pane: EnvVarGuard::set("TEAM_AGENT_E27_PANE_ID", "%9277"),
376
+ _real_tmux: EnvVarGuard::set("TEAM_AGENT_E27_REAL_TMUX", &real_tmux),
377
+ }
378
+ }
379
+
380
+ fn seed_e27_attached_socket_state(ws: &std::path::Path, endpoint: &str, session_name: &str) {
381
+ let mut state = crate::state::persist::load_runtime_state(ws).unwrap();
382
+ let obj = state.as_object_mut().unwrap();
383
+ obj.insert("session_name".to_string(), json!(session_name));
384
+ obj.insert("tmux_endpoint".to_string(), json!(endpoint));
385
+ obj.insert("tmux_socket".to_string(), json!(endpoint));
386
+ let alpha = obj
387
+ .get_mut("agents")
388
+ .and_then(serde_json::Value::as_object_mut)
389
+ .and_then(|agents| agents.get_mut("alpha"))
390
+ .and_then(serde_json::Value::as_object_mut)
391
+ .unwrap();
392
+ alpha.insert("status".to_string(), json!("running"));
393
+ alpha.insert("provider".to_string(), json!("fake"));
394
+ alpha.insert("window".to_string(), json!("alpha"));
395
+ alpha.insert("pane_id".to_string(), json!("%9277"));
396
+ alpha.insert("session_id".to_string(), json!("old-session"));
397
+ crate::state::persist::save_runtime_state(ws, &state).unwrap();
398
+ seed_healthy_coordinator(ws);
399
+ }
400
+
401
+ fn assert_only_expected_socket_used(log: &std::path::Path, expected_endpoint: &str) {
402
+ let raw = std::fs::read_to_string(log).unwrap();
403
+ assert!(!raw.is_empty(), "tmux shim was not invoked");
404
+ assert!(
405
+ !raw.contains("-L ta-"),
406
+ "lifecycle worker operation must not use workspace-derived -L ta-* socket; argv log:\n{raw}"
407
+ );
408
+ for line in raw.lines() {
409
+ assert!(
410
+ line.contains(&format!("-S {expected_endpoint}")),
411
+ "tmux argv must use state endpoint {expected_endpoint}; got line {line:?}; full log:\n{raw}"
412
+ );
413
+ }
414
+ }
415
+
416
+ #[test]
417
+ #[serial_test::serial(env)]
418
+ fn e27_stop_agent_uses_attached_explicit_state_socket() {
419
+ let endpoint = "/tmp/loop13-e27-explicit.sock";
420
+ let session_name = "team-e27-explicit-stop";
421
+ let ws = lanea_team_ws("running");
422
+ seed_e27_attached_socket_state(&ws, endpoint, session_name);
423
+ let shim = install_e27_tmux_shim(endpoint, session_name);
424
+
425
+ let report = stop_agent(&ws, &aid("alpha"), None).expect("stop-agent");
426
+
427
+ assert!(report.stopped, "attached explicit-socket worker should be stopped");
428
+ assert_only_expected_socket_used(&shim.log, endpoint);
429
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
430
+ assert_eq!(
431
+ state.pointer("/agents/alpha/status").and_then(serde_json::Value::as_str),
432
+ Some("stopped")
433
+ );
434
+ }
435
+
436
+ #[test]
437
+ #[serial_test::serial(env)]
438
+ fn e27_reset_agent_uses_attached_explicit_state_socket_for_stop_and_spawn() {
439
+ let endpoint = "/tmp/loop13-e27-explicit.sock";
440
+ let session_name = "team-e27-explicit-reset";
441
+ let ws = lanea_team_ws("running");
442
+ seed_e27_attached_socket_state(&ws, endpoint, session_name);
443
+ let shim = install_e27_tmux_shim(endpoint, session_name);
444
+
445
+ let outcome = reset_agent(&ws, &aid("alpha"), true, false, None).expect("reset-agent");
446
+
447
+ assert!(
448
+ matches!(outcome, ResetAgentOutcome::Reset { .. }),
449
+ "reset-agent should complete over attached explicit socket; got {outcome:?}"
450
+ );
451
+ assert_only_expected_socket_used(&shim.log, endpoint);
452
+ let raw = std::fs::read_to_string(&shim.log).unwrap();
453
+ assert!(raw.contains("kill-pane"), "reset must stop the old pane first; argv log:\n{raw}");
454
+ assert!(
455
+ raw.contains("new-window") || raw.contains("new-session"),
456
+ "reset must respawn the worker through the same endpoint; argv log:\n{raw}"
457
+ );
458
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
459
+ assert_ne!(
460
+ state.pointer("/agents/alpha/session_id").and_then(serde_json::Value::as_str),
461
+ Some("old-session"),
462
+ "discard-session must not preserve the old session id"
463
+ );
464
+ }
465
+
466
+ #[test]
467
+ #[serial_test::serial(env)]
468
+ fn e27_stop_agent_expands_short_state_socket_name() {
469
+ let short = "ta-loop13-e27-short";
470
+ let session_name = "team-e27-short-stop";
471
+ let expected = crate::tmux_backend::socket_path_for_name(short)
472
+ .unwrap()
473
+ .to_string_lossy()
474
+ .to_string();
475
+ let ws = lanea_team_ws("running");
476
+ seed_e27_attached_socket_state(&ws, short, session_name);
477
+ let shim = install_e27_tmux_shim(&expected, session_name);
478
+
479
+ let report = stop_agent(&ws, &aid("alpha"), None).expect("stop-agent short endpoint");
480
+
481
+ assert!(report.stopped, "short endpoint worker should be stopped");
482
+ assert_only_expected_socket_used(&shim.log, &expected);
483
+ }
484
+
263
485
  // ═════════════════════════════════════════════════════════════════════════
264
486
  // WAVE-2 · LANE A v2 — DEEPENED byte-parity REDs (stop / reset / remove / fork).
265
487
  //
@@ -188,6 +188,29 @@ impl TmuxSocketEndpoint {
188
188
  }
189
189
  }
190
190
 
191
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
192
+ pub(crate) enum RuntimeTmuxEndpointSource {
193
+ StateTmuxEndpoint,
194
+ StateTmuxSocket,
195
+ WorkspaceFallback,
196
+ }
197
+
198
+ impl RuntimeTmuxEndpointSource {
199
+ pub(crate) fn as_str(self) -> &'static str {
200
+ match self {
201
+ Self::StateTmuxEndpoint => "state.tmux_endpoint",
202
+ Self::StateTmuxSocket => "state.tmux_socket",
203
+ Self::WorkspaceFallback => "workspace_fallback",
204
+ }
205
+ }
206
+ }
207
+
208
+ pub(crate) struct RuntimeTmuxBackendSelection {
209
+ pub(crate) backend: TmuxBackend,
210
+ pub(crate) tmux_endpoint_used: Option<String>,
211
+ pub(crate) tmux_endpoint_source: RuntimeTmuxEndpointSource,
212
+ }
213
+
191
214
  impl TmuxBackend {
192
215
  /// Backend bound to the real `tmux` subprocess on the SHARED default socket (no `-L`).
193
216
  /// Non-team callers + existing argv/unit tests stay unaffected.
@@ -338,6 +361,70 @@ impl TmuxBackend {
338
361
  }
339
362
  }
340
363
 
364
+ pub(crate) fn tmux_backend_for_runtime_state_or_workspace(
365
+ workspace: &Path,
366
+ state: Option<&serde_json::Value>,
367
+ ) -> RuntimeTmuxBackendSelection {
368
+ let (backend, source) =
369
+ if let Some((endpoint, source)) = runtime_tmux_endpoint_from_state(state) {
370
+ (TmuxBackend::for_tmux_endpoint(endpoint), source)
371
+ } else {
372
+ (
373
+ TmuxBackend::for_workspace(workspace),
374
+ RuntimeTmuxEndpointSource::WorkspaceFallback,
375
+ )
376
+ };
377
+ RuntimeTmuxBackendSelection {
378
+ tmux_endpoint_used: backend.tmux_endpoint(),
379
+ backend,
380
+ tmux_endpoint_source: source,
381
+ }
382
+ }
383
+
384
+ #[cfg(test)]
385
+ pub(crate) fn tmux_backend_with_runner_for_runtime_state_or_workspace(
386
+ runner: Box<dyn CommandRunner>,
387
+ workspace: &Path,
388
+ state: Option<&serde_json::Value>,
389
+ ) -> RuntimeTmuxBackendSelection {
390
+ let (backend, source) =
391
+ if let Some((endpoint, source)) = runtime_tmux_endpoint_from_state(state) {
392
+ (
393
+ TmuxBackend::with_runner_for_tmux_endpoint(runner, endpoint),
394
+ source,
395
+ )
396
+ } else {
397
+ (
398
+ TmuxBackend::with_runner_for_workspace(runner, workspace),
399
+ RuntimeTmuxEndpointSource::WorkspaceFallback,
400
+ )
401
+ };
402
+ RuntimeTmuxBackendSelection {
403
+ tmux_endpoint_used: backend.tmux_endpoint(),
404
+ backend,
405
+ tmux_endpoint_source: source,
406
+ }
407
+ }
408
+
409
+ fn runtime_tmux_endpoint_from_state(
410
+ state: Option<&serde_json::Value>,
411
+ ) -> Option<(&str, RuntimeTmuxEndpointSource)> {
412
+ state.and_then(|state| {
413
+ state
414
+ .get("tmux_endpoint")
415
+ .and_then(|v| v.as_str())
416
+ .filter(|endpoint| !endpoint.is_empty())
417
+ .map(|endpoint| (endpoint, RuntimeTmuxEndpointSource::StateTmuxEndpoint))
418
+ .or_else(|| {
419
+ state
420
+ .get("tmux_socket")
421
+ .and_then(|v| v.as_str())
422
+ .filter(|endpoint| !endpoint.is_empty())
423
+ .map(|endpoint| (endpoint, RuntimeTmuxEndpointSource::StateTmuxSocket))
424
+ })
425
+ })
426
+ }
427
+
341
428
  /// CP-1 socket name: SHORT + DETERMINISTIC per canonical workspace path. `ta-` + 12 hex chars of a
342
429
  /// stable FNV-1a hash over the canonicalized path. AF_UNIX `sun_path` is ~104 chars and the socket
343
430
  /// lives at `/tmp/tmux-<uid>/<name>`, so we must NOT use the (~88-char) session name. §10: a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.3.19",
24
- "@team-agent/cli-darwin-x64": "0.3.19",
25
- "@team-agent/cli-linux-x64": "0.3.19"
23
+ "@team-agent/cli-darwin-arm64": "0.3.20",
24
+ "@team-agent/cli-darwin-x64": "0.3.20",
25
+ "@team-agent/cli-linux-x64": "0.3.20"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",