@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +57 -1
- package/crates/team-agent/src/coordinator/tests/api_error_recovery.rs +57 -1
- package/crates/team-agent/src/lifecycle/launch.rs +44 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +552 -4
- package/crates/team-agent/src/lifecycle/restart.rs +6 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
-
|
|
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":
|
|
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
|
-
//
|
|
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
|
|
@@ -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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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",
|