@team-agent/installer 0.5.36 → 0.5.38

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.36"
578
+ version = "0.5.38"
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.36"
12
+ version = "0.5.38"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -1703,6 +1703,13 @@ pub(crate) fn attempt_due_recoveries(
1703
1703
  let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
1704
1704
  return;
1705
1705
  };
1706
+ // 0.5.37 (`architect res_b8a6d40f3765`, R8): clear stale `next_retry_at`
1707
+ // on terminal intents (succeeded/blocked/exhausted) BEFORE dispatching.
1708
+ // Without this, a terminal record whose past `next_retry_at` survives
1709
+ // a coordinator restart could get re-dispatched on the next tick,
1710
+ // producing a spurious `recovery_started` event for an intent whose
1711
+ // lifecycle already completed.
1712
+ clear_stale_terminal_next_retry_at(workspace);
1706
1713
  let due_agents = collect_due_recovery_agents(&state);
1707
1714
  for agent_id in due_agents {
1708
1715
  run_single_recovery(workspace, event_log, transport, &agent_id);
@@ -1723,7 +1730,12 @@ fn collect_due_recovery_agents(state: &Value) -> Vec<String> {
1723
1730
  .get("status")
1724
1731
  .and_then(Value::as_str)
1725
1732
  .unwrap_or("");
1726
- if !matches!(status, "scheduled") {
1733
+ // 0.5.37 R8: dispatch only for non-terminal states. `succeeded`,
1734
+ // `blocked`, `exhausted`, `backpressured` are terminal / awaiting
1735
+ // manual action — a lifecycle dispatch here would be double-fire
1736
+ // or wasted work. `scheduled` / `running` remain dispatchable so
1737
+ // an interrupted tick can resume.
1738
+ if !matches!(status, "scheduled" | "running") {
1727
1739
  continue;
1728
1740
  }
1729
1741
  let Some(next_retry_at) = intent
@@ -1741,6 +1753,44 @@ fn collect_due_recovery_agents(state: &Value) -> Vec<String> {
1741
1753
  due
1742
1754
  }
1743
1755
 
1756
+ /// 0.5.37 (`architect res_b8a6d40f3765`, R8): terminal recovery states
1757
+ /// (`succeeded`, `blocked`, `exhausted`) must not carry a stale
1758
+ /// `next_retry_at`. Load state, scrub the key on any terminal entry, and
1759
+ /// only persist when at least one row changed so we don't dirty the tick's
1760
+ /// atomic_save contract for idle workspaces.
1761
+ fn clear_stale_terminal_next_retry_at(workspace: &Path) {
1762
+ let Ok(mut state) = crate::state::persist::load_runtime_state(workspace) else {
1763
+ return;
1764
+ };
1765
+ let mut mutated = false;
1766
+ if let Some(agents) = recovery_intent_agents(&mut state) {
1767
+ for (_agent_id, entry) in agents.iter_mut() {
1768
+ let Some(obj) = entry.as_object_mut() else {
1769
+ continue;
1770
+ };
1771
+ let status = obj
1772
+ .get("status")
1773
+ .and_then(Value::as_str)
1774
+ .unwrap_or("")
1775
+ .to_string();
1776
+ if !matches!(status.as_str(), "succeeded" | "blocked" | "exhausted") {
1777
+ continue;
1778
+ }
1779
+ let had_stale = obj
1780
+ .get("next_retry_at")
1781
+ .filter(|v| !v.is_null())
1782
+ .is_some();
1783
+ if had_stale {
1784
+ obj.remove("next_retry_at");
1785
+ mutated = true;
1786
+ }
1787
+ }
1788
+ }
1789
+ if mutated {
1790
+ let _ = crate::state::persist::save_runtime_state(workspace, &state);
1791
+ }
1792
+ }
1793
+
1744
1794
  fn run_single_recovery(
1745
1795
  workspace: &Path,
1746
1796
  event_log: &EventLog,
@@ -1916,6 +1966,12 @@ fn write_recovery_intent_result(workspace: &Path, agent_id: &str, update: Recove
1916
1966
  obj.remove("blocked_reason");
1917
1967
  }
1918
1968
  }
1969
+ // 0.5.37 R8: transitioning into a terminal state clears the
1970
+ // stale `next_retry_at`; a future retry earns a fresh due
1971
+ // time when it re-enters `scheduled` through the detector.
1972
+ if matches!(update.status, "succeeded" | "blocked" | "exhausted") {
1973
+ obj.remove("next_retry_at");
1974
+ }
1919
1975
  }
1920
1976
  }
1921
1977
  let _ = crate::state::persist::save_runtime_state(workspace, &state);
@@ -257,6 +257,50 @@ fn r7_recovery_never_synthesizes_hidden_provider_turn_or_sdk_call() {
257
257
  );
258
258
  }
259
259
 
260
+ #[test]
261
+ fn r8_terminal_recovery_intents_are_idempotent_and_clear_due_time() {
262
+ for status in ["succeeded", "blocked", "exhausted"] {
263
+ let case = RecoveryCase::new(&format!("r8-terminal-{status}"));
264
+ case.seed_agents(&[("fe-admin", 429, "rate_limit", "baseline-429")]);
265
+ case.seed_due_recovery_with_status("fe-admin", status);
266
+
267
+ run_due_recoveries(&case);
268
+
269
+ let events = case.events();
270
+ assert!(
271
+ find_event(&events, "worker.abnormal_exit.recovery_started").is_none(),
272
+ "R8: terminal status `{status}` with stale next_retry_at must be skipped before lifecycle dispatch; events={events:?}"
273
+ );
274
+ let state = case.state();
275
+ let recovery = state
276
+ .pointer("/coordinator/abnormal_api_error_recovery/agents/fe-admin")
277
+ .expect("R8: terminal recovery entry remains auditable");
278
+ assert_eq!(recovery["status"], serde_json::json!(status));
279
+ assert!(
280
+ recovery.get("next_retry_at").is_none()
281
+ || recovery.get("next_retry_at") == Some(&serde_json::Value::Null),
282
+ "R8: terminal status `{status}` must clear stale next_retry_at so future ticks cannot reuse it; recovery={recovery}"
283
+ );
284
+ }
285
+ }
286
+
287
+ #[test]
288
+ fn r8_non_terminal_due_intents_still_dispatch() {
289
+ for status in ["scheduled", "running"] {
290
+ let case = RecoveryCase::new(&format!("r8-nonterminal-{status}"));
291
+ case.seed_agents(&[("fe-admin", 429, "rate_limit", "baseline-429")]);
292
+ case.seed_due_recovery_with_status("fe-admin", status);
293
+
294
+ run_due_recoveries(&case);
295
+
296
+ let events = case.events();
297
+ assert!(
298
+ find_event(&events, "worker.abnormal_exit.recovery_started").is_some(),
299
+ "R8: non-terminal status `{status}` with due next_retry_at must still dispatch recovery; events={events:?}"
300
+ );
301
+ }
302
+ }
303
+
260
304
  struct RecoveryCase {
261
305
  root: std::path::PathBuf,
262
306
  }
@@ -328,11 +372,15 @@ impl RecoveryCase {
328
372
  }
329
373
 
330
374
  fn seed_due_recovery(&self, agent: &str) {
375
+ self.seed_due_recovery_with_status(agent, "scheduled");
376
+ }
377
+
378
+ fn seed_due_recovery_with_status(&self, agent: &str, status: &str) {
331
379
  let mut state = self.state();
332
380
  state["coordinator"]["abnormal_api_error_recovery"]["agents"][agent] = serde_json::json!({
333
381
  "error_key": format!("worker.abnormal_exit.error:{agent}:rollout:api_error:seed"),
334
382
  "cohort_key": "research:claude_code:api_error:429:rate_limit",
335
- "status": "scheduled",
383
+ "status": status,
336
384
  "attempts": 0,
337
385
  "max_attempts": 2,
338
386
  "next_retry_at": "2000-01-01T00:00:00Z",
@@ -478,3 +526,11 @@ fn detect_abnormal_body() -> String {
478
526
  .expect("derive after detect_abnormal_exits");
479
527
  source[start..end].to_string()
480
528
  }
529
+
530
+ fn run_due_recoveries(case: &RecoveryCase) {
531
+ crate::coordinator::steps::abnormal::attempt_due_recoveries(
532
+ &case.root,
533
+ &crate::event_log::EventLog::new(&case.root),
534
+ &MockTransport::new(true),
535
+ );
536
+ }
@@ -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),
@@ -3,6 +3,42 @@ use super::selection::classify_restart_plan_with_resume_validation;
3
3
  use super::*;
4
4
  use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
5
5
 
6
+ // ── 0.5.38 startup latency instrumentation ──────────────────────────────────
7
+ //
8
+ // `.team/artifacts/startup-latency-locate.md` §5 Step 1: emit structured
9
+ // `restart.phase` / `launch.phase` events with monotonic `elapsed_ms` so a
10
+ // downstream operator can see WHERE the wall clock is spent, and per-worker
11
+ // `worker.spawn_timing` events (`command_plan_ms`, `transport_spawn_ms`,
12
+ // `pane_verify_ms`, `startup_prompt_handler_ms`, `tmux_start_mode`) so
13
+ // bounded-concurrency spawn can be justified with real numbers.
14
+
15
+ pub(crate) struct RestartPhaseTimer {
16
+ started_at: std::time::Instant,
17
+ }
18
+
19
+ impl RestartPhaseTimer {
20
+ pub(crate) fn start() -> Self {
21
+ Self {
22
+ started_at: std::time::Instant::now(),
23
+ }
24
+ }
25
+
26
+ pub(crate) fn elapsed_ms(&self) -> u64 {
27
+ u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
28
+ }
29
+
30
+ pub(crate) fn emit(&self, workspace: &Path, kind: &'static str, phase: &'static str) {
31
+ let event_log = crate::event_log::EventLog::new(workspace);
32
+ let _ = event_log.write(
33
+ kind,
34
+ serde_json::json!({
35
+ "phase": phase,
36
+ "elapsed_ms": self.elapsed_ms(),
37
+ }),
38
+ );
39
+ }
40
+ }
41
+
6
42
  // ── lifecycle::restart —— 整队 Route B resume-or-fresh 重建 ──────────────────
7
43
 
8
44
  /// `restart(workspace, allow_fresh, team)`(`restart/orchestration.py:26`)。整队重建:
@@ -182,6 +218,12 @@ fn restart_with_selected_team_and_transport(
182
218
  readiness_deadline_ms: Option<u64>,
183
219
  tmux_endpoint_source: Option<&str>,
184
220
  ) -> Result<RestartReport, LifecycleError> {
221
+ // 0.5.38 Step 1 (`.team/artifacts/startup-latency-locate.md` §5): phase
222
+ // instrumentation. Timer boots when the caller's team selection has
223
+ // resolved a context; downstream phases are emitted with monotonic
224
+ // `elapsed_ms` for at-a-glance latency triage.
225
+ let phase_timer = RestartPhaseTimer::start();
226
+ phase_timer.emit(&selected.run_workspace, "restart.phase", "resolve_context");
185
227
  let lifecycle_lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
186
228
  workspace: &selected.run_workspace,
187
229
  operation: "restart",
@@ -221,6 +263,7 @@ fn restart_with_selected_team_and_transport(
221
263
  // 写 runtime spec。角色缺(TEAM.md/agents 不在)→ 显式拒(列缺哪些),旧 spec 原地保留不删不用。
222
264
  let spec =
223
265
  rebuild_runtime_spec_from_roles(&selected.run_workspace, &selected.team_key, &state)?;
266
+ phase_timer.emit(&selected.run_workspace, "restart.phase", "compile_spec");
224
267
  // 重建后 spec_workspace 恒为 runtime spec 的父目录(.team/runtime/<team_key>/)。
225
268
  let runtime_spec =
226
269
  crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key);
@@ -314,6 +357,11 @@ fn restart_with_selected_team_and_transport(
314
357
  &state,
315
358
  allow_fresh,
316
359
  )?;
360
+ phase_timer.emit(
361
+ &selected.run_workspace,
362
+ "restart.phase",
363
+ "plan_classification",
364
+ );
317
365
  write_restart_resume_decision_events(
318
366
  &selected.run_workspace,
319
367
  &state,
@@ -401,14 +449,100 @@ fn restart_with_selected_team_and_transport(
401
449
  &topology_authority_agent_ids,
402
450
  )?;
403
451
  }
452
+ phase_timer.emit(&selected.run_workspace, "restart.phase", "teardown");
453
+ phase_timer.emit(&selected.run_workspace, "restart.phase", "spawn_all");
404
454
  let mut successful_agents: Vec<RestartedAgent> = Vec::new();
405
455
  let mut failed_agents: Vec<RestartFailedAgent> = Vec::new();
406
456
  let mut fatal_resume_failure = false;
407
- // B5 restart isolation loop: per-agent spawn failures must be recorded and
408
- // isolated here. G1 resume-integrity failures set a fatal flag and skip later
409
- // spawns; do not reintroduce `?`, `break`, or `return` inside this loop.
457
+ // 0.5.38 Step 2 (`.team/artifacts/startup-latency-locate.md` §5): bounded
458
+ // parallel new-window spawn. The first decision remains serial so the
459
+ // tmux session is created deterministically; the remaining independent
460
+ // decisions run their `transport.spawn_into` in parallel via a thread
461
+ // scope with concurrency capped at `min(4, workers-1)`. Every spawn
462
+ // result is collected in-memory; `verify_spawned_agent_live` +
463
+ // `mark_agent_respawned` are then applied in ORIGINAL PLAN ORDER so
464
+ // persisted `spawn_epoch` / `spawned_at` / `pane_id` / `window` stay
465
+ // deterministic regardless of thread completion order. Failure
466
+ // aggregation stays equivalent to serial: per-agent errors go into
467
+ // `failed_agents`; a resume-integrity phase failure sets
468
+ // `fatal_resume_failure` so later marks are skipped.
469
+ let plan_decisions: Vec<RestartedAgent> = plan.decisions.iter().cloned().collect();
470
+ // 0.5.38 Step 2 safety gate: only parallelize when NO decision is
471
+ // Resumed. The serial `session_disappeared_after_spawn` semantics
472
+ // (detect a prior resume that killed the session, then pop the
473
+ // previous successful agent and mark it resume-failure BEFORE
474
+ // spawning the next) has no correct concurrent analog, so any
475
+ // resume plan continues to run through the pre-0.5.38 serial loop.
476
+ // Fresh / FreshAfterMissingRollout are independent — safe to spawn
477
+ // concurrently.
478
+ let parallel_safe = !plan_decisions
479
+ .iter()
480
+ .any(|decision| matches!(decision.restart_mode, StartMode::Resumed));
481
+ let parallel_outcomes = if parallel_safe {
482
+ run_bounded_parallel_worker_spawns(
483
+ &plan_decisions,
484
+ &selected.run_workspace,
485
+ spec_workspace,
486
+ &selected.team_key,
487
+ &session_name,
488
+ transport,
489
+ &safety,
490
+ tmux_endpoint_source,
491
+ &state,
492
+ )
493
+ } else {
494
+ vec![None; plan_decisions.len()]
495
+ };
410
496
  // BEGIN_B5_RESTART_ISOLATION_LOOP
411
- for decision in &plan.decisions {
497
+ for (decision_index, decision) in plan_decisions.iter().enumerate() {
498
+ if fatal_resume_failure {
499
+ continue;
500
+ }
501
+ // The parallel spawn stage delivered a Result per decision — early
502
+ // decisions may be fake-harness / not-found which the parallel
503
+ // stage kept as None so the serial code below handles them exactly
504
+ // as pre-0.5.38 (fake harness + missing agent branches). Where a
505
+ // parallel Some(Ok(spawn)) is present we skip the transport spawn
506
+ // and jump straight to verify + mark.
507
+ let parallel_result = parallel_outcomes.get(decision_index).cloned().unwrap_or(None);
508
+ if let Some(parallel_result) = parallel_result {
509
+ match parallel_result {
510
+ Ok(pspawn) => {
511
+ apply_marked_respawn(
512
+ &selected.run_workspace,
513
+ &selected.team_key,
514
+ &mut state,
515
+ transport,
516
+ &safety,
517
+ &phase_timer,
518
+ decision,
519
+ &pspawn.spawn,
520
+ pspawn.spawn_start,
521
+ pspawn.session_live_at_spawn,
522
+ &mut successful_agents,
523
+ &mut failed_agents,
524
+ &mut fatal_resume_failure,
525
+ );
526
+ }
527
+ Err(error) => {
528
+ let phase = restart_failure_phase(decision, "spawn", &error);
529
+ mark_agent_restart_failed(&mut state, decision, &error);
530
+ let _ = write_restart_agent_failed_event(
531
+ &selected.run_workspace,
532
+ decision,
533
+ phase,
534
+ &error,
535
+ );
536
+ failed_agents.push(restart_failed_agent(decision, phase, error));
537
+ if phase == "resume" {
538
+ fatal_resume_failure = true;
539
+ }
540
+ }
541
+ }
542
+ continue;
543
+ }
544
+ // Fall through to serial handling for edge cases the parallel
545
+ // stage intentionally deferred (missing agent, fake harness).
412
546
  if fatal_resume_failure {
413
547
  continue;
414
548
  }
@@ -495,6 +629,8 @@ fn restart_with_selected_team_and_transport(
495
629
  &session_name,
496
630
  &decision.agent_id,
497
631
  );
632
+ let session_live_at_spawn = session_live;
633
+ let spawn_start = std::time::Instant::now();
498
634
  let spawn = match spawn_agent_window(
499
635
  &selected.run_workspace,
500
636
  &session_name,
@@ -530,6 +666,7 @@ fn restart_with_selected_team_and_transport(
530
666
  continue;
531
667
  }
532
668
  };
669
+ let verify_start = std::time::Instant::now();
533
670
  if let Err(error) = verify_spawned_agent_live(&decision.agent_id, &spawn, transport)
534
671
  .and_then(|_| {
535
672
  mark_agent_respawned(
@@ -553,6 +690,27 @@ fn restart_with_selected_team_and_transport(
553
690
  }
554
691
  continue;
555
692
  }
693
+ let pane_verify_ms = u64::try_from(verify_start.elapsed().as_millis()).unwrap_or(u64::MAX);
694
+ let transport_spawn_ms = u64::try_from(
695
+ (verify_start.saturating_duration_since(spawn_start)).as_millis(),
696
+ )
697
+ .unwrap_or(u64::MAX);
698
+ // 0.5.38 Step 1: per-worker spawn timing so operators can identify
699
+ // whether wall time is spent in command-plan compilation, transport
700
+ // spawn, pane verification, or provider startup prompts.
701
+ write_worker_spawn_timing_event(
702
+ &selected.run_workspace,
703
+ phase_timer.elapsed_ms(),
704
+ decision.agent_id.as_str(),
705
+ provider_wire_from_state(&state, decision.agent_id.as_str()),
706
+ decision.restart_mode,
707
+ predict_tmux_start_mode(spawn.layout_placement.as_ref(), session_live_at_spawn),
708
+ /* command_plan_ms */ 0,
709
+ transport_spawn_ms,
710
+ pane_verify_ms,
711
+ /* startup_prompt_handler_ms */ 0,
712
+ "restart",
713
+ );
556
714
  successful_agents.push(decision.clone());
557
715
  if let Some(agent) = state
558
716
  .get_mut("agents")
@@ -599,6 +757,7 @@ fn restart_with_selected_team_and_transport(
599
757
  &capture_backfill_skip_agent_ids,
600
758
  &topology_authority_agent_ids,
601
759
  )?;
760
+ phase_timer.emit(&selected.run_workspace, "restart.phase", "save_state");
602
761
  if fatal_resume_failure {
603
762
  let attach_commands = Vec::new();
604
763
  let next_actions = restart_failure_next_actions(&failed_agents);
@@ -855,7 +1014,9 @@ fn restart_with_selected_team_and_transport(
855
1014
  drop(lifecycle_lock);
856
1015
  let coordinator =
857
1016
  start_coordinator_for_workspace(&selected.run_workspace, Some(&selected.team_key))?;
1017
+ phase_timer.emit(&selected.run_workspace, "restart.phase", "coordinator_start");
858
1018
  let coordinator_started = coordinator.ok;
1019
+ phase_timer.emit(&selected.run_workspace, "restart.phase", "readiness_wait");
859
1020
  wait_restart_readiness_or_timeout(
860
1021
  &selected.run_workspace,
861
1022
  &state,
@@ -879,6 +1040,7 @@ fn restart_with_selected_team_and_transport(
879
1040
  let mut next_actions = Vec::new();
880
1041
  if !failed_agents.is_empty() {
881
1042
  next_actions.extend(restart_failure_next_actions(&failed_agents));
1043
+ phase_timer.emit(&selected.run_workspace, "restart.phase", "completed");
882
1044
  write_restart_completed_event(
883
1045
  &selected.run_workspace,
884
1046
  &successful_agents,
@@ -903,6 +1065,7 @@ fn restart_with_selected_team_and_transport(
903
1065
  attach_commands,
904
1066
  });
905
1067
  }
1068
+ phase_timer.emit(&selected.run_workspace, "restart.phase", "completed");
906
1069
  write_restart_completed_event(
907
1070
  &selected.run_workspace,
908
1071
  &successful_agents,
@@ -1681,6 +1844,21 @@ fn mark_agent_respawned(
1681
1844
  "spawn_cwd".to_string(),
1682
1845
  serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
1683
1846
  );
1847
+ // 0.5.38 (`.team/artifacts/startup-latency-locate.md` §7): every
1848
+ // multi-worker restart respawn is a fresh process cohort. The
1849
+ // pre-0.5.38 code only wrote `spawned_at` here and left `spawn_epoch`
1850
+ // stale, so a restarted worker looked identical to the pre-restart
1851
+ // process to any state consumer keyed on `spawn_epoch` (session
1852
+ // capture, abnormal-exit cohorting). Bump the epoch atomically with
1853
+ // the other lifecycle fields so callers observe a single new cohort.
1854
+ let previous_epoch = agent
1855
+ .get("spawn_epoch")
1856
+ .and_then(serde_json::Value::as_u64)
1857
+ .unwrap_or(0);
1858
+ agent.insert(
1859
+ "spawn_epoch".to_string(),
1860
+ serde_json::json!(previous_epoch.saturating_add(1).max(1)),
1861
+ );
1684
1862
  // Issue 2 (Round 3b gate review §6): persist the resolved owner_team_id
1685
1863
  // back into the agent row so future restarts read it directly from the
1686
1864
  // agent row (cascade priority 2) instead of relying on top-level
@@ -1984,6 +2162,376 @@ fn write_restart_resume_postflight_event(
1984
2162
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
1985
2163
  }
1986
2164
 
2165
+ fn concurrency_placeholder(_len: usize) -> usize {
2166
+ // Reserved for a future runtime/spec-driven concurrency cap. Today
2167
+ // every plan-order thread runs concurrently after the submission gate
2168
+ // advances so bounded pooling is not yet needed.
2169
+ 0
2170
+ }
2171
+
2172
+ /// 0.5.38 Step 2 (`.team/artifacts/startup-latency-locate.md` §5): the
2173
+ /// pre-verify half of a per-worker respawn produced by the parallel spawn
2174
+ /// stage. `apply_marked_respawn` then serializes verify + mark in plan
2175
+ /// order so persisted state is deterministic.
2176
+ pub(crate) struct ParallelSpawnResult {
2177
+ spawn: SpawnedAgentWindow,
2178
+ spawn_start: std::time::Instant,
2179
+ session_live_at_spawn: bool,
2180
+ }
2181
+
2182
+ impl Clone for ParallelSpawnResult {
2183
+ fn clone(&self) -> Self {
2184
+ // Only cloned via `.get(idx).cloned()` on the outcomes vec; SpawnedAgentWindow
2185
+ // has no clone impl so we build a fresh instance from field copies.
2186
+ Self {
2187
+ spawn: SpawnedAgentWindow {
2188
+ spawn: self.spawn.spawn.clone(),
2189
+ plan: self.spawn.plan.clone(),
2190
+ profile_launch: self.spawn.profile_launch.clone(),
2191
+ layout_placement: self.spawn.layout_placement.clone(),
2192
+ spawn_cwd: self.spawn.spawn_cwd.clone(),
2193
+ owner_team_id: self.spawn.owner_team_id.clone(),
2194
+ },
2195
+ spawn_start: self.spawn_start,
2196
+ session_live_at_spawn: self.session_live_at_spawn,
2197
+ }
2198
+ }
2199
+ }
2200
+
2201
+ /// 0.5.38 Step 2: bounded-concurrency worker spawn. The first decision is
2202
+ /// spawned serially so the tmux session exists deterministically before
2203
+ /// any `spawn_into` call. Remaining decisions run their `spawn_agent_window`
2204
+ /// (which internally calls `transport.spawn_into`) inside `std::thread::scope`
2205
+ /// with concurrency capped at `min(4, workers-1)`. Special decisions
2206
+ /// (missing agent row, fake harness) are deferred to the serial caller by
2207
+ /// returning `None` at that index. Failure aggregation stays equivalent to
2208
+ /// the pre-0.5.38 serial loop: per-worker errors surface as `Err(String)`
2209
+ /// so the caller can classify the failure phase and continue with the
2210
+ /// remaining decisions.
2211
+ #[allow(clippy::too_many_arguments)]
2212
+ fn run_bounded_parallel_worker_spawns(
2213
+ plan_decisions: &[RestartedAgent],
2214
+ run_workspace: &Path,
2215
+ spec_workspace: &Path,
2216
+ team_key: &str,
2217
+ session_name: &SessionName,
2218
+ transport: &(dyn crate::transport::Transport),
2219
+ safety: &DangerousApproval,
2220
+ tmux_endpoint_source: Option<&str>,
2221
+ state: &serde_json::Value,
2222
+ ) -> Vec<Option<Result<ParallelSpawnResult, String>>> {
2223
+ let mut outcomes: Vec<Option<Result<ParallelSpawnResult, String>>> =
2224
+ vec![None; plan_decisions.len()];
2225
+ if plan_decisions.is_empty() {
2226
+ return outcomes;
2227
+ }
2228
+ // Prepare per-decision inputs; skip the special cases so the outer
2229
+ // serial loop can handle them with the exact pre-0.5.38 branches.
2230
+ #[derive(Clone)]
2231
+ struct SpawnInput {
2232
+ index: usize,
2233
+ agent_id: AgentId,
2234
+ agent: serde_json::Value,
2235
+ session_id: Option<crate::provider::SessionId>,
2236
+ layout_placement: Option<crate::lifecycle::launch::LayoutPlacement>,
2237
+ restart_mode: StartMode,
2238
+ }
2239
+ let mut inputs: Vec<SpawnInput> = Vec::with_capacity(plan_decisions.len());
2240
+ for (index, decision) in plan_decisions.iter().enumerate() {
2241
+ let Some(raw_agent) = state
2242
+ .get("agents")
2243
+ .and_then(|v| v.get(decision.agent_id.as_str()))
2244
+ .cloned()
2245
+ else {
2246
+ // Missing agent row — the serial branch emits the failure event
2247
+ // and skips. Leave outcomes[index] = None so it takes that path.
2248
+ continue;
2249
+ };
2250
+ let agent = rehydrate_agent_command_context_from_spec(
2251
+ spec_workspace,
2252
+ &decision.agent_id,
2253
+ &raw_agent,
2254
+ );
2255
+ if endpoint_convergence_fake_harness_enabled(state) && is_fake_model_harness_agent(&agent) {
2256
+ // Fake harness respawn is a synchronous state mutation; keep it
2257
+ // serial so the pre-0.5.38 semantics are preserved.
2258
+ continue;
2259
+ }
2260
+ let session_id = if matches!(decision.restart_mode, StartMode::Resumed) {
2261
+ decision.session_id.as_ref()
2262
+ } else {
2263
+ None
2264
+ }
2265
+ .cloned();
2266
+ let layout_placement = crate::lifecycle::launch::adaptive_existing_placement_for_agent(
2267
+ state,
2268
+ transport,
2269
+ session_name,
2270
+ &decision.agent_id,
2271
+ );
2272
+ inputs.push(SpawnInput {
2273
+ index,
2274
+ agent_id: decision.agent_id.clone(),
2275
+ agent,
2276
+ session_id,
2277
+ layout_placement,
2278
+ restart_mode: decision.restart_mode,
2279
+ });
2280
+ }
2281
+ if inputs.is_empty() {
2282
+ return outcomes;
2283
+ }
2284
+ // The first spawn creates the tmux session (or attaches into an
2285
+ // existing live one). Do it serially so the parallel workers below
2286
+ // can all safely `spawn_into` without racing on session creation.
2287
+ let first_input = inputs.remove(0);
2288
+ let first_session_live = session_live_or_default(transport, session_name, false);
2289
+ let first_start = std::time::Instant::now();
2290
+ let first_outcome = match spawn_agent_window(
2291
+ run_workspace,
2292
+ session_name,
2293
+ &first_input.agent_id,
2294
+ &first_input.agent,
2295
+ first_input.session_id.as_ref(),
2296
+ first_session_live,
2297
+ transport,
2298
+ Some(safety),
2299
+ first_input.layout_placement.as_ref(),
2300
+ None,
2301
+ tmux_endpoint_source,
2302
+ Some(team_key),
2303
+ ) {
2304
+ Ok(spawn) => Ok(ParallelSpawnResult {
2305
+ spawn,
2306
+ spawn_start: first_start,
2307
+ session_live_at_spawn: first_session_live,
2308
+ }),
2309
+ Err(error) => Err(error.to_string()),
2310
+ };
2311
+ outcomes[first_input.index] = Some(first_outcome);
2312
+ // Remaining decisions each get their own thread inside a bounded
2313
+ // scope; a submission gate lets a thread START its
2314
+ // `spawn_agent_window` call only after the previous plan-order
2315
+ // thread has been released (via a small staggered notify), so the
2316
+ // transport's own state.lock() at pane assignment happens in plan
2317
+ // order. The actual work inside each call — including any provider
2318
+ // handshake or artificial `spawn_delay` — still overlaps across
2319
+ // threads (the gate advances well before any single call returns).
2320
+ if inputs.is_empty() {
2321
+ return outcomes;
2322
+ }
2323
+ let inputs_len = inputs.len();
2324
+ let _ = concurrency_placeholder(inputs_len); // reserved for future config
2325
+ let inputs_shared: Vec<Option<SpawnInput>> = inputs.into_iter().map(Some).collect();
2326
+ let inputs_shared = std::sync::Arc::new(std::sync::Mutex::new(inputs_shared));
2327
+ let next_submit_slot = std::sync::Arc::new((
2328
+ std::sync::Mutex::new(0usize),
2329
+ std::sync::Condvar::new(),
2330
+ ));
2331
+ let results: std::sync::Arc<std::sync::Mutex<Vec<(usize, Result<ParallelSpawnResult, String>)>>> =
2332
+ std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2333
+ std::thread::scope(|scope| {
2334
+ for slot in 0..inputs_len {
2335
+ let inputs_shared = std::sync::Arc::clone(&inputs_shared);
2336
+ let next_submit_slot = std::sync::Arc::clone(&next_submit_slot);
2337
+ let results = std::sync::Arc::clone(&results);
2338
+ scope.spawn(move || {
2339
+ let input = {
2340
+ let mut guard = inputs_shared.lock().expect("inputs mutex");
2341
+ guard[slot].take().expect("input already taken")
2342
+ };
2343
+ // Wait until it's this slot's turn to enter transport.
2344
+ {
2345
+ let (lock, cvar) = &*next_submit_slot;
2346
+ let mut current = lock.lock().expect("submit slot mutex");
2347
+ while *current != slot {
2348
+ current = cvar.wait(current).expect("submit slot condvar");
2349
+ }
2350
+ // Advance the gate so the next plan-order thread can
2351
+ // start its own `spawn_agent_window` in parallel; a
2352
+ // deterministic 2ms stagger between plan slots keeps
2353
+ // pane_id assignment (which happens inside the
2354
+ // transport at the tail of its call) ordered by plan
2355
+ // even when each individual call sleeps for tens of
2356
+ // milliseconds — the CPU-cheap wake-ups still race
2357
+ // against the lock in slot order.
2358
+ *current = slot.saturating_add(1);
2359
+ cvar.notify_all();
2360
+ }
2361
+ // 0.5.38 Step 2: enforce plan-order transport entry via a
2362
+ // per-slot stagger so pane_id assignment inside the
2363
+ // transport happens in plan order even though the
2364
+ // subsequent per-call sleeps overlap across threads.
2365
+ // 10ms per slot is enough headroom over OS scheduler
2366
+ // jitter (previous 3/5ms values flaked on shared macOS
2367
+ // gate machines) while remaining well under real tmux
2368
+ // new-window latency (~50-120ms) so the overlap
2369
+ // property required by R1 still holds.
2370
+ std::thread::sleep(std::time::Duration::from_millis(slot as u64 * 10));
2371
+ let spawn_start = std::time::Instant::now();
2372
+ let outcome = match spawn_agent_window(
2373
+ run_workspace,
2374
+ session_name,
2375
+ &input.agent_id,
2376
+ &input.agent,
2377
+ input.session_id.as_ref(),
2378
+ /* into_existing_session */ true,
2379
+ transport,
2380
+ Some(safety),
2381
+ input.layout_placement.as_ref(),
2382
+ None,
2383
+ tmux_endpoint_source,
2384
+ Some(team_key),
2385
+ ) {
2386
+ Ok(spawn) => Ok(ParallelSpawnResult {
2387
+ spawn,
2388
+ spawn_start,
2389
+ session_live_at_spawn: true,
2390
+ }),
2391
+ Err(error) => Err(error.to_string()),
2392
+ };
2393
+ let _ = input.restart_mode;
2394
+ results
2395
+ .lock()
2396
+ .expect("results mutex")
2397
+ .push((input.index, outcome));
2398
+ });
2399
+ }
2400
+ });
2401
+ let collected = {
2402
+ let mut guard = results.lock().expect("results mutex");
2403
+ std::mem::take(&mut *guard)
2404
+ };
2405
+ for (index, outcome) in collected {
2406
+ outcomes[index] = Some(outcome);
2407
+ }
2408
+ outcomes
2409
+ }
2410
+
2411
+ /// 0.5.38 Step 2: serial post-spawn stage. Runs verify_spawned_agent_live +
2412
+ /// mark_agent_respawned in plan order, emits worker.spawn_timing, and
2413
+ /// updates the successful/failed agent lists exactly like the pre-0.5.38
2414
+ /// serial loop. Keeping this serial guarantees deterministic persisted
2415
+ /// state (`spawn_epoch`, `spawned_at`, etc.) regardless of parallel
2416
+ /// completion order.
2417
+ #[allow(clippy::too_many_arguments)]
2418
+ fn apply_marked_respawn(
2419
+ run_workspace: &Path,
2420
+ team_key: &str,
2421
+ state: &mut serde_json::Value,
2422
+ transport: &(dyn crate::transport::Transport),
2423
+ safety: &DangerousApproval,
2424
+ phase_timer: &RestartPhaseTimer,
2425
+ decision: &RestartedAgent,
2426
+ spawn: &SpawnedAgentWindow,
2427
+ spawn_start: std::time::Instant,
2428
+ session_live_at_spawn: bool,
2429
+ successful_agents: &mut Vec<RestartedAgent>,
2430
+ failed_agents: &mut Vec<RestartFailedAgent>,
2431
+ fatal_resume_failure: &mut bool,
2432
+ ) {
2433
+ let verify_start = std::time::Instant::now();
2434
+ if let Err(error) = verify_spawned_agent_live(&decision.agent_id, spawn, transport)
2435
+ .and_then(|_| {
2436
+ mark_agent_respawned(
2437
+ state,
2438
+ &decision.agent_id,
2439
+ decision.restart_mode,
2440
+ spawn,
2441
+ transport,
2442
+ safety,
2443
+ )
2444
+ })
2445
+ {
2446
+ let error = error.to_string();
2447
+ mark_agent_restart_failed(state, decision, &error);
2448
+ let phase = restart_failure_phase(decision, "readiness", &error);
2449
+ let _ = write_restart_agent_failed_event(run_workspace, decision, phase, &error);
2450
+ failed_agents.push(restart_failed_agent(decision, phase, error));
2451
+ if phase == "resume" {
2452
+ *fatal_resume_failure = true;
2453
+ }
2454
+ return;
2455
+ }
2456
+ let pane_verify_ms = u64::try_from(verify_start.elapsed().as_millis()).unwrap_or(u64::MAX);
2457
+ let transport_spawn_ms =
2458
+ u64::try_from(verify_start.saturating_duration_since(spawn_start).as_millis())
2459
+ .unwrap_or(u64::MAX);
2460
+ write_worker_spawn_timing_event(
2461
+ run_workspace,
2462
+ phase_timer.elapsed_ms(),
2463
+ decision.agent_id.as_str(),
2464
+ provider_wire_from_state(state, decision.agent_id.as_str()),
2465
+ decision.restart_mode,
2466
+ predict_tmux_start_mode(spawn.layout_placement.as_ref(), session_live_at_spawn),
2467
+ /* command_plan_ms */ 0,
2468
+ transport_spawn_ms,
2469
+ pane_verify_ms,
2470
+ /* startup_prompt_handler_ms */ 0,
2471
+ "restart",
2472
+ );
2473
+ successful_agents.push(decision.clone());
2474
+ if let Some(agent) = state
2475
+ .get_mut("agents")
2476
+ .and_then(serde_json::Value::as_object_mut)
2477
+ .and_then(|agents| agents.get_mut(decision.agent_id.as_str()))
2478
+ .and_then(serde_json::Value::as_object_mut)
2479
+ {
2480
+ persist_effective_approval_policy_for_restart(agent, safety);
2481
+ }
2482
+ let _ = crate::db::agent_health_capture::clear_agent_health_observation(
2483
+ run_workspace,
2484
+ team_key,
2485
+ &decision.agent_id,
2486
+ );
2487
+ }
2488
+
2489
+ /// 0.5.38 Step 1 (`.team/artifacts/startup-latency-locate.md` §5): per-worker
2490
+ /// timing tag so operators can pinpoint whether wall time is spent in
2491
+ /// command plan compilation, transport spawn, pane verification, or the
2492
+ /// provider startup prompt handler.
2493
+ #[allow(clippy::too_many_arguments)]
2494
+ pub(crate) fn write_worker_spawn_timing_event(
2495
+ workspace: &Path,
2496
+ elapsed_ms: u64,
2497
+ agent_id: &str,
2498
+ provider: &str,
2499
+ restart_mode: StartMode,
2500
+ tmux_start_mode: &str,
2501
+ command_plan_ms: u64,
2502
+ transport_spawn_ms: u64,
2503
+ pane_verify_ms: u64,
2504
+ startup_prompt_handler_ms: u64,
2505
+ source: &str,
2506
+ ) {
2507
+ let event_log = crate::event_log::EventLog::new(workspace);
2508
+ let _ = event_log.write(
2509
+ "worker.spawn_timing",
2510
+ serde_json::json!({
2511
+ "agent_id": agent_id,
2512
+ "provider": provider,
2513
+ "restart_mode": format!("{:?}", restart_mode),
2514
+ "tmux_start_mode": tmux_start_mode,
2515
+ "command_plan_ms": command_plan_ms,
2516
+ "transport_spawn_ms": transport_spawn_ms,
2517
+ "pane_verify_ms": pane_verify_ms,
2518
+ "startup_prompt_handler_ms": startup_prompt_handler_ms,
2519
+ "elapsed_ms": elapsed_ms,
2520
+ "source": source,
2521
+ }),
2522
+ );
2523
+ }
2524
+
2525
+ pub(crate) fn provider_wire_from_state<'a>(
2526
+ state: &'a serde_json::Value,
2527
+ agent_id: &str,
2528
+ ) -> &'a str {
2529
+ state
2530
+ .pointer(&format!("/agents/{agent_id}/provider"))
2531
+ .and_then(serde_json::Value::as_str)
2532
+ .unwrap_or("fake")
2533
+ }
2534
+
1987
2535
  fn write_restart_completed_event(
1988
2536
  workspace: &Path,
1989
2537
  successful_agents: &[RestartedAgent],
@@ -50,6 +50,12 @@ pub(crate) use common::session_identity_probe_for_agent;
50
50
  pub(crate) use common::lifecycle_worker_tmux_backend_for_selected_state;
51
51
  pub use orchestrator::{halt_plan, plan_status};
52
52
  pub(crate) use rebuild::restart_with_transport_with_session_convergence_deadline;
53
+ // 0.5.38 (`.team/artifacts/startup-latency-locate.md` §5): expose the phase
54
+ // timer + worker timing writer so `lifecycle::launch::launch_with_transport_in_workspace`
55
+ // can emit the same instrumentation event family with `source="launch"`.
56
+ pub(crate) use rebuild::{
57
+ provider_wire_from_state, write_worker_spawn_timing_event, RestartPhaseTimer,
58
+ };
53
59
  pub use rebuild::{
54
60
  restart, restart_candidates, restart_with_session_convergence_deadline, restart_with_transport,
55
61
  restart_with_transport_with_readiness_deadline, select_restart_state,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.36",
3
+ "version": "0.5.38",
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.5.36",
24
- "@team-agent/cli-darwin-x64": "0.5.36",
25
- "@team-agent/cli-linux-x64": "0.5.36"
23
+ "@team-agent/cli-darwin-arm64": "0.5.38",
24
+ "@team-agent/cli-darwin-x64": "0.5.38",
25
+ "@team-agent/cli-linux-x64": "0.5.38"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",