@team-agent/installer 0.3.21 → 0.3.23

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.
Files changed (31) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +9 -1
  4. package/crates/team-agent/src/cli/mod.rs +158 -2
  5. package/crates/team-agent/src/cli/status.rs +61 -0
  6. package/crates/team-agent/src/cli/status_port.rs +2 -2
  7. package/crates/team-agent/src/cli/tests/base.rs +26 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
  9. package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
  10. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/coordinator/tick.rs +452 -17
  12. package/crates/team-agent/src/lifecycle/display.rs +23 -302
  13. package/crates/team-agent/src/lifecycle/launch.rs +38 -0
  14. package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
  15. package/crates/team-agent/src/lifecycle/restart/common.rs +179 -0
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
  17. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
  18. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
  19. package/crates/team-agent/src/messaging/delivery.rs +324 -95
  20. package/crates/team-agent/src/messaging/scheduler.rs +95 -73
  21. package/crates/team-agent/src/messaging/send.rs +10 -0
  22. package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
  23. package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
  24. package/crates/team-agent/src/session_capture.rs +261 -1
  25. package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
  26. package/crates/team-agent/src/tmux_backend.rs +138 -2
  27. package/crates/team-agent/src/transport/test_support.rs +25 -0
  28. package/crates/team-agent/src/transport.rs +11 -0
  29. package/package.json +4 -4
  30. package/skills/team-agent/SKILL.md +2 -1
  31. package/skills/team-agent/references/team-in-team.md +127 -0
@@ -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(&mut state, &event_log) {
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 pane_snapshot = self.transport.list_targets().unwrap_or_default();
256
- let captures_by_agent =
257
- match self.sync_agent_health(&mut state, &store, &event_log, &pane_snapshot) {
258
- Ok(captures) => captures,
259
- Err(error) => {
260
- let _ = event_log.write(
261
- "coordinator.tick.sync_health_failed",
262
- serde_json::json!({"error": error.to_string()}),
263
- );
264
- BTreeMap::new()
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(&self, state: &mut Value, event_log: &EventLog) {
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((_, _, target)) = capture_window_target(agent, session_name.as_deref()) else {
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
+ }