@team-agent/installer 0.3.20 → 0.3.22
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/emit.rs +9 -1
- package/crates/team-agent/src/cli/mod.rs +158 -2
- package/crates/team-agent/src/cli/status.rs +61 -0
- package/crates/team-agent/src/cli/status_port.rs +2 -2
- package/crates/team-agent/src/cli/tests/base.rs +26 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +251 -2
- package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tick.rs +452 -17
- package/crates/team-agent/src/lifecycle/display.rs +23 -302
- package/crates/team-agent/src/lifecycle/launch.rs +38 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
- package/crates/team-agent/src/lifecycle/restart/common.rs +76 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
- package/crates/team-agent/src/messaging/delivery.rs +324 -95
- package/crates/team-agent/src/messaging/scheduler.rs +95 -73
- package/crates/team-agent/src/messaging/send.rs +10 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
- package/crates/team-agent/src/session_capture.rs +261 -1
- package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
- package/crates/team-agent/src/tmux_backend.rs +138 -2
- package/crates/team-agent/src/transport/test_support.rs +25 -0
- package/crates/team-agent/src/transport.rs +11 -0
- package/package.json +4 -4
|
@@ -29,6 +29,11 @@ use super::types::{
|
|
|
29
29
|
IdleAlert, LeaderApiError, SessionDriftResult,
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
const STARTUP_PROMPT_GRACE_SECS: i64 = 120;
|
|
33
|
+
const RUNTIME_APPROVAL_INITIAL_BACKOFF_SECS: i64 = 30;
|
|
34
|
+
const RUNTIME_APPROVAL_MAX_BACKOFF_SECS: i64 = 300;
|
|
35
|
+
const IDLE_HEALTH_CAPTURE_INTERVAL_SECS: i64 = 60;
|
|
36
|
+
|
|
32
37
|
// ===========================================================================
|
|
33
38
|
// TickReport / TickError(§10:tick(..) -> Result<TickReport, TickError>)
|
|
34
39
|
// ===========================================================================
|
|
@@ -213,13 +218,30 @@ impl Coordinator {
|
|
|
213
218
|
}
|
|
214
219
|
|
|
215
220
|
self.record_step("capture_missing");
|
|
216
|
-
self.capture_missing_sessions(&mut state, &event_log)
|
|
221
|
+
if let Err(error) = self.capture_missing_sessions(&mut state, &event_log) {
|
|
222
|
+
let _ = event_log.write(
|
|
223
|
+
"coordinator.tick.capture_missing_failed",
|
|
224
|
+
serde_json::json!({"error": error.to_string()}),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Slice 1 energy gate: one pane snapshot per tick feeds probe eligibility,
|
|
229
|
+
// health sync, and abnormal-exit detection. Missing panes are filtered
|
|
230
|
+
// before any capture-pane call.
|
|
231
|
+
let pane_snapshot = self.transport.list_targets().unwrap_or_default();
|
|
232
|
+
let window_snapshot = state
|
|
233
|
+
.get("session_name")
|
|
234
|
+
.and_then(Value::as_str)
|
|
235
|
+
.map(crate::transport::SessionName::new)
|
|
236
|
+
.and_then(|session| self.transport.list_windows(&session).ok())
|
|
237
|
+
.unwrap_or_default();
|
|
238
|
+
let has_work_obligation = tick_has_work_obligation(&store);
|
|
217
239
|
|
|
218
240
|
self.record_step("refresh_statuses");
|
|
219
241
|
// TODO(spine slice 2b): split lightweight runtime status refresh from health sync.
|
|
220
242
|
|
|
221
243
|
self.record_step("startup_prompts");
|
|
222
|
-
self.handle_startup_prompts(&mut state, &event_log);
|
|
244
|
+
self.handle_startup_prompts(&mut state, &event_log, &pane_snapshot, &window_snapshot);
|
|
223
245
|
|
|
224
246
|
// #229 step2-retry: once an agent's `startup_prompts` flipped to `handled`
|
|
225
247
|
// (this tick OR earlier), `queued_until_trust` messages for that recipient
|
|
@@ -241,7 +263,12 @@ impl Coordinator {
|
|
|
241
263
|
// bug-084 哲学 + A-6 同族:每步独立 try,失败写 `coordinator.tick.<step>_failed`
|
|
242
264
|
// 事件后继续走下一步;tick 本身仍返 Ok。
|
|
243
265
|
self.record_step("runtime_prompts");
|
|
244
|
-
if let Err(error) = self.handle_runtime_approval_prompts(
|
|
266
|
+
if let Err(error) = self.handle_runtime_approval_prompts(
|
|
267
|
+
&mut state,
|
|
268
|
+
&event_log,
|
|
269
|
+
&pane_snapshot,
|
|
270
|
+
&window_snapshot,
|
|
271
|
+
) {
|
|
245
272
|
let _ = event_log.write(
|
|
246
273
|
"coordinator.tick.runtime_prompts_failed",
|
|
247
274
|
serde_json::json!({"error": error.to_string()}),
|
|
@@ -252,18 +279,23 @@ impl Coordinator {
|
|
|
252
279
|
// P5 (C-P5-1, N3): ONE pane snapshot per tick, shared by sync_health and the
|
|
253
280
|
// abnormal-exit pass (same-tick reuse only — the snapshot does not outlive
|
|
254
281
|
// this tick; every tick re-reads).
|
|
255
|
-
let
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
282
|
+
let captures_by_agent = match self.sync_agent_health(
|
|
283
|
+
&mut state,
|
|
284
|
+
&store,
|
|
285
|
+
&event_log,
|
|
286
|
+
&pane_snapshot,
|
|
287
|
+
&window_snapshot,
|
|
288
|
+
has_work_obligation,
|
|
289
|
+
) {
|
|
290
|
+
Ok(captures) => captures,
|
|
291
|
+
Err(error) => {
|
|
292
|
+
let _ = event_log.write(
|
|
293
|
+
"coordinator.tick.sync_health_failed",
|
|
294
|
+
serde_json::json!({"error": error.to_string()}),
|
|
295
|
+
);
|
|
296
|
+
BTreeMap::new()
|
|
297
|
+
}
|
|
298
|
+
};
|
|
267
299
|
if let Err(error) = self.detect_abnormal_exits(&mut state, &event_log, &pane_snapshot) {
|
|
268
300
|
let _ = event_log.write(
|
|
269
301
|
"coordinator.tick.detect_abnormal_failed",
|
|
@@ -374,6 +406,15 @@ impl Coordinator {
|
|
|
374
406
|
true,
|
|
375
407
|
0,
|
|
376
408
|
)?;
|
|
409
|
+
for failure in report.capture_failures {
|
|
410
|
+
event_log.write(
|
|
411
|
+
"coordinator.tick.capture_missing_failed",
|
|
412
|
+
serde_json::json!({
|
|
413
|
+
"agent_id": failure.agent_id,
|
|
414
|
+
"error": failure.error,
|
|
415
|
+
}),
|
|
416
|
+
)?;
|
|
417
|
+
}
|
|
377
418
|
for ambiguous in report.ambiguous {
|
|
378
419
|
event_log.write(
|
|
379
420
|
"provider.session.attribution_ambiguous",
|
|
@@ -392,6 +433,8 @@ impl Coordinator {
|
|
|
392
433
|
store: &crate::message_store::MessageStore,
|
|
393
434
|
event_log: &EventLog,
|
|
394
435
|
pane_infos: &[crate::transport::PaneInfo],
|
|
436
|
+
window_snapshot: &[crate::transport::WindowName],
|
|
437
|
+
has_work_obligation: bool,
|
|
395
438
|
) -> Result<BTreeMap<AgentId, CapturedRuntimeFact>, TickError> {
|
|
396
439
|
let mut captures = BTreeMap::new();
|
|
397
440
|
let snapshot = state.clone();
|
|
@@ -413,10 +456,16 @@ impl Coordinator {
|
|
|
413
456
|
String,
|
|
414
457
|
Result<Vec<crate::transport::WindowName>, String>,
|
|
415
458
|
> = BTreeMap::new();
|
|
459
|
+
if let Some(session_name) = session_name.as_deref() {
|
|
460
|
+
windows_by_session.insert(session_name.to_string(), Ok(window_snapshot.to_vec()));
|
|
461
|
+
}
|
|
416
462
|
let Some(agents) = state.get_mut("agents").and_then(Value::as_object_mut) else {
|
|
417
463
|
return Ok(captures);
|
|
418
464
|
};
|
|
419
465
|
for (agent_id, agent) in agents {
|
|
466
|
+
if !agent_probe_base_eligible(agent) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
420
469
|
let Some((session, window, target)) =
|
|
421
470
|
capture_window_target(agent, session_name.as_deref())
|
|
422
471
|
else {
|
|
@@ -446,6 +495,9 @@ impl Coordinator {
|
|
|
446
495
|
if !windows.iter().any(|known| known == &window) {
|
|
447
496
|
continue;
|
|
448
497
|
}
|
|
498
|
+
if warm_idle_capture_suppressed(agent, has_work_obligation) {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
449
501
|
let captured = match self
|
|
450
502
|
.transport
|
|
451
503
|
.capture(&target, crate::transport::CaptureRange::Tail(40))
|
|
@@ -499,6 +551,7 @@ impl Coordinator {
|
|
|
499
551
|
current_command.as_deref(),
|
|
500
552
|
last_output_at_now.as_deref(),
|
|
501
553
|
);
|
|
554
|
+
remember_idle_capture_schedule(agent, &activity);
|
|
502
555
|
write_activity(agent, &activity, false);
|
|
503
556
|
let last_output_at = last_output_at_now;
|
|
504
557
|
write_agent_health(
|
|
@@ -755,7 +808,13 @@ impl Coordinator {
|
|
|
755
808
|
Ok(())
|
|
756
809
|
}
|
|
757
810
|
|
|
758
|
-
fn handle_startup_prompts(
|
|
811
|
+
fn handle_startup_prompts(
|
|
812
|
+
&self,
|
|
813
|
+
state: &mut Value,
|
|
814
|
+
event_log: &EventLog,
|
|
815
|
+
pane_infos: &[crate::transport::PaneInfo],
|
|
816
|
+
windows: &[crate::transport::WindowName],
|
|
817
|
+
) {
|
|
759
818
|
let session_name = state
|
|
760
819
|
.get("session_name")
|
|
761
820
|
.and_then(Value::as_str)
|
|
@@ -764,6 +823,9 @@ impl Coordinator {
|
|
|
764
823
|
return;
|
|
765
824
|
};
|
|
766
825
|
for (agent_id, agent) in agents {
|
|
826
|
+
if !agent_probe_base_eligible(agent) {
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
767
829
|
// #229 step1-idem: once trust is auto-answered, the row carries
|
|
768
830
|
// `startup_prompts = "handled"` (or "complete"). Both are terminal for
|
|
769
831
|
// this tick loop — repeated ticks must not re-classify, re-send Enter,
|
|
@@ -783,9 +845,22 @@ impl Coordinator {
|
|
|
783
845
|
else {
|
|
784
846
|
continue;
|
|
785
847
|
};
|
|
786
|
-
let Some((
|
|
848
|
+
let Some((session, window, target)) =
|
|
849
|
+
capture_window_target(agent, session_name.as_deref())
|
|
850
|
+
else {
|
|
787
851
|
continue;
|
|
788
852
|
};
|
|
853
|
+
if !agent_window_present(agent, &session, &window, pane_infos, windows) {
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
clear_startup_probe_disable_if_epoch_changed(agent);
|
|
857
|
+
if startup_probe_disabled_for_epoch(agent) {
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
if !startup_probe_within_grace(agent) {
|
|
861
|
+
disable_startup_probe_for_epoch(agent);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
789
864
|
let adapter = self.provider_registry.adapter_for(provider);
|
|
790
865
|
let outcome =
|
|
791
866
|
adapter.handle_startup_prompts_outcome(self.transport.as_ref(), &target, 1, 0.0);
|
|
@@ -835,6 +910,7 @@ impl Coordinator {
|
|
|
835
910
|
"startup_prompt_status".to_string(),
|
|
836
911
|
serde_json::json!("handled"),
|
|
837
912
|
);
|
|
913
|
+
agent_obj.remove("startup_prompt_probe_disabled_at");
|
|
838
914
|
agent_obj.insert("startup_prompt_handled".to_string(), handled_payload);
|
|
839
915
|
}
|
|
840
916
|
}
|
|
@@ -895,6 +971,8 @@ impl Coordinator {
|
|
|
895
971
|
&self,
|
|
896
972
|
state: &mut Value,
|
|
897
973
|
event_log: &EventLog,
|
|
974
|
+
pane_infos: &[crate::transport::PaneInfo],
|
|
975
|
+
windows: &[crate::transport::WindowName],
|
|
898
976
|
) -> Result<(), TickError> {
|
|
899
977
|
let snapshot = state.clone();
|
|
900
978
|
let team = crate::state::projection::team_state_key(&snapshot);
|
|
@@ -908,6 +986,10 @@ impl Coordinator {
|
|
|
908
986
|
return Ok(());
|
|
909
987
|
};
|
|
910
988
|
for (agent_id, agent) in agents {
|
|
989
|
+
if !agent_probe_base_eligible(agent) {
|
|
990
|
+
clear_awaiting_human_confirm(agent);
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
911
993
|
let approval_policy = runtime_approval_policy_from_agent(agent);
|
|
912
994
|
let auto_answer_allowed = approval_policy.auto_answer_allowed();
|
|
913
995
|
let Some(target) = runtime_approval_target(agent, session_name.as_deref()) else {
|
|
@@ -918,6 +1000,19 @@ impl Coordinator {
|
|
|
918
1000
|
});
|
|
919
1001
|
continue;
|
|
920
1002
|
};
|
|
1003
|
+
let target_present = capture_window_target(agent, session_name.as_deref())
|
|
1004
|
+
.map_or_else(
|
|
1005
|
+
|| runtime_approval_target_present(&target, pane_infos, windows),
|
|
1006
|
+
|(session, window, _)| {
|
|
1007
|
+
agent_window_present(agent, &session, &window, pane_infos, windows)
|
|
1008
|
+
},
|
|
1009
|
+
);
|
|
1010
|
+
if !target_present {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if runtime_approval_backoff_active(agent) {
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
921
1016
|
let captured = match self
|
|
922
1017
|
.transport
|
|
923
1018
|
.capture(&target, crate::transport::CaptureRange::Tail(80))
|
|
@@ -932,6 +1027,7 @@ impl Coordinator {
|
|
|
932
1027
|
"error": error.to_string(),
|
|
933
1028
|
}),
|
|
934
1029
|
)?;
|
|
1030
|
+
remember_runtime_approval_backoff(agent);
|
|
935
1031
|
continue;
|
|
936
1032
|
}
|
|
937
1033
|
};
|
|
@@ -941,8 +1037,10 @@ impl Coordinator {
|
|
|
941
1037
|
team: team.clone(),
|
|
942
1038
|
agent_id: agent_id.to_string(),
|
|
943
1039
|
});
|
|
1040
|
+
remember_runtime_approval_backoff(agent);
|
|
944
1041
|
continue;
|
|
945
1042
|
};
|
|
1043
|
+
clear_runtime_approval_backoff(agent);
|
|
946
1044
|
match runtime_approval_decision(&prompt, auto_answer_allowed) {
|
|
947
1045
|
RuntimeApprovalDecision::AutoApprove => {
|
|
948
1046
|
clear_awaiting_human_confirm(agent);
|
|
@@ -2132,6 +2230,256 @@ fn capture_window_target(
|
|
|
2132
2230
|
))
|
|
2133
2231
|
}
|
|
2134
2232
|
|
|
2233
|
+
fn tick_has_work_obligation(store: &crate::message_store::MessageStore) -> bool {
|
|
2234
|
+
let Ok(conn) = crate::db::schema::open_db(store.db_path()) else {
|
|
2235
|
+
return true;
|
|
2236
|
+
};
|
|
2237
|
+
let pending: i64 = conn.query_row(
|
|
2238
|
+
"select count(*) from messages where status in ('pending', 'accepted', 'target_resolved')",
|
|
2239
|
+
[],
|
|
2240
|
+
|row| row.get(0),
|
|
2241
|
+
).unwrap_or(1);
|
|
2242
|
+
if pending > 0 {
|
|
2243
|
+
return true;
|
|
2244
|
+
}
|
|
2245
|
+
let now = chrono::Utc::now().to_rfc3339();
|
|
2246
|
+
let due: i64 = conn
|
|
2247
|
+
.query_row(
|
|
2248
|
+
"select count(*) from scheduled_events where status = 'pending' and due_at <= ?1",
|
|
2249
|
+
[now],
|
|
2250
|
+
|row| row.get(0),
|
|
2251
|
+
)
|
|
2252
|
+
.unwrap_or(1);
|
|
2253
|
+
due > 0
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
fn agent_probe_base_eligible(agent: &Value) -> bool {
|
|
2257
|
+
let status = agent.get("status").and_then(Value::as_str);
|
|
2258
|
+
!matches!(
|
|
2259
|
+
status,
|
|
2260
|
+
Some("missing" | "stopped" | "dead" | "exited" | "terminated" | "removed" | "failed")
|
|
2261
|
+
)
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
fn agent_window_present(
|
|
2265
|
+
agent: &Value,
|
|
2266
|
+
session: &crate::transport::SessionName,
|
|
2267
|
+
window: &crate::transport::WindowName,
|
|
2268
|
+
pane_infos: &[crate::transport::PaneInfo],
|
|
2269
|
+
windows: &[crate::transport::WindowName],
|
|
2270
|
+
) -> bool {
|
|
2271
|
+
if let Some(pane_id) = agent_pane_id(agent) {
|
|
2272
|
+
if pane_infos.iter().any(|info| info.pane_id == pane_id) {
|
|
2273
|
+
return true;
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
if pane_infos.iter().any(|info| {
|
|
2277
|
+
&info.session == session
|
|
2278
|
+
&& info
|
|
2279
|
+
.window_name
|
|
2280
|
+
.as_ref()
|
|
2281
|
+
.is_some_and(|known_window| known_window == window)
|
|
2282
|
+
}) {
|
|
2283
|
+
return true;
|
|
2284
|
+
}
|
|
2285
|
+
if !pane_infos.is_empty() {
|
|
2286
|
+
return false;
|
|
2287
|
+
}
|
|
2288
|
+
windows.is_empty() || windows.iter().any(|known| known == window)
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
fn runtime_approval_target_present(
|
|
2292
|
+
target: &crate::transport::Target,
|
|
2293
|
+
pane_infos: &[crate::transport::PaneInfo],
|
|
2294
|
+
windows: &[crate::transport::WindowName],
|
|
2295
|
+
) -> bool {
|
|
2296
|
+
match target {
|
|
2297
|
+
crate::transport::Target::Pane(pane) => {
|
|
2298
|
+
if pane_infos.iter().any(|info| &info.pane_id == pane) {
|
|
2299
|
+
return true;
|
|
2300
|
+
}
|
|
2301
|
+
pane_infos.is_empty()
|
|
2302
|
+
}
|
|
2303
|
+
crate::transport::Target::SessionWindow { session, window } => {
|
|
2304
|
+
if pane_infos.iter().any(|info| {
|
|
2305
|
+
&info.session == session
|
|
2306
|
+
&& info
|
|
2307
|
+
.window_name
|
|
2308
|
+
.as_ref()
|
|
2309
|
+
.is_some_and(|known_window| known_window == window)
|
|
2310
|
+
}) {
|
|
2311
|
+
return true;
|
|
2312
|
+
}
|
|
2313
|
+
if !pane_infos.is_empty() {
|
|
2314
|
+
return false;
|
|
2315
|
+
}
|
|
2316
|
+
windows.is_empty() || windows.iter().any(|known| known == window)
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
fn agent_process_epoch(agent: &Value) -> String {
|
|
2322
|
+
if let Some(pid) = agent.get("pane_pid").and_then(Value::as_u64) {
|
|
2323
|
+
return format!("pane_pid:{pid}");
|
|
2324
|
+
}
|
|
2325
|
+
if let Some(spawned_at) = agent.get("spawned_at").and_then(Value::as_str) {
|
|
2326
|
+
return format!("spawned_at:{spawned_at}");
|
|
2327
|
+
}
|
|
2328
|
+
if let Some(pane_id) = agent.get("pane_id").and_then(Value::as_str) {
|
|
2329
|
+
return format!("pane:{pane_id}");
|
|
2330
|
+
}
|
|
2331
|
+
agent
|
|
2332
|
+
.get("window")
|
|
2333
|
+
.and_then(Value::as_str)
|
|
2334
|
+
.map(|window| format!("window:{window}"))
|
|
2335
|
+
.unwrap_or_else(|| "unknown".to_string())
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
fn startup_probe_disabled_for_epoch(agent: &Value) -> bool {
|
|
2339
|
+
let epoch = agent_process_epoch(agent);
|
|
2340
|
+
agent.get("startup_prompt_status").and_then(Value::as_str) == Some("disabled_for_epoch")
|
|
2341
|
+
&& agent
|
|
2342
|
+
.get("startup_prompt_probe_epoch")
|
|
2343
|
+
.and_then(Value::as_str)
|
|
2344
|
+
== Some(epoch.as_str())
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
fn clear_startup_probe_disable_if_epoch_changed(agent: &mut Value) {
|
|
2348
|
+
if agent.get("startup_prompt_status").and_then(Value::as_str) != Some("disabled_for_epoch") {
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
let epoch = agent_process_epoch(agent);
|
|
2352
|
+
if agent
|
|
2353
|
+
.get("startup_prompt_probe_epoch")
|
|
2354
|
+
.and_then(Value::as_str)
|
|
2355
|
+
== Some(epoch.as_str())
|
|
2356
|
+
{
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
if let Some(agent) = agent.as_object_mut() {
|
|
2360
|
+
agent.remove("startup_prompt_status");
|
|
2361
|
+
agent.remove("startup_prompts");
|
|
2362
|
+
agent.remove("startup_prompt_probe_disabled_at");
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
fn startup_probe_within_grace(agent: &Value) -> bool {
|
|
2367
|
+
let Some(spawned_at) = agent.get("spawned_at").and_then(Value::as_str) else {
|
|
2368
|
+
return true;
|
|
2369
|
+
};
|
|
2370
|
+
let Some(spawned_at) = parse_rfc3339_utc(spawned_at) else {
|
|
2371
|
+
return true;
|
|
2372
|
+
};
|
|
2373
|
+
chrono::Utc::now()
|
|
2374
|
+
.signed_duration_since(spawned_at)
|
|
2375
|
+
.num_seconds()
|
|
2376
|
+
<= STARTUP_PROMPT_GRACE_SECS
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
fn disable_startup_probe_for_epoch(agent: &mut Value) {
|
|
2380
|
+
let epoch = agent_process_epoch(agent);
|
|
2381
|
+
if let Some(agent) = agent.as_object_mut() {
|
|
2382
|
+
agent.insert(
|
|
2383
|
+
"startup_prompt_status".to_string(),
|
|
2384
|
+
serde_json::json!("disabled_for_epoch"),
|
|
2385
|
+
);
|
|
2386
|
+
agent.insert(
|
|
2387
|
+
"startup_prompts".to_string(),
|
|
2388
|
+
serde_json::json!("disabled_for_epoch"),
|
|
2389
|
+
);
|
|
2390
|
+
agent.insert(
|
|
2391
|
+
"startup_prompt_probe_epoch".to_string(),
|
|
2392
|
+
serde_json::json!(epoch),
|
|
2393
|
+
);
|
|
2394
|
+
agent.insert(
|
|
2395
|
+
"startup_prompt_probe_disabled_at".to_string(),
|
|
2396
|
+
serde_json::json!(chrono::Utc::now().to_rfc3339()),
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
fn runtime_approval_backoff_active(agent: &Value) -> bool {
|
|
2402
|
+
let Some(next) = agent
|
|
2403
|
+
.pointer("/runtime_approval_probe/next_probe_at")
|
|
2404
|
+
.and_then(Value::as_str)
|
|
2405
|
+
.and_then(parse_rfc3339_utc)
|
|
2406
|
+
else {
|
|
2407
|
+
return false;
|
|
2408
|
+
};
|
|
2409
|
+
next > chrono::Utc::now()
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
fn remember_runtime_approval_backoff(agent: &mut Value) {
|
|
2413
|
+
let previous = agent
|
|
2414
|
+
.pointer("/runtime_approval_probe/backoff_secs")
|
|
2415
|
+
.and_then(Value::as_i64)
|
|
2416
|
+
.unwrap_or(0);
|
|
2417
|
+
let backoff = if previous <= 0 {
|
|
2418
|
+
RUNTIME_APPROVAL_INITIAL_BACKOFF_SECS
|
|
2419
|
+
} else {
|
|
2420
|
+
previous
|
|
2421
|
+
.saturating_mul(2)
|
|
2422
|
+
.min(RUNTIME_APPROVAL_MAX_BACKOFF_SECS)
|
|
2423
|
+
};
|
|
2424
|
+
let next = chrono::Utc::now() + chrono::Duration::seconds(backoff);
|
|
2425
|
+
if let Some(agent) = agent.as_object_mut() {
|
|
2426
|
+
agent.insert(
|
|
2427
|
+
"runtime_approval_probe".to_string(),
|
|
2428
|
+
serde_json::json!({
|
|
2429
|
+
"backoff_secs": backoff,
|
|
2430
|
+
"next_probe_at": next.to_rfc3339(),
|
|
2431
|
+
}),
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
fn clear_runtime_approval_backoff(agent: &mut Value) {
|
|
2437
|
+
if let Some(agent) = agent.as_object_mut() {
|
|
2438
|
+
agent.remove("runtime_approval_probe");
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
fn warm_idle_capture_suppressed(agent: &Value, has_work_obligation: bool) -> bool {
|
|
2443
|
+
if has_work_obligation {
|
|
2444
|
+
return false;
|
|
2445
|
+
}
|
|
2446
|
+
let status = agent
|
|
2447
|
+
.pointer("/activity/status")
|
|
2448
|
+
.and_then(Value::as_str)
|
|
2449
|
+
.or_else(|| agent.get("status").and_then(Value::as_str));
|
|
2450
|
+
if status != Some("idle") {
|
|
2451
|
+
return false;
|
|
2452
|
+
}
|
|
2453
|
+
if runtime_approval_backoff_active(agent) {
|
|
2454
|
+
return true;
|
|
2455
|
+
}
|
|
2456
|
+
agent
|
|
2457
|
+
.get("coordinator_idle_capture_next_at")
|
|
2458
|
+
.and_then(Value::as_str)
|
|
2459
|
+
.and_then(parse_rfc3339_utc)
|
|
2460
|
+
.is_some_and(|next| next > chrono::Utc::now())
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
fn remember_idle_capture_schedule(agent: &mut Value, activity: &crate::messaging::AgentActivity) {
|
|
2464
|
+
if activity.status != crate::messaging::ActivityStatus::Idle {
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
if let Some(agent) = agent.as_object_mut() {
|
|
2468
|
+
let next =
|
|
2469
|
+
chrono::Utc::now() + chrono::Duration::seconds(IDLE_HEALTH_CAPTURE_INTERVAL_SECS);
|
|
2470
|
+
agent.insert(
|
|
2471
|
+
"coordinator_idle_capture_next_at".to_string(),
|
|
2472
|
+
serde_json::json!(next.to_rfc3339()),
|
|
2473
|
+
);
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
fn parse_rfc3339_utc(raw: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
|
2478
|
+
chrono::DateTime::parse_from_rfc3339(raw)
|
|
2479
|
+
.ok()
|
|
2480
|
+
.map(|value| value.with_timezone(&chrono::Utc))
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2135
2483
|
fn matching_capture_pane_info(
|
|
2136
2484
|
agent: &Value,
|
|
2137
2485
|
session: &crate::transport::SessionName,
|
|
@@ -2598,3 +2946,90 @@ fn notify_session_missing(
|
|
|
2598
2946
|
}
|
|
2599
2947
|
Ok(())
|
|
2600
2948
|
}
|
|
2949
|
+
|
|
2950
|
+
#[cfg(test)]
|
|
2951
|
+
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
2952
|
+
mod u1_tests {
|
|
2953
|
+
use super::*;
|
|
2954
|
+
|
|
2955
|
+
struct CaptureFailureRegistry;
|
|
2956
|
+
|
|
2957
|
+
impl ProviderRegistry for CaptureFailureRegistry {
|
|
2958
|
+
fn adapter_for(
|
|
2959
|
+
&self,
|
|
2960
|
+
provider: crate::provider::Provider,
|
|
2961
|
+
) -> Box<dyn crate::provider::ProviderAdapter> {
|
|
2962
|
+
Box::new(
|
|
2963
|
+
crate::session_capture::test_support::CaptureCandidatesAdapter::new(
|
|
2964
|
+
provider,
|
|
2965
|
+
Some("w1"),
|
|
2966
|
+
"capture exploded",
|
|
2967
|
+
),
|
|
2968
|
+
)
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
fn error_lists(
|
|
2972
|
+
&self,
|
|
2973
|
+
_provider: crate::provider::Provider,
|
|
2974
|
+
) -> super::super::types::ErrorLists {
|
|
2975
|
+
super::super::types::ErrorLists::default()
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
#[test]
|
|
2980
|
+
fn tick_logs_capture_missing_failure_and_continues() {
|
|
2981
|
+
let dir = std::env::temp_dir().join(format!(
|
|
2982
|
+
"team-agent-u1-capture-missing-{}-{}",
|
|
2983
|
+
std::process::id(),
|
|
2984
|
+
std::time::SystemTime::now()
|
|
2985
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
2986
|
+
.unwrap()
|
|
2987
|
+
.as_nanos()
|
|
2988
|
+
));
|
|
2989
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
2990
|
+
crate::state::persist::save_runtime_state(
|
|
2991
|
+
&dir,
|
|
2992
|
+
&serde_json::json!({
|
|
2993
|
+
"agents": {
|
|
2994
|
+
"w1": {
|
|
2995
|
+
"provider": "codex",
|
|
2996
|
+
"status": "running",
|
|
2997
|
+
"spawn_cwd": dir.to_string_lossy()
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
}),
|
|
3001
|
+
)
|
|
3002
|
+
.unwrap();
|
|
3003
|
+
let coordinator = Coordinator::for_test(
|
|
3004
|
+
WorkspacePath::new(dir.clone()),
|
|
3005
|
+
Box::new(CaptureFailureRegistry),
|
|
3006
|
+
Box::new(crate::transport::test_support::OfflineTransport::new()),
|
|
3007
|
+
None,
|
|
3008
|
+
None,
|
|
3009
|
+
);
|
|
3010
|
+
|
|
3011
|
+
let report = coordinator
|
|
3012
|
+
.tick()
|
|
3013
|
+
.expect("capture_missing failure must be logged and not abort the tick");
|
|
3014
|
+
|
|
3015
|
+
assert!(report.ok, "tick should continue to a successful report");
|
|
3016
|
+
let events_path = crate::model::paths::logs_dir(&dir).join("events.jsonl");
|
|
3017
|
+
let events = std::fs::read_to_string(events_path).unwrap();
|
|
3018
|
+
let has_capture_failure = events.lines().any(|line| {
|
|
3019
|
+
serde_json::from_str::<Value>(line)
|
|
3020
|
+
.ok()
|
|
3021
|
+
.and_then(|event| {
|
|
3022
|
+
event
|
|
3023
|
+
.get("event")
|
|
3024
|
+
.and_then(Value::as_str)
|
|
3025
|
+
.map(str::to_string)
|
|
3026
|
+
})
|
|
3027
|
+
.as_deref()
|
|
3028
|
+
== Some("coordinator.tick.capture_missing_failed")
|
|
3029
|
+
});
|
|
3030
|
+
assert!(
|
|
3031
|
+
has_capture_failure,
|
|
3032
|
+
"capture_missing failure must be visible in events.jsonl; got {events}"
|
|
3033
|
+
);
|
|
3034
|
+
}
|
|
3035
|
+
}
|