@team-agent/installer 0.5.37 → 0.5.39

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
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.37"
578
+ version = "0.5.39"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.37"
12
+ version = "0.5.39"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -3,6 +3,22 @@ use super::*;
3
3
  use crate::provider::wire::{command_name, parse_provider, provider_wire};
4
4
  use crate::transport::Transport;
5
5
 
6
+ /// 0.5.39 Slice 1 (tmux-server-death-locate §11.1 B): classify a tmux
7
+ /// transport error string into a diagnose issue id. When the underlying
8
+ /// tmux subprocess stderr contains `server exited unexpectedly`, the
9
+ /// tmux server itself crashed (upstream tmux 3.6a control-mode +
10
+ /// broadcast attach/detach jitter is one known trigger) — the physical
11
+ /// layer that disappeared is the whole server, not just this team's
12
+ /// session. Otherwise, callers keep the legacy `tmux_session_missing`
13
+ /// classification.
14
+ pub(crate) fn classify_tmux_server_error(error_text: &str) -> &'static str {
15
+ if error_text.contains("server exited unexpectedly") {
16
+ "tmux_server_crashed"
17
+ } else {
18
+ "tmux_session_missing"
19
+ }
20
+ }
21
+
6
22
  pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value, Value) {
7
23
  let mut issues = Vec::new();
8
24
  let mut repairs = Vec::new();
@@ -31,11 +47,20 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
31
47
  ));
32
48
  }
33
49
  Err(error) => {
34
- issues.push(json!("tmux_session_missing"));
50
+ // 0.5.39 Slice 1 (tmux-server-death-locate §11.1 B):
51
+ // when the transport error stderr matches "server exited
52
+ // unexpectedly", the physical layer that disappeared is
53
+ // the tmux server itself — not just this team's session.
54
+ // Surface `tmux_server_crashed` so the user's next
55
+ // action is a coordinator/host-level recovery, not a
56
+ // per-agent `restart <agent>`.
57
+ let error_str = error.to_string();
58
+ let issue_id = classify_tmux_server_error(&error_str);
59
+ issues.push(json!(issue_id));
35
60
  let mut hint =
36
- recovery_hint(session_name, "tmux_session_missing", "team-agent restart");
61
+ recovery_hint(session_name, issue_id, "team-agent diagnose");
37
62
  if let Some(obj) = hint.as_object_mut() {
38
- obj.insert("reason".to_string(), Value::String(error.to_string()));
63
+ obj.insert("reason".to_string(), Value::String(error_str));
39
64
  }
40
65
  repairs.push(hint);
41
66
  }
@@ -1,10 +1,19 @@
1
1
  //! lifecycle::display —— 能力门 / 后端解析 / 开关 / rebind 后重建。
2
+ //!
3
+ //! 0.5.39 Slice 1 (tmux-server-death-locate §7 Slice 1): all tmux
4
+ //! session/window/pane operations here MUST go through the workspace's
5
+ //! scoped `TmuxBackend` — raw `Command::new("tmux")` / ambient
6
+ //! `run_tmux(...)` helpers in this file inherit ambient `$TMUX` and can
7
+ //! kill sessions on the wrong socket. Ambient leader-pane env probes
8
+ //! (`display-message` reading $TMUX to discover which session invoked
9
+ //! us) live in `tmux_backend::probe_ambient_leader_pane_info` — that
10
+ //! probe is definitionally ambient (its whole job is "which session am I
11
+ //! in") and stays outside this module.
2
12
 
3
13
  use std::collections::BTreeMap;
4
14
  use std::path::Path;
5
- use std::process::Command;
6
15
 
7
- use crate::transport::{PaneId, SessionName, WindowName};
16
+ use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
8
17
 
9
18
  use super::*;
10
19
 
@@ -239,6 +248,12 @@ fn close_adaptive_displays(
239
248
  orphans_cleaned: Vec::new(),
240
249
  });
241
250
  };
251
+ // 0.5.39 Slice 1: build workspace-scoped tmux transport once and
252
+ // route every destructive tmux op below through it (no ambient
253
+ // $TMUX). The kill_* Transport methods carry the workspace socket
254
+ // (`tmux -L <socket>`) so display cleanup cannot cross-kill a session
255
+ // on the user's default tmux server.
256
+ let transport = crate::transport_factory::tmux_workspace_transport(workspace);
242
257
  let mut closed = Vec::new();
243
258
  let mut orphans_cleaned = Vec::new();
244
259
  let mut seen = std::collections::BTreeSet::new();
@@ -258,13 +273,13 @@ fn close_adaptive_displays(
258
273
  }
259
274
  for target in display_identifiers(display, agent, agent_id, session_name, &state) {
260
275
  if seen.insert(target.clone()) {
261
- kill_display_target(&target);
276
+ kill_display_target(&transport, &target);
262
277
  closed.push(target);
263
278
  }
264
279
  }
265
280
  }
266
281
  if let Some(leader_session) = adaptive_leader_session(&state) {
267
- for target in close_adaptive_windows(leader_session.as_str(), session_name.as_str()) {
282
+ for target in close_adaptive_windows(&transport, leader_session.as_str(), session_name.as_str()) {
268
283
  orphans_cleaned.push(target.clone());
269
284
  if seen.insert(target.clone()) {
270
285
  closed.push(target);
@@ -351,13 +366,6 @@ struct LeaderTmuxInfo {
351
366
  pane: Option<String>,
352
367
  }
353
368
 
354
- #[derive(Debug)]
355
- struct TmuxOutput {
356
- ok: bool,
357
- stdout: String,
358
- stderr: String,
359
- }
360
-
361
369
  fn in_wsl() -> bool {
362
370
  std::env::var("WSL_DISTRO_NAME").is_ok_and(|value| !value.is_empty())
363
371
  || std::env::var("WSL_INTEROP").is_ok_and(|value| !value.is_empty())
@@ -369,52 +377,11 @@ fn running_inside_tmux() -> bool {
369
377
  }
370
378
 
371
379
  fn current_leader_tmux_info() -> Option<LeaderTmuxInfo> {
372
- let pane = std::env::var("TMUX_PANE")
373
- .ok()
374
- .filter(|value| !value.is_empty());
375
- let mut commands = Vec::new();
376
- if let Some(pane) = pane.as_deref() {
377
- commands.push(vec![
378
- "display-message".to_string(),
379
- "-p".to_string(),
380
- "-t".to_string(),
381
- pane.to_string(),
382
- "-F".to_string(),
383
- "#{session_name}\t#{pane_id}".to_string(),
384
- ]);
385
- commands.push(vec![
386
- "display-message".to_string(),
387
- "-p".to_string(),
388
- "-t".to_string(),
389
- pane.to_string(),
390
- "-F".to_string(),
391
- "#{session_name}".to_string(),
392
- ]);
393
- }
394
- if std::env::var("TMUX").is_ok_and(|value| !value.is_empty()) {
395
- commands.push(vec![
396
- "display-message".to_string(),
397
- "-p".to_string(),
398
- "-F".to_string(),
399
- "#{session_name}\t#{pane_id}".to_string(),
400
- ]);
401
- commands.push(vec![
402
- "display-message".to_string(),
403
- "-p".to_string(),
404
- "-F".to_string(),
405
- "#{session_name}".to_string(),
406
- ]);
407
- }
408
- for command in commands {
409
- let args = command.iter().map(String::as_str).collect::<Vec<_>>();
410
- if let Some(parsed) = run_tmux(&args)
411
- .ok()
412
- .and_then(|out| parse_tmux_info(&out.stdout))
413
- {
414
- return Some(parsed);
415
- }
416
- }
417
- None
380
+ // 0.5.39 Slice 1: ambient probe lives in `tmux_backend` — the single
381
+ // controlled exception to the "all tmux ops through socket-scoped
382
+ // transport" rule (its whole job is "which session am I in?").
383
+ crate::tmux_backend::probe_ambient_leader_pane_info()
384
+ .map(|(session, pane)| LeaderTmuxInfo { session, pane })
418
385
  }
419
386
 
420
387
  fn env_leader_tmux_info() -> Option<LeaderTmuxInfo> {
@@ -428,44 +395,29 @@ fn env_leader_tmux_info() -> Option<LeaderTmuxInfo> {
428
395
  Some(LeaderTmuxInfo { session, pane })
429
396
  }
430
397
 
431
- fn parse_tmux_info(stdout: &str) -> Option<LeaderTmuxInfo> {
432
- let line = stdout.lines().find(|line| !line.trim().is_empty())?.trim();
433
- let parts = line.split('\t').collect::<Vec<_>>();
434
- match parts.as_slice() {
435
- [session, pane, ..] if !session.is_empty() && !session.starts_with('%') => {
436
- Some(LeaderTmuxInfo {
437
- session: (*session).to_string(),
438
- pane: (!pane.is_empty()).then(|| (*pane).to_string()),
439
- })
440
- }
441
- [pane, session, ..] if pane.starts_with('%') && !session.is_empty() => {
442
- Some(LeaderTmuxInfo {
443
- session: (*session).to_string(),
444
- pane: Some((*pane).to_string()),
445
- })
446
- }
447
- [session] if !session.is_empty() && !session.starts_with('%') => Some(LeaderTmuxInfo {
448
- session: (*session).to_string(),
449
- pane: None,
450
- }),
451
- _ => None,
452
- }
453
- }
454
-
455
- fn close_adaptive_windows(leader_session: &str, session_name: &str) -> Vec<String> {
398
+ fn close_adaptive_windows(
399
+ transport: &dyn Transport,
400
+ leader_session: &str,
401
+ session_name: &str,
402
+ ) -> Vec<String> {
456
403
  let prefix = format!("team-agent:{session_name}:overview");
457
- let Ok(output) = run_tmux(&["list-windows", "-t", leader_session, "-F", "#{window_name}"])
458
- else {
404
+ // 0.5.39 Slice 1: `Transport::list_windows` uses the workspace socket
405
+ // — if `leader_session` doesn't exist on that socket, list_windows
406
+ // returns Err/empty and we bail without touching another server.
407
+ let Ok(windows) = transport.list_windows(&SessionName::new(leader_session.to_string())) else {
459
408
  return Vec::new();
460
409
  };
461
- output
462
- .stdout
463
- .lines()
464
- .filter_map(|line| {
465
- let window = line.trim();
410
+ windows
411
+ .into_iter()
412
+ .filter_map(|window| {
413
+ let window = window.as_str().to_string();
466
414
  if window == prefix || window.starts_with(&format!("{prefix}-")) {
467
- let target = format!("{leader_session}:{window}");
468
- kill_adaptive_window(&target).then_some(target)
415
+ let target_string = format!("{leader_session}:{window}");
416
+ let target = Target::SessionWindow {
417
+ session: SessionName::new(leader_session.to_string()),
418
+ window: WindowName::new(window),
419
+ };
420
+ transport.kill_window(&target).ok().map(|_| target_string)
469
421
  } else {
470
422
  None
471
423
  }
@@ -473,18 +425,23 @@ fn close_adaptive_windows(leader_session: &str, session_name: &str) -> Vec<Strin
473
425
  .collect()
474
426
  }
475
427
 
476
- fn kill_adaptive_window(target: &str) -> bool {
477
- run_tmux(&["kill-window", "-t", target]).is_ok()
478
- }
479
-
480
- fn kill_display_target(target: &str) {
481
- if target.contains(':') {
482
- let _ = run_tmux(&["kill-window", "-t", target]);
428
+ fn kill_display_target(transport: &dyn Transport, target: &str) {
429
+ // 0.5.39 Slice 1: only kill window/pane never fall back to
430
+ // kill-session on an ambiguous string shape. Locate §7 Slice 1:
431
+ // "Remove the string-shape `kill-session` fallback unless target is a
432
+ // proven display-only session." The old code parsed any bare
433
+ // non-`%`/non-`:` target as a session and issued `kill-session`,
434
+ // which is exactly the unbounded blast radius we're closing.
435
+ if let Some((session, window)) = target.split_once(':') {
436
+ let _ = transport.kill_window(&Target::SessionWindow {
437
+ session: SessionName::new(session.to_string()),
438
+ window: WindowName::new(window.to_string()),
439
+ });
483
440
  } else if target.starts_with('%') {
484
- let _ = run_tmux(&["kill-pane", "-t", target]);
485
- } else {
486
- let _ = run_tmux(&["kill-session", "-t", target]);
441
+ let _ = transport.kill_pane(&PaneId::new(target.to_string()));
487
442
  }
443
+ // Bare session-name form is intentionally a no-op: display cleanup
444
+ // must not kill a whole session on shape guesswork.
488
445
  }
489
446
 
490
447
  fn adaptive_leader_session(state: &serde_json::Value) -> Option<String> {
@@ -525,23 +482,3 @@ fn string_field(display: &serde_json::Map<String, serde_json::Value>, key: &str)
525
482
  .map(str::to_string)
526
483
  }
527
484
 
528
- fn run_tmux(args: &[&str]) -> Result<TmuxOutput, LifecycleError> {
529
- let output = Command::new("tmux")
530
- .args(args)
531
- .output()
532
- .map_err(|e| LifecycleError::StatePersist(format!("tmux {}: {e}", args.join(" "))))?;
533
- let result = TmuxOutput {
534
- ok: output.status.success(),
535
- stdout: String::from_utf8_lossy(&output.stdout).to_string(),
536
- stderr: String::from_utf8_lossy(&output.stderr).to_string(),
537
- };
538
- if result.ok {
539
- Ok(result)
540
- } else {
541
- Err(LifecycleError::StatePersist(format!(
542
- "tmux {}: {}",
543
- args.join(" "),
544
- result.stderr.trim()
545
- )))
546
- }
547
- }
@@ -68,6 +68,9 @@ pub fn launch_with_transport_in_workspace(
68
68
  transport: &dyn Transport,
69
69
  ) -> Result<LaunchReport, LifecycleError> {
70
70
  let _ = skip_profile_smoke;
71
+ // 0.5.38 (`.team/artifacts/startup-latency-locate.md` §5): launch.phase
72
+ // timer with monotonic `elapsed_ms` for latency triage.
73
+ let phase_timer = crate::lifecycle::restart::RestartPhaseTimer::start();
71
74
  if !spec_path.exists() {
72
75
  return Err(LifecycleError::Compile(format!(
73
76
  "spec path not found: {}",
@@ -77,6 +80,7 @@ pub fn launch_with_transport_in_workspace(
77
80
  let text = std::fs::read_to_string(spec_path)
78
81
  .map_err(|e| LifecycleError::Compile(format!("{}: {e}", spec_path.display())))?;
79
82
  let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
83
+ phase_timer.emit(workspace, "launch.phase", "compile_spec");
80
84
  let session_name = spec_session_name(&spec);
81
85
  let safety = effective_runtime_config(&spec)?;
82
86
  if safety.enabled && !safety.inherited && !auto_approve && !dry_run {
@@ -102,6 +106,7 @@ pub fn launch_with_transport_in_workspace(
102
106
  let started = if dry_run {
103
107
  Vec::new()
104
108
  } else {
109
+ phase_timer.emit(workspace, "launch.phase", "spawn_all");
105
110
  let started = spawn_agents(
106
111
  workspace,
107
112
  spec_path,
@@ -119,6 +124,37 @@ pub fn launch_with_transport_in_workspace(
119
124
  &started,
120
125
  &safety,
121
126
  )?;
127
+ // 0.5.38: per-worker timing tags (source="launch") so operators can
128
+ // trace which worker's spawn dominates wall time. Zeros for now on
129
+ // the sub-timings — Step 1 first enables shape assertion; a later
130
+ // slice may thread real command_plan / transport_spawn / handler
131
+ // timings from spawn_agents.
132
+ for agent in &started {
133
+ let provider = spec_agent_values(&spec)
134
+ .into_iter()
135
+ .find(|entry| {
136
+ entry
137
+ .get("id")
138
+ .and_then(Value::as_str)
139
+ .map(|id| id == agent.agent_id.as_str())
140
+ .unwrap_or(false)
141
+ })
142
+ .and_then(|entry| entry.get("provider").and_then(Value::as_str).map(str::to_string))
143
+ .unwrap_or_else(|| "fake".to_string());
144
+ crate::lifecycle::restart::write_worker_spawn_timing_event(
145
+ workspace,
146
+ phase_timer.elapsed_ms(),
147
+ agent.agent_id.as_str(),
148
+ &provider,
149
+ agent.start_mode,
150
+ "new-window",
151
+ 0,
152
+ 0,
153
+ 0,
154
+ 0,
155
+ "launch",
156
+ );
157
+ }
122
158
  started
123
159
  };
124
160
  // 0.3.28 Step 1: topology invariant guard (warn-only during migration).
@@ -3241,6 +3277,11 @@ pub fn quick_start_with_transport_in_workspace_with_display(
3241
3277
  // FIX (rt-host-a real-machine finding): dry_run=false so launch_with_transport calls spawn_agents
3242
3278
  // and really creates the tmux session + worker windows (was hardcoded true → never spawned, which
3243
3279
  // also starved the coordinator: no session → first tick TmuxSessionMissing → run_daemon loop exits).
3280
+ // 0.5.38 (`.team/artifacts/startup-latency-locate.md` §5): quick-start
3281
+ // owns the outer `launch.phase` timer so `coordinator_start` /
3282
+ // `readiness_wait` / `completed` fire monotonically after the inner
3283
+ // launch_with_transport's own `compile_spec` / `spawn_all` events.
3284
+ let quick_start_phase_timer = crate::lifecycle::restart::RestartPhaseTimer::start();
3244
3285
  let mut launch =
3245
3286
  launch_with_transport_in_workspace(&workspace, &spec_path, false, yes, true, transport)?;
3246
3287
  annotate_persisted_team_depth(
@@ -3257,6 +3298,7 @@ pub fn quick_start_with_transport_in_workspace_with_display(
3257
3298
  let coordinator_started = crate::coordinator::start_coordinator(&coordinator_workspace)
3258
3299
  .map(|report| report.ok)
3259
3300
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
3301
+ quick_start_phase_timer.emit(&workspace, "launch.phase", "coordinator_start");
3260
3302
  let coordinator_action = if coordinator_started {
3261
3303
  "coordinator started"
3262
3304
  } else {
@@ -3269,6 +3311,7 @@ pub fn quick_start_with_transport_in_workspace_with_display(
3269
3311
  // loaded successfully (provider-side codex/claude schema rejections happen
3270
3312
  // asynchronously after spawn), so the verdict is PendingToolLoad — never
3271
3313
  // bare Ready.
3314
+ quick_start_phase_timer.emit(&workspace, "launch.phase", "readiness_wait");
3272
3315
  let worker_readiness = quick_start_worker_readiness(&workspace, &state_team_key);
3273
3316
  let attach_windows = load_runtime_state(&workspace)
3274
3317
  .ok()
@@ -3310,6 +3353,7 @@ pub fn quick_start_with_transport_in_workspace_with_display(
3310
3353
  .and_then(serde_json::Value::as_str)
3311
3354
  .unwrap_or("none")
3312
3355
  .to_string();
3356
+ quick_start_phase_timer.emit(&workspace, "launch.phase", "completed");
3313
3357
  Ok(QuickStartReport::Ready {
3314
3358
  session_name,
3315
3359
  launch: Box::new(launch),
@@ -228,25 +228,35 @@ pub(super) fn spawn_agent_window(
228
228
  );
229
229
  }
230
230
 
231
+ // 0.5.39 Slice 2 (tmux-server-death-locate §7 Slice 2): route the
232
+ // primary worker spawn through the worker shell wrapper so provider
233
+ // exit leaves the pane at an interactive shell with an explicit exit
234
+ // marker instead of collapsing the pane into `[exited]`. This mirrors
235
+ // the leader wrapper's manual-tmux equivalence. The split path stays
236
+ // on plain spawn_split — split panes are display overlays, not the
237
+ // primary worker process.
238
+ let provider_label = crate::provider::wire::command_name(provider);
231
239
  let result = if let Some(placement) = layout_placement {
232
240
  if placement.starts_window {
233
241
  if into_existing_session {
234
- transport.spawn_into_with_env_unset(
242
+ transport.spawn_into_with_worker_shell_wrapper(
235
243
  session_name,
236
244
  &window,
237
245
  &plan.argv,
238
246
  spawn_cwd,
239
247
  &env,
240
248
  &env_unset,
249
+ provider_label,
241
250
  )
242
251
  } else {
243
- transport.spawn_first_with_env_unset(
252
+ transport.spawn_first_with_worker_shell_wrapper(
244
253
  session_name,
245
254
  &window,
246
255
  &plan.argv,
247
256
  spawn_cwd,
248
257
  &env,
249
258
  &env_unset,
259
+ provider_label,
250
260
  )
251
261
  }
252
262
  } else if !window_present_in_live(transport, session_name, &window)
@@ -264,13 +274,14 @@ pub(super) fn spawn_agent_window(
264
274
  // hijack the developer worker's pane. Downgrade to spawn_into
265
275
  // (new window named after agent_id) — canonical per-agent
266
276
  // fallback the existing 7 workers use.
267
- transport.spawn_into_with_env_unset(
277
+ transport.spawn_into_with_worker_shell_wrapper(
268
278
  session_name,
269
279
  &WindowName::new(agent_id.as_str()),
270
280
  &plan.argv,
271
281
  spawn_cwd,
272
282
  &env,
273
283
  &env_unset,
284
+ provider_label,
274
285
  )
275
286
  } else {
276
287
  // 0.3.28 Step 8: spawn_split must only fire from the display
@@ -286,22 +297,24 @@ pub(super) fn spawn_agent_window(
286
297
  )
287
298
  }
288
299
  } else if into_existing_session {
289
- transport.spawn_into_with_env_unset(
300
+ transport.spawn_into_with_worker_shell_wrapper(
290
301
  session_name,
291
302
  &window,
292
303
  &plan.argv,
293
304
  spawn_cwd,
294
305
  &env,
295
306
  &env_unset,
307
+ provider_label,
296
308
  )
297
309
  } else {
298
- transport.spawn_first_with_env_unset(
310
+ transport.spawn_first_with_worker_shell_wrapper(
299
311
  session_name,
300
312
  &window,
301
313
  &plan.argv,
302
314
  spawn_cwd,
303
315
  &env,
304
316
  &env_unset,
317
+ provider_label,
305
318
  )
306
319
  };
307
320
  let spawn = result.map_err(|e| LifecycleError::Transport(e.to_string()))?;