@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.
@@ -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
@@ -1871,6 +2049,16 @@ fn restart_failure_phase(
1871
2049
  phase: &'static str,
1872
2050
  error: &str,
1873
2051
  ) -> &'static str {
2052
+ // 0.5.39 Slice 1 (tmux-server-death-locate §11.1 B): promote
2053
+ // spawn/readiness errors whose stderr contains "server exited
2054
+ // unexpectedly" to `tmux_server_crashed`, so downstream event
2055
+ // classification and diagnose next_actions ("team-agent diagnose")
2056
+ // don't misattribute a whole-server death to a per-agent provider
2057
+ // failure. Restart itself does not recover from server crashes in
2058
+ // 0.5.39 (§11.1 C is Slice 3, later car); it just classifies clearly.
2059
+ if is_tmux_server_crashed(error) {
2060
+ return "tmux_server_crashed";
2061
+ }
1874
2062
  if is_resume_integrity_failure(decision, phase, error) {
1875
2063
  "resume"
1876
2064
  } else {
@@ -1878,6 +2066,10 @@ fn restart_failure_phase(
1878
2066
  }
1879
2067
  }
1880
2068
 
2069
+ fn is_tmux_server_crashed(error: &str) -> bool {
2070
+ error.contains("server exited unexpectedly")
2071
+ }
2072
+
1881
2073
  fn is_resume_integrity_failure(decision: &RestartedAgent, phase: &str, error: &str) -> bool {
1882
2074
  if !matches!(decision.restart_mode, StartMode::Resumed) {
1883
2075
  return false;
@@ -1984,6 +2176,379 @@ fn write_restart_resume_postflight_event(
1984
2176
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))
1985
2177
  }
1986
2178
 
2179
+ fn concurrency_placeholder(_len: usize) -> usize {
2180
+ // Reserved for a future runtime/spec-driven concurrency cap. Today
2181
+ // every plan-order thread runs concurrently after the submission gate
2182
+ // advances so bounded pooling is not yet needed.
2183
+ 0
2184
+ }
2185
+
2186
+ /// 0.5.38 Step 2 (`.team/artifacts/startup-latency-locate.md` §5): the
2187
+ /// pre-verify half of a per-worker respawn produced by the parallel spawn
2188
+ /// stage. `apply_marked_respawn` then serializes verify + mark in plan
2189
+ /// order so persisted state is deterministic.
2190
+ pub(crate) struct ParallelSpawnResult {
2191
+ spawn: SpawnedAgentWindow,
2192
+ spawn_start: std::time::Instant,
2193
+ session_live_at_spawn: bool,
2194
+ }
2195
+
2196
+ impl Clone for ParallelSpawnResult {
2197
+ fn clone(&self) -> Self {
2198
+ // Only cloned via `.get(idx).cloned()` on the outcomes vec; SpawnedAgentWindow
2199
+ // has no clone impl so we build a fresh instance from field copies.
2200
+ Self {
2201
+ spawn: SpawnedAgentWindow {
2202
+ spawn: self.spawn.spawn.clone(),
2203
+ plan: self.spawn.plan.clone(),
2204
+ profile_launch: self.spawn.profile_launch.clone(),
2205
+ layout_placement: self.spawn.layout_placement.clone(),
2206
+ spawn_cwd: self.spawn.spawn_cwd.clone(),
2207
+ owner_team_id: self.spawn.owner_team_id.clone(),
2208
+ },
2209
+ spawn_start: self.spawn_start,
2210
+ session_live_at_spawn: self.session_live_at_spawn,
2211
+ }
2212
+ }
2213
+ }
2214
+
2215
+ /// 0.5.38 Step 2: bounded-concurrency worker spawn. The first decision is
2216
+ /// spawned serially so the tmux session exists deterministically before
2217
+ /// any `spawn_into` call. Remaining decisions run their `spawn_agent_window`
2218
+ /// (which internally calls `transport.spawn_into`) inside `std::thread::scope`
2219
+ /// with concurrency capped at `min(4, workers-1)`. Special decisions
2220
+ /// (missing agent row, fake harness) are deferred to the serial caller by
2221
+ /// returning `None` at that index. Failure aggregation stays equivalent to
2222
+ /// the pre-0.5.38 serial loop: per-worker errors surface as `Err(String)`
2223
+ /// so the caller can classify the failure phase and continue with the
2224
+ /// remaining decisions.
2225
+ #[allow(clippy::too_many_arguments)]
2226
+ fn run_bounded_parallel_worker_spawns(
2227
+ plan_decisions: &[RestartedAgent],
2228
+ run_workspace: &Path,
2229
+ spec_workspace: &Path,
2230
+ team_key: &str,
2231
+ session_name: &SessionName,
2232
+ transport: &(dyn crate::transport::Transport),
2233
+ safety: &DangerousApproval,
2234
+ tmux_endpoint_source: Option<&str>,
2235
+ state: &serde_json::Value,
2236
+ ) -> Vec<Option<Result<ParallelSpawnResult, String>>> {
2237
+ let mut outcomes: Vec<Option<Result<ParallelSpawnResult, String>>> =
2238
+ vec![None; plan_decisions.len()];
2239
+ if plan_decisions.is_empty() {
2240
+ return outcomes;
2241
+ }
2242
+ // Prepare per-decision inputs; skip the special cases so the outer
2243
+ // serial loop can handle them with the exact pre-0.5.38 branches.
2244
+ #[derive(Clone)]
2245
+ struct SpawnInput {
2246
+ index: usize,
2247
+ agent_id: AgentId,
2248
+ agent: serde_json::Value,
2249
+ session_id: Option<crate::provider::SessionId>,
2250
+ layout_placement: Option<crate::lifecycle::launch::LayoutPlacement>,
2251
+ restart_mode: StartMode,
2252
+ }
2253
+ let mut inputs: Vec<SpawnInput> = Vec::with_capacity(plan_decisions.len());
2254
+ for (index, decision) in plan_decisions.iter().enumerate() {
2255
+ let Some(raw_agent) = state
2256
+ .get("agents")
2257
+ .and_then(|v| v.get(decision.agent_id.as_str()))
2258
+ .cloned()
2259
+ else {
2260
+ // Missing agent row — the serial branch emits the failure event
2261
+ // and skips. Leave outcomes[index] = None so it takes that path.
2262
+ continue;
2263
+ };
2264
+ let agent = rehydrate_agent_command_context_from_spec(
2265
+ spec_workspace,
2266
+ &decision.agent_id,
2267
+ &raw_agent,
2268
+ );
2269
+ if endpoint_convergence_fake_harness_enabled(state) && is_fake_model_harness_agent(&agent) {
2270
+ // Fake harness respawn is a synchronous state mutation; keep it
2271
+ // serial so the pre-0.5.38 semantics are preserved.
2272
+ continue;
2273
+ }
2274
+ let session_id = if matches!(decision.restart_mode, StartMode::Resumed) {
2275
+ decision.session_id.as_ref()
2276
+ } else {
2277
+ None
2278
+ }
2279
+ .cloned();
2280
+ let layout_placement = crate::lifecycle::launch::adaptive_existing_placement_for_agent(
2281
+ state,
2282
+ transport,
2283
+ session_name,
2284
+ &decision.agent_id,
2285
+ );
2286
+ inputs.push(SpawnInput {
2287
+ index,
2288
+ agent_id: decision.agent_id.clone(),
2289
+ agent,
2290
+ session_id,
2291
+ layout_placement,
2292
+ restart_mode: decision.restart_mode,
2293
+ });
2294
+ }
2295
+ if inputs.is_empty() {
2296
+ return outcomes;
2297
+ }
2298
+ // The first spawn creates the tmux session (or attaches into an
2299
+ // existing live one). Do it serially so the parallel workers below
2300
+ // can all safely `spawn_into` without racing on session creation.
2301
+ let first_input = inputs.remove(0);
2302
+ let first_session_live = session_live_or_default(transport, session_name, false);
2303
+ let first_start = std::time::Instant::now();
2304
+ let first_outcome = match spawn_agent_window(
2305
+ run_workspace,
2306
+ session_name,
2307
+ &first_input.agent_id,
2308
+ &first_input.agent,
2309
+ first_input.session_id.as_ref(),
2310
+ first_session_live,
2311
+ transport,
2312
+ Some(safety),
2313
+ first_input.layout_placement.as_ref(),
2314
+ None,
2315
+ tmux_endpoint_source,
2316
+ Some(team_key),
2317
+ ) {
2318
+ Ok(spawn) => Ok(ParallelSpawnResult {
2319
+ spawn,
2320
+ spawn_start: first_start,
2321
+ session_live_at_spawn: first_session_live,
2322
+ }),
2323
+ Err(error) => Err(error.to_string()),
2324
+ };
2325
+ outcomes[first_input.index] = Some(first_outcome);
2326
+ // Remaining decisions each get their own thread inside a bounded
2327
+ // scope; a submission gate lets a thread START its
2328
+ // `spawn_agent_window` call only after the previous plan-order
2329
+ // thread has been released (via a small staggered notify), so the
2330
+ // transport's own state.lock() at pane assignment happens in plan
2331
+ // order. The actual work inside each call — including any provider
2332
+ // handshake or artificial `spawn_delay` — still overlaps across
2333
+ // threads (the gate advances well before any single call returns).
2334
+ if inputs.is_empty() {
2335
+ return outcomes;
2336
+ }
2337
+ let inputs_len = inputs.len();
2338
+ let _ = concurrency_placeholder(inputs_len); // reserved for future config
2339
+ let inputs_shared: Vec<Option<SpawnInput>> = inputs.into_iter().map(Some).collect();
2340
+ let inputs_shared = std::sync::Arc::new(std::sync::Mutex::new(inputs_shared));
2341
+ let next_submit_slot = std::sync::Arc::new((
2342
+ std::sync::Mutex::new(0usize),
2343
+ std::sync::Condvar::new(),
2344
+ ));
2345
+ let results: std::sync::Arc<std::sync::Mutex<Vec<(usize, Result<ParallelSpawnResult, String>)>>> =
2346
+ std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2347
+ std::thread::scope(|scope| {
2348
+ for slot in 0..inputs_len {
2349
+ let inputs_shared = std::sync::Arc::clone(&inputs_shared);
2350
+ let next_submit_slot = std::sync::Arc::clone(&next_submit_slot);
2351
+ let results = std::sync::Arc::clone(&results);
2352
+ scope.spawn(move || {
2353
+ let input = {
2354
+ let mut guard = inputs_shared.lock().expect("inputs mutex");
2355
+ guard[slot].take().expect("input already taken")
2356
+ };
2357
+ // Wait until it's this slot's turn to enter transport.
2358
+ {
2359
+ let (lock, cvar) = &*next_submit_slot;
2360
+ let mut current = lock.lock().expect("submit slot mutex");
2361
+ while *current != slot {
2362
+ current = cvar.wait(current).expect("submit slot condvar");
2363
+ }
2364
+ // Advance the gate so the next plan-order thread can
2365
+ // start its own `spawn_agent_window` in parallel; a
2366
+ // deterministic 10ms stagger between plan slots keeps
2367
+ // pane_id assignment (which happens inside the
2368
+ // transport at the tail of its call) ordered by plan
2369
+ // even when each individual call sleeps for tens of
2370
+ // milliseconds — the CPU-cheap wake-ups still race
2371
+ // against the lock in slot order.
2372
+ *current = slot.saturating_add(1);
2373
+ cvar.notify_all();
2374
+ }
2375
+ // 0.5.38 Step 2: enforce plan-order transport entry via a
2376
+ // per-slot stagger so pane_id assignment inside the
2377
+ // transport happens in plan order even though the
2378
+ // subsequent per-call sleeps overlap across threads.
2379
+ // 10ms per slot is enough headroom over OS scheduler jitter
2380
+ // (previous 3/5ms values flaked on shared macOS gate
2381
+ // machines) while remaining well under real tmux
2382
+ // new-window latency (~50-120ms) so the overlap
2383
+ // property required by R1 still holds. Reverse guard for
2384
+ // this stagger lives in
2385
+ // `tests/parallel_spawn_stagger_reverse_guard.rs`
2386
+ // (0.5.38 cr observation D-k).
2387
+ std::thread::sleep(std::time::Duration::from_millis(slot as u64 * 10));
2388
+ let spawn_start = std::time::Instant::now();
2389
+ let outcome = match spawn_agent_window(
2390
+ run_workspace,
2391
+ session_name,
2392
+ &input.agent_id,
2393
+ &input.agent,
2394
+ input.session_id.as_ref(),
2395
+ /* into_existing_session */ true,
2396
+ transport,
2397
+ Some(safety),
2398
+ input.layout_placement.as_ref(),
2399
+ None,
2400
+ tmux_endpoint_source,
2401
+ Some(team_key),
2402
+ ) {
2403
+ Ok(spawn) => Ok(ParallelSpawnResult {
2404
+ spawn,
2405
+ spawn_start,
2406
+ session_live_at_spawn: true,
2407
+ }),
2408
+ Err(error) => Err(error.to_string()),
2409
+ };
2410
+ let _ = input.restart_mode;
2411
+ results
2412
+ .lock()
2413
+ .expect("results mutex")
2414
+ .push((input.index, outcome));
2415
+ });
2416
+ }
2417
+ });
2418
+ let collected = {
2419
+ let mut guard = results.lock().expect("results mutex");
2420
+ std::mem::take(&mut *guard)
2421
+ };
2422
+ for (index, outcome) in collected {
2423
+ outcomes[index] = Some(outcome);
2424
+ }
2425
+ outcomes
2426
+ }
2427
+
2428
+ /// 0.5.38 Step 2: serial post-spawn stage. Runs verify_spawned_agent_live +
2429
+ /// mark_agent_respawned in plan order, emits worker.spawn_timing, and
2430
+ /// updates the successful/failed agent lists exactly like the pre-0.5.38
2431
+ /// serial loop. Keeping this serial guarantees deterministic persisted
2432
+ /// state (`spawn_epoch`, `spawned_at`, etc.) regardless of parallel
2433
+ /// completion order.
2434
+ #[allow(clippy::too_many_arguments)]
2435
+ fn apply_marked_respawn(
2436
+ run_workspace: &Path,
2437
+ team_key: &str,
2438
+ state: &mut serde_json::Value,
2439
+ transport: &(dyn crate::transport::Transport),
2440
+ safety: &DangerousApproval,
2441
+ phase_timer: &RestartPhaseTimer,
2442
+ decision: &RestartedAgent,
2443
+ spawn: &SpawnedAgentWindow,
2444
+ spawn_start: std::time::Instant,
2445
+ session_live_at_spawn: bool,
2446
+ successful_agents: &mut Vec<RestartedAgent>,
2447
+ failed_agents: &mut Vec<RestartFailedAgent>,
2448
+ fatal_resume_failure: &mut bool,
2449
+ ) {
2450
+ let verify_start = std::time::Instant::now();
2451
+ if let Err(error) = verify_spawned_agent_live(&decision.agent_id, spawn, transport)
2452
+ .and_then(|_| {
2453
+ mark_agent_respawned(
2454
+ state,
2455
+ &decision.agent_id,
2456
+ decision.restart_mode,
2457
+ spawn,
2458
+ transport,
2459
+ safety,
2460
+ )
2461
+ })
2462
+ {
2463
+ let error = error.to_string();
2464
+ mark_agent_restart_failed(state, decision, &error);
2465
+ let phase = restart_failure_phase(decision, "readiness", &error);
2466
+ let _ = write_restart_agent_failed_event(run_workspace, decision, phase, &error);
2467
+ failed_agents.push(restart_failed_agent(decision, phase, error));
2468
+ if phase == "resume" {
2469
+ *fatal_resume_failure = true;
2470
+ }
2471
+ return;
2472
+ }
2473
+ let pane_verify_ms = u64::try_from(verify_start.elapsed().as_millis()).unwrap_or(u64::MAX);
2474
+ let transport_spawn_ms =
2475
+ u64::try_from(verify_start.saturating_duration_since(spawn_start).as_millis())
2476
+ .unwrap_or(u64::MAX);
2477
+ write_worker_spawn_timing_event(
2478
+ run_workspace,
2479
+ phase_timer.elapsed_ms(),
2480
+ decision.agent_id.as_str(),
2481
+ provider_wire_from_state(state, decision.agent_id.as_str()),
2482
+ decision.restart_mode,
2483
+ predict_tmux_start_mode(spawn.layout_placement.as_ref(), session_live_at_spawn),
2484
+ /* command_plan_ms */ 0,
2485
+ transport_spawn_ms,
2486
+ pane_verify_ms,
2487
+ /* startup_prompt_handler_ms */ 0,
2488
+ "restart",
2489
+ );
2490
+ successful_agents.push(decision.clone());
2491
+ if let Some(agent) = state
2492
+ .get_mut("agents")
2493
+ .and_then(serde_json::Value::as_object_mut)
2494
+ .and_then(|agents| agents.get_mut(decision.agent_id.as_str()))
2495
+ .and_then(serde_json::Value::as_object_mut)
2496
+ {
2497
+ persist_effective_approval_policy_for_restart(agent, safety);
2498
+ }
2499
+ let _ = crate::db::agent_health_capture::clear_agent_health_observation(
2500
+ run_workspace,
2501
+ team_key,
2502
+ &decision.agent_id,
2503
+ );
2504
+ }
2505
+
2506
+ /// 0.5.38 Step 1 (`.team/artifacts/startup-latency-locate.md` §5): per-worker
2507
+ /// timing tag so operators can pinpoint whether wall time is spent in
2508
+ /// command plan compilation, transport spawn, pane verification, or the
2509
+ /// provider startup prompt handler.
2510
+ #[allow(clippy::too_many_arguments)]
2511
+ pub(crate) fn write_worker_spawn_timing_event(
2512
+ workspace: &Path,
2513
+ elapsed_ms: u64,
2514
+ agent_id: &str,
2515
+ provider: &str,
2516
+ restart_mode: StartMode,
2517
+ tmux_start_mode: &str,
2518
+ command_plan_ms: u64,
2519
+ transport_spawn_ms: u64,
2520
+ pane_verify_ms: u64,
2521
+ startup_prompt_handler_ms: u64,
2522
+ source: &str,
2523
+ ) {
2524
+ let event_log = crate::event_log::EventLog::new(workspace);
2525
+ let _ = event_log.write(
2526
+ "worker.spawn_timing",
2527
+ serde_json::json!({
2528
+ "agent_id": agent_id,
2529
+ "provider": provider,
2530
+ "restart_mode": format!("{:?}", restart_mode),
2531
+ "tmux_start_mode": tmux_start_mode,
2532
+ "command_plan_ms": command_plan_ms,
2533
+ "transport_spawn_ms": transport_spawn_ms,
2534
+ "pane_verify_ms": pane_verify_ms,
2535
+ "startup_prompt_handler_ms": startup_prompt_handler_ms,
2536
+ "elapsed_ms": elapsed_ms,
2537
+ "source": source,
2538
+ }),
2539
+ );
2540
+ }
2541
+
2542
+ pub(crate) fn provider_wire_from_state<'a>(
2543
+ state: &'a serde_json::Value,
2544
+ agent_id: &str,
2545
+ ) -> &'a str {
2546
+ state
2547
+ .pointer(&format!("/agents/{agent_id}/provider"))
2548
+ .and_then(serde_json::Value::as_str)
2549
+ .unwrap_or("fake")
2550
+ }
2551
+
1987
2552
  fn write_restart_completed_event(
1988
2553
  workspace: &Path,
1989
2554
  successful_agents: &[RestartedAgent],