@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/diagnose.rs +28 -3
- package/crates/team-agent/src/lifecycle/display.rs +58 -121
- package/crates/team-agent/src/lifecycle/launch.rs +44 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +18 -5
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +569 -4
- package/crates/team-agent/src/lifecycle/restart.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +11 -1
- package/crates/team-agent/src/tmux_backend.rs +175 -0
- package/crates/team-agent/src/transport.rs +38 -0
- package/package.json +4 -4
|
@@ -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
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
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
|
|
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],
|