@team-agent/installer 0.4.11 → 0.5.0

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 (72) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +62 -4
  6. package/crates/team-agent/src/compiler.rs +11 -20
  7. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  8. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  9. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  10. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  11. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  12. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  13. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  14. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  15. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  16. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  17. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  18. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  19. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  20. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  21. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  22. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  23. package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
  24. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  26. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  27. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  28. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
  29. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  30. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  31. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
  32. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  33. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +648 -0
  34. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  35. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  36. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  37. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  38. package/crates/team-agent/src/messaging/activity.rs +43 -18
  39. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  40. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  41. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  42. package/crates/team-agent/src/messaging/results.rs +34 -9
  43. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  44. package/crates/team-agent/src/model/spec.rs +10 -6
  45. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  46. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  47. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  48. package/crates/team-agent/src/provider/classify.rs +23 -48
  49. package/crates/team-agent/src/provider/command.rs +16 -7
  50. package/crates/team-agent/src/provider/faults.rs +93 -53
  51. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  52. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  53. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  54. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  55. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  56. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  57. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  58. package/crates/team-agent/src/provider/types.rs +33 -1
  59. package/crates/team-agent/src/provider/wire.rs +171 -1
  60. package/crates/team-agent/src/state/identity.rs +242 -57
  61. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  62. package/crates/team-agent/src/state/mod.rs +5 -1
  63. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  64. package/crates/team-agent/src/state/ownership.rs +12 -11
  65. package/crates/team-agent/src/state/paths.rs +13 -6
  66. package/crates/team-agent/src/state/persist.rs +671 -128
  67. package/crates/team-agent/src/state/projection.rs +240 -49
  68. package/crates/team-agent/src/state/selector.rs +11 -5
  69. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  70. package/crates/team-agent/src/tmux_backend.rs +42 -5
  71. package/crates/team-agent/src/transport/test_support.rs +55 -6
  72. package/package.json +4 -4
@@ -1,4 +1,1778 @@
1
1
  //! unit-11 (Stage 4) — coordinator tick `abnormal` step group.
2
2
  //!
3
- //! Future home for abnormal-exit detection + classification steps from
4
- //! `coordinator/tick.rs`.
3
+ //! Abnormal-exit detection + classification step extracted from
4
+ //! `coordinator/tick.rs` without behavior changes.
5
+
6
+ use std::path::{Path, PathBuf};
7
+
8
+ use serde_json::Value;
9
+
10
+ use crate::event_log::EventLog;
11
+ use crate::provider::wire::{parse_provider, provider_wire};
12
+ use crate::provider::ProcessLiveness;
13
+
14
+ use super::super::health::pid_is_running;
15
+ use super::super::tick::TickError;
16
+ use super::super::types::Pid;
17
+
18
+ /// #236 `worker.abnormal_exit` watcher.
19
+ ///
20
+ /// Notify only when the bounded transcript/rollout tail contains a latest explicit
21
+ /// provider error that is fresh for the current worker cohort. A dead process
22
+ /// without an explicit error remains a suppressed `dead_only` audit event; process
23
+ /// liveness is otherwise diagnostic data for this path.
24
+ pub(crate) fn detect_abnormal_exits(
25
+ workspace: &Path,
26
+ transport: &dyn crate::transport::Transport,
27
+ state: &mut Value,
28
+ event_log: &EventLog,
29
+ targets: &[crate::transport::PaneInfo],
30
+ ) -> Result<(), TickError> {
31
+ let snapshot = state.clone();
32
+ let team = crate::state::projection::team_state_key(&snapshot);
33
+ let session_name = snapshot.get("session_name").and_then(Value::as_str);
34
+ for agent in abnormal_watch_agents(&snapshot) {
35
+ let rollout_path = resolve_agent_rollout_path(workspace, &agent.rollout_path);
36
+ let metadata = match std::fs::metadata(&rollout_path) {
37
+ Ok(metadata) => metadata,
38
+ Err(error) => {
39
+ upsert_abnormal_watch(
40
+ state,
41
+ &agent.agent_id,
42
+ abnormal_watch_payload(
43
+ &agent,
44
+ None,
45
+ None,
46
+ ProcessLiveness::Unverifiable,
47
+ None,
48
+ ErrorRecency::None,
49
+ None,
50
+ Some(error.to_string()),
51
+ ),
52
+ );
53
+ continue;
54
+ }
55
+ };
56
+ let size = metadata.len();
57
+ let mtime_ns = metadata_mtime_ns(&metadata);
58
+ // P1 (C-P1-2/3): (size, mtime_ns) pair gate — an unchanged transcript is not
59
+ // read at all (live sample: 332MB whole-file read per agent per 2s tick).
60
+ // ANY field change (including a size shrink / truncate) falls through to the
61
+ // re-read below.
62
+ if let (Some(mtime), Some(stored)) = (
63
+ mtime_ns,
64
+ abnormal_watch_stored_metadata(&snapshot, &agent.agent_id),
65
+ ) {
66
+ if stored == (size, mtime) {
67
+ continue;
68
+ }
69
+ }
70
+ // P1 (C-P1-1): bounded tail read — the abnormal decision only consumes
71
+ // this tail window. The provider scan walks backward inside it; window
72
+ // matches Python `_TAIL_BYTES` (131072, idle_takeover_wiring.py:13),
73
+ // never less.
74
+ let text = match read_tail_text(&rollout_path, ABNORMAL_TAIL_BYTES) {
75
+ Ok(text) => text,
76
+ Err(error) => {
77
+ upsert_abnormal_watch(
78
+ state,
79
+ &agent.agent_id,
80
+ abnormal_watch_payload(
81
+ &agent,
82
+ Some(size),
83
+ mtime_ns,
84
+ ProcessLiveness::Unverifiable,
85
+ None,
86
+ ErrorRecency::None,
87
+ None,
88
+ Some(error.to_string()),
89
+ ),
90
+ );
91
+ continue;
92
+ }
93
+ };
94
+ let liveness = agent_process_liveness(&agent, session_name, targets, transport);
95
+ let fact = crate::provider::latest_explicit_error_fact(agent.provider, &text);
96
+ let error_observation_key = fact
97
+ .as_ref()
98
+ .map(|fact| abnormal_error_observation_key(&agent, fact, size));
99
+ let error_observation_cohort = fact.as_ref().map(|_| abnormal_error_cohort_key(&agent));
100
+ let error_recency = abnormal_error_recency(
101
+ &snapshot,
102
+ &agent,
103
+ error_observation_key.as_deref(),
104
+ error_observation_cohort.as_deref(),
105
+ );
106
+ let decision = abnormal_exit_decision(liveness.state, fact.as_ref(), error_recency);
107
+ let check_key = abnormal_check_key(
108
+ &agent,
109
+ &liveness,
110
+ fact.as_ref(),
111
+ error_recency,
112
+ error_observation_key.as_deref(),
113
+ size,
114
+ );
115
+ upsert_abnormal_watch(
116
+ state,
117
+ &agent.agent_id,
118
+ abnormal_watch_payload(
119
+ &agent,
120
+ Some(size),
121
+ mtime_ns,
122
+ liveness.state,
123
+ fact.as_ref().map(|f| f.signature.as_str()),
124
+ error_recency,
125
+ error_observation_key.as_deref(),
126
+ None,
127
+ ),
128
+ );
129
+ if let (Some(observation_key), Some(cohort_key)) = (
130
+ error_observation_key.as_deref(),
131
+ error_observation_cohort.as_deref(),
132
+ ) {
133
+ mark_abnormal_error_observed(state, &agent.agent_id, observation_key, cohort_key);
134
+ }
135
+ if abnormal_last_check_key(state, &agent.agent_id).as_deref() != Some(check_key.as_str()) {
136
+ write_abnormal_check(
137
+ event_log,
138
+ &team,
139
+ &agent,
140
+ &liveness,
141
+ fact.as_ref(),
142
+ decision,
143
+ error_recency,
144
+ size,
145
+ mtime_ns,
146
+ )?;
147
+ mark_abnormal_checked(state, &agent.agent_id, &check_key);
148
+ }
149
+ let fact = match (decision, fact) {
150
+ (AbnormalExitDecision::Notify, Some(fact)) => fact,
151
+ (AbnormalExitDecision::Suppress(reason), _) => {
152
+ let suppress_key = abnormal_suppression_key(&agent, &liveness, reason, size);
153
+ if abnormal_last_suppressed_key(state, &agent.agent_id).as_deref()
154
+ != Some(suppress_key.as_str())
155
+ {
156
+ write_abnormal_suppressed(event_log, &team, &agent, &liveness, reason)?;
157
+ mark_abnormal_suppressed(state, &agent.agent_id, &suppress_key);
158
+ }
159
+ continue;
160
+ }
161
+ (AbnormalExitDecision::NoSignal, _) => continue,
162
+ (AbnormalExitDecision::Notify, None) => continue,
163
+ };
164
+ let dedupe_key = abnormal_dedupe_key(&agent, &fact, size);
165
+ if abnormal_last_notified_key(state, &agent.agent_id).as_deref()
166
+ == Some(dedupe_key.as_str())
167
+ {
168
+ continue;
169
+ }
170
+ let content = format_abnormal_exit_message(&team, &agent, &fact, &liveness, size);
171
+ let outcome = crate::messaging::send_to_leader_receiver(
172
+ workspace,
173
+ state,
174
+ "leader",
175
+ &content,
176
+ None,
177
+ &agent.agent_id,
178
+ false,
179
+ Some(&dedupe_key),
180
+ event_log,
181
+ )?;
182
+ let notification_status = if outcome.ok {
183
+ "queued"
184
+ } else if matches!(outcome.status, crate::messaging::DeliveryStatus::Blocked) {
185
+ "rebind_required"
186
+ } else {
187
+ "refused"
188
+ };
189
+ event_log.write(
190
+ "worker.abnormal_exit",
191
+ serde_json::json!({
192
+ "team_id": team.as_str(),
193
+ "agent_id": agent.agent_id.as_str(),
194
+ "provider": provider_wire(agent.provider),
195
+ "path": agent.rollout_path_display.as_str(),
196
+ "dead_process": liveness.state == ProcessLiveness::Dead,
197
+ "process_dead": liveness.state == ProcessLiveness::Dead,
198
+ "provider_process_dead": liveness.state == ProcessLiveness::Dead,
199
+ "latest_error": true,
200
+ "latest_explicit_error": true,
201
+ "error_recency": error_recency.as_str(),
202
+ "fresh_error": error_recency.is_fresh(),
203
+ "dead_process_and_latest_error": liveness.state == ProcessLiveness::Dead,
204
+ "dead_process_and_latest_explicit_error": liveness.state == ProcessLiveness::Dead,
205
+ "process_dead_and_latest_explicit_error": liveness.state == ProcessLiveness::Dead,
206
+ "provider_process_dead_and_latest_explicit_error": liveness.state == ProcessLiveness::Dead,
207
+ "signature": fact.signature.as_str(),
208
+ "turn_id": fact.turn_id.as_ref().map(|id| id.as_str()),
209
+ "apiErrorStatus": fact.api_error_status,
210
+ "error": fact.error.as_deref(),
211
+ "requestId": fact.request_id.as_deref(),
212
+ "assistant_uuid": fact.assistant_uuid.as_deref(),
213
+ "size": size,
214
+ "mtime_ns": mtime_ns,
215
+ "process_liveness": process_liveness_wire(liveness.state),
216
+ "pid_status": liveness.detail.as_str(),
217
+ "notification_message_id": outcome.message_id,
218
+ "notification_status": notification_status,
219
+ "notification_channel": outcome.channel,
220
+ }),
221
+ )?;
222
+ mark_abnormal_notified(state, &agent.agent_id, &dedupe_key);
223
+ }
224
+ Ok(())
225
+ }
226
+
227
+ #[derive(Debug, Clone)]
228
+ struct AbnormalWatchAgent {
229
+ agent_id: String,
230
+ provider: crate::model::enums::Provider,
231
+ rollout_path: PathBuf,
232
+ rollout_path_display: String,
233
+ spawn_epoch: Option<u64>,
234
+ spawned_at: Option<String>,
235
+ status: Option<String>,
236
+ process_liveness: Option<ProcessLiveness>,
237
+ window: Option<String>,
238
+ pane_id: Option<String>,
239
+ pid: Option<Pid>,
240
+ current_command: Option<String>,
241
+ }
242
+
243
+ #[derive(Debug, Clone, PartialEq, Eq)]
244
+ struct ProcessCheck {
245
+ state: ProcessLiveness,
246
+ detail: String,
247
+ }
248
+
249
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
250
+ enum AbnormalExitDecision {
251
+ Notify,
252
+ Suppress(&'static str),
253
+ NoSignal,
254
+ }
255
+
256
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
257
+ enum ErrorRecency {
258
+ None,
259
+ Stale,
260
+ Fresh,
261
+ }
262
+
263
+ impl ErrorRecency {
264
+ fn as_str(self) -> &'static str {
265
+ match self {
266
+ Self::None => "none",
267
+ Self::Stale => "stale",
268
+ Self::Fresh => "fresh",
269
+ }
270
+ }
271
+
272
+ fn is_fresh(self) -> bool {
273
+ matches!(self, Self::Fresh)
274
+ }
275
+ }
276
+
277
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
278
+ struct AbnormalExitGate {
279
+ provider_process_dead: bool,
280
+ latest_explicit_error: bool,
281
+ error_recency: ErrorRecency,
282
+ }
283
+
284
+ impl AbnormalExitGate {
285
+ fn new(
286
+ process_liveness: ProcessLiveness,
287
+ latest_explicit_error: bool,
288
+ error_recency: ErrorRecency,
289
+ ) -> Self {
290
+ Self {
291
+ provider_process_dead: process_liveness == ProcessLiveness::Dead,
292
+ latest_explicit_error,
293
+ error_recency,
294
+ }
295
+ }
296
+
297
+ fn should_notify_worker_abnormal_exit(self) -> bool {
298
+ should_notify_worker_abnormal_exit(self.latest_explicit_error, self.error_recency)
299
+ }
300
+
301
+ fn suppressed_reason(self) -> Option<&'static str> {
302
+ match (self.provider_process_dead, self.latest_explicit_error) {
303
+ (true, false) => Some("dead_only"),
304
+ _ => None,
305
+ }
306
+ }
307
+ }
308
+
309
+ fn abnormal_exit_decision(
310
+ process_liveness: ProcessLiveness,
311
+ latest_explicit_error: Option<&crate::provider::FaultFact>,
312
+ error_recency: ErrorRecency,
313
+ ) -> AbnormalExitDecision {
314
+ let gate = AbnormalExitGate::new(
315
+ process_liveness,
316
+ latest_explicit_error.is_some(),
317
+ error_recency,
318
+ );
319
+ if gate.should_notify_worker_abnormal_exit() {
320
+ return AbnormalExitDecision::Notify;
321
+ }
322
+ match gate.suppressed_reason() {
323
+ Some(reason) => AbnormalExitDecision::Suppress(reason),
324
+ None => AbnormalExitDecision::NoSignal,
325
+ }
326
+ }
327
+
328
+ fn should_notify_worker_abnormal_exit(
329
+ latest_explicit_error: bool,
330
+ error_recency: ErrorRecency,
331
+ ) -> bool {
332
+ latest_explicit_error && error_recency.is_fresh()
333
+ }
334
+
335
+ fn resolve_agent_rollout_path(workspace: &Path, path: &Path) -> PathBuf {
336
+ if path.is_absolute() {
337
+ path.to_path_buf()
338
+ } else {
339
+ workspace.join(path)
340
+ }
341
+ }
342
+
343
+ fn abnormal_watch_agents(state: &Value) -> Vec<AbnormalWatchAgent> {
344
+ let Some(agents) = state.get("agents").and_then(Value::as_object) else {
345
+ return Vec::new();
346
+ };
347
+ agents
348
+ .iter()
349
+ .filter_map(|(agent_id, agent)| {
350
+ if matches!(agent.get("status").and_then(Value::as_str), Some("paused")) {
351
+ return None;
352
+ }
353
+ let provider = agent
354
+ .get("provider")
355
+ .and_then(Value::as_str)
356
+ .and_then(parse_provider)?;
357
+ let rollout_path_display = ["rollout_path", "transcript_path", "session_log_path"]
358
+ .into_iter()
359
+ .find_map(|key| agent.get(key).and_then(Value::as_str))
360
+ .filter(|path| !path.is_empty())?
361
+ .to_string();
362
+ Some(AbnormalWatchAgent {
363
+ agent_id: agent_id.clone(),
364
+ provider,
365
+ rollout_path: PathBuf::from(&rollout_path_display),
366
+ rollout_path_display,
367
+ spawn_epoch: agent.get("spawn_epoch").and_then(Value::as_u64),
368
+ spawned_at: agent
369
+ .get("spawned_at")
370
+ .and_then(Value::as_str)
371
+ .map(str::to_string),
372
+ status: agent
373
+ .get("status")
374
+ .and_then(Value::as_str)
375
+ .map(str::to_string),
376
+ process_liveness: explicit_process_liveness(agent),
377
+ window: agent
378
+ .get("window")
379
+ .and_then(Value::as_str)
380
+ .map(str::to_string),
381
+ pane_id: agent
382
+ .get("pane_id")
383
+ .and_then(Value::as_str)
384
+ .map(str::to_string),
385
+ pid: agent_pid(agent),
386
+ current_command: agent
387
+ .get("pane_current_command")
388
+ .or_else(|| agent.get("current_command"))
389
+ .and_then(Value::as_str)
390
+ .map(str::to_string),
391
+ })
392
+ })
393
+ .collect()
394
+ }
395
+
396
+ fn agent_pid(agent: &Value) -> Option<Pid> {
397
+ ["provider_pid", "process_id", "pid", "child_pid", "pane_pid"]
398
+ .into_iter()
399
+ .find_map(|key| json_u32(agent.get(key)).map(Pid::new))
400
+ }
401
+
402
+ pub(crate) fn explicit_process_liveness(agent: &Value) -> Option<ProcessLiveness> {
403
+ if let Some(process) = agent
404
+ .get("provider_process")
405
+ .or_else(|| agent.get("process"))
406
+ {
407
+ if let Some(liveness) = explicit_process_liveness(process) {
408
+ return Some(liveness);
409
+ }
410
+ }
411
+ for key in [
412
+ "provider_process_liveness",
413
+ "process_liveness",
414
+ "pane_liveness",
415
+ ] {
416
+ match agent.get(key).and_then(Value::as_str) {
417
+ Some("dead") => return Some(ProcessLiveness::Dead),
418
+ Some("alive" | "live") => return Some(ProcessLiveness::Alive),
419
+ Some("unverifiable" | "unknown") => return Some(ProcessLiveness::Unverifiable),
420
+ _ => {}
421
+ }
422
+ }
423
+ for key in [
424
+ "provider_process_alive",
425
+ "process_alive",
426
+ "provider_alive",
427
+ "alive",
428
+ ] {
429
+ if let Some(alive) = agent.get(key).and_then(Value::as_bool) {
430
+ return Some(if alive {
431
+ ProcessLiveness::Alive
432
+ } else {
433
+ ProcessLiveness::Dead
434
+ });
435
+ }
436
+ }
437
+ for key in [
438
+ "provider_process_dead",
439
+ "process_dead",
440
+ "provider_dead",
441
+ "dead",
442
+ ] {
443
+ if let Some(dead) = agent.get(key).and_then(Value::as_bool) {
444
+ return Some(if dead {
445
+ ProcessLiveness::Dead
446
+ } else {
447
+ ProcessLiveness::Alive
448
+ });
449
+ }
450
+ }
451
+ for key in ["status", "state", "liveness"] {
452
+ match agent.get(key).and_then(Value::as_str) {
453
+ Some("dead" | "exited" | "terminated" | "crashed" | "missing") => {
454
+ return Some(ProcessLiveness::Dead);
455
+ }
456
+ Some("alive" | "live" | "running") => return Some(ProcessLiveness::Alive),
457
+ Some("unverifiable" | "unknown") => return Some(ProcessLiveness::Unverifiable),
458
+ _ => {}
459
+ }
460
+ }
461
+ None
462
+ }
463
+
464
+ fn json_u32(value: Option<&Value>) -> Option<u32> {
465
+ value
466
+ .and_then(|v| {
467
+ v.as_u64()
468
+ .or_else(|| v.as_i64().and_then(|n| u64::try_from(n).ok()))
469
+ })
470
+ .and_then(|n| u32::try_from(n).ok())
471
+ }
472
+
473
+ fn agent_process_liveness(
474
+ agent: &AbnormalWatchAgent,
475
+ session_name: Option<&str>,
476
+ targets: &[crate::transport::PaneInfo],
477
+ transport: &dyn crate::transport::Transport,
478
+ ) -> ProcessCheck {
479
+ if let Some(pid) = agent.pid {
480
+ return pid_process_check("pid", pid);
481
+ }
482
+ if let Some(liveness) = agent.process_liveness {
483
+ return process_check(
484
+ liveness,
485
+ format!("explicit:{}", process_liveness_wire(liveness)),
486
+ );
487
+ }
488
+ if agent.status.as_deref().is_some_and(|status| {
489
+ matches!(
490
+ status,
491
+ "stopped" | "missing" | "error" | "dead" | "exited" | "terminated" | "crashed"
492
+ )
493
+ }) {
494
+ return process_check(
495
+ ProcessLiveness::Dead,
496
+ format!("status:{}", agent.status.as_deref().unwrap_or("unknown")),
497
+ );
498
+ }
499
+ if let Some(command) = agent.current_command.as_deref() {
500
+ return command_process_check(agent.provider, command);
501
+ }
502
+ if let Some(target) = matching_agent_target(agent, session_name, targets) {
503
+ if let Some(command) = target.current_command.as_deref() {
504
+ return pane_command_process_check(agent.provider, target, command);
505
+ }
506
+ if let Some(pid) = target.pane_pid.map(Pid::new) {
507
+ return pid_process_check("pane_pid", pid);
508
+ }
509
+ return process_check(
510
+ ProcessLiveness::Unverifiable,
511
+ "pane_present_pid_unknown".to_string(),
512
+ );
513
+ }
514
+ if let Some(pane_id) = agent.pane_id.as_deref() {
515
+ let pane = crate::transport::PaneId::new(pane_id);
516
+ return match transport.liveness(&pane) {
517
+ Ok(crate::transport::PaneLiveness::Dead) => {
518
+ process_check(ProcessLiveness::Dead, format!("pane_dead:{pane_id}"))
519
+ }
520
+ Ok(crate::transport::PaneLiveness::Live) => process_check(
521
+ ProcessLiveness::Unverifiable,
522
+ format!("pane_live_pid_unknown:{pane_id}"),
523
+ ),
524
+ Ok(crate::transport::PaneLiveness::Unknown) => process_check(
525
+ ProcessLiveness::Unverifiable,
526
+ format!("pane_unknown:{pane_id}"),
527
+ ),
528
+ Err(error) => process_check(
529
+ ProcessLiveness::Unverifiable,
530
+ format!("pane_unverifiable:{pane_id}:{error}"),
531
+ ),
532
+ };
533
+ }
534
+ let (Some(session), Some(window)) = (session_name, agent.window.as_deref()) else {
535
+ return process_check(
536
+ ProcessLiveness::Unverifiable,
537
+ "missing_session_or_window".to_string(),
538
+ );
539
+ };
540
+ let session = crate::transport::SessionName::new(session);
541
+ match transport.list_windows(&session) {
542
+ Ok(windows) if windows.iter().any(|known| known.as_str() == window) => process_check(
543
+ ProcessLiveness::Unverifiable,
544
+ "window_present_pid_unknown".to_string(),
545
+ ),
546
+ Ok(_) => process_check(ProcessLiveness::Dead, format!("window_missing:{window}")),
547
+ Err(error) => process_check(
548
+ ProcessLiveness::Unverifiable,
549
+ format!("window_unverifiable:{window}:{error}"),
550
+ ),
551
+ }
552
+ }
553
+
554
+ fn matching_agent_target<'a>(
555
+ agent: &AbnormalWatchAgent,
556
+ session_name: Option<&str>,
557
+ targets: &'a [crate::transport::PaneInfo],
558
+ ) -> Option<&'a crate::transport::PaneInfo> {
559
+ if let Some(pane_id) = agent.pane_id.as_deref() {
560
+ if let Some(target) = targets
561
+ .iter()
562
+ .find(|target| target.pane_id.as_str() == pane_id)
563
+ {
564
+ return Some(target);
565
+ }
566
+ }
567
+ let (Some(session), Some(window)) = (session_name, agent.window.as_deref()) else {
568
+ return None;
569
+ };
570
+ targets.iter().find(|target| {
571
+ target.session.as_str() == session
572
+ && target
573
+ .window_name
574
+ .as_ref()
575
+ .is_some_and(|known| known.as_str() == window)
576
+ })
577
+ }
578
+
579
+ fn pid_process_check(label: &str, pid: Pid) -> ProcessCheck {
580
+ match pid_is_running(pid) {
581
+ Ok(true) => process_check(ProcessLiveness::Alive, format!("{label}_running:{pid}")),
582
+ Ok(false) => process_check(ProcessLiveness::Dead, format!("{label}_not_running:{pid}")),
583
+ Err(error) => process_check(
584
+ ProcessLiveness::Unverifiable,
585
+ format!("{label}_unverifiable:{pid}:{error}"),
586
+ ),
587
+ }
588
+ }
589
+
590
+ fn command_process_check(provider: crate::model::enums::Provider, command: &str) -> ProcessCheck {
591
+ if crate::leader::command_matches_provider(provider, command) {
592
+ process_check(ProcessLiveness::Alive, format!("current_command:{command}"))
593
+ } else {
594
+ process_check(
595
+ ProcessLiveness::Dead,
596
+ format!("provider_not_foreground:{command}"),
597
+ )
598
+ }
599
+ }
600
+
601
+ fn pane_command_process_check(
602
+ provider: crate::model::enums::Provider,
603
+ pane: &crate::transport::PaneInfo,
604
+ command: &str,
605
+ ) -> ProcessCheck {
606
+ if crate::leader::attribute_pane_provider(pane)
607
+ .is_some_and(|candidate| crate::leader::provider_matches(candidate, provider))
608
+ {
609
+ process_check(ProcessLiveness::Alive, format!("current_command:{command}"))
610
+ } else {
611
+ process_check(
612
+ ProcessLiveness::Dead,
613
+ format!("provider_not_foreground:{command}"),
614
+ )
615
+ }
616
+ }
617
+
618
+ fn process_check(state: ProcessLiveness, detail: String) -> ProcessCheck {
619
+ ProcessCheck { state, detail }
620
+ }
621
+
622
+ fn process_liveness_wire(state: ProcessLiveness) -> &'static str {
623
+ match state {
624
+ ProcessLiveness::Alive => "alive",
625
+ ProcessLiveness::Dead => "dead",
626
+ ProcessLiveness::Unverifiable => "unverifiable",
627
+ }
628
+ }
629
+
630
+ pub(crate) fn metadata_mtime_ns(metadata: &std::fs::Metadata) -> Option<u64> {
631
+ let duration = metadata
632
+ .modified()
633
+ .ok()?
634
+ .duration_since(std::time::UNIX_EPOCH)
635
+ .ok()?;
636
+ Some(
637
+ duration
638
+ .as_secs()
639
+ .saturating_mul(1_000_000_000)
640
+ .saturating_add(u64::from(duration.subsec_nanos())),
641
+ )
642
+ }
643
+
644
+ fn abnormal_watch_payload(
645
+ agent: &AbnormalWatchAgent,
646
+ size: Option<u64>,
647
+ mtime_ns: Option<u64>,
648
+ liveness: ProcessLiveness,
649
+ signature: Option<&str>,
650
+ error_recency: ErrorRecency,
651
+ error_observation_key: Option<&str>,
652
+ error: Option<String>,
653
+ ) -> Value {
654
+ let liveness_wire = process_liveness_wire(liveness);
655
+ let dead_process = liveness == ProcessLiveness::Dead;
656
+ let latest_explicit_error = signature.is_some();
657
+ let gate = AbnormalExitGate::new(liveness, latest_explicit_error, error_recency);
658
+ let notify = gate.should_notify_worker_abnormal_exit();
659
+ let suppressed_reason = gate.suppressed_reason();
660
+ serde_json::json!({
661
+ "path": agent.rollout_path_display.as_str(),
662
+ "provider": provider_wire(agent.provider),
663
+ "mtime_ns": mtime_ns,
664
+ "size": size,
665
+ "last_offset": size,
666
+ "last_signature": signature,
667
+ "last_liveness": liveness_wire,
668
+ "dead_process": dead_process,
669
+ "process_dead": dead_process,
670
+ "provider_process_dead": dead_process,
671
+ "latest_error": latest_explicit_error,
672
+ "latest_explicit_error": latest_explicit_error,
673
+ "error_recency": error_recency.as_str(),
674
+ "fresh_error": error_recency.is_fresh(),
675
+ "error_observation_key": error_observation_key,
676
+ "dead_process_and_latest_error": dead_process && latest_explicit_error,
677
+ "dead_process_and_latest_explicit_error": dead_process && latest_explicit_error,
678
+ "process_dead_and_latest_explicit_error": dead_process && latest_explicit_error,
679
+ "provider_process_dead_and_latest_explicit_error": dead_process && latest_explicit_error,
680
+ "suppressed_reason": suppressed_reason,
681
+ "notification": notify,
682
+ "last_error": error,
683
+ "last_checked_at": chrono::Utc::now().to_rfc3339(),
684
+ })
685
+ }
686
+
687
+ fn upsert_abnormal_watch(state: &mut Value, agent_id: &str, mut payload: Value) {
688
+ let preserved = [
689
+ "last_notified_key",
690
+ "last_notified_at",
691
+ "last_suppressed_key",
692
+ "last_suppressed_at",
693
+ "last_check_key",
694
+ "last_check_at",
695
+ "last_error_observation_key",
696
+ "last_error_observation_cohort",
697
+ "last_error_observed_at",
698
+ ]
699
+ .into_iter()
700
+ .filter_map(|key| abnormal_watch_field(state, agent_id, key).map(|value| (key, value)))
701
+ .collect::<Vec<_>>();
702
+ if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
703
+ if let Some(payload_obj) = payload.as_object_mut() {
704
+ for (key, value) in preserved {
705
+ payload_obj.insert(key.to_string(), value);
706
+ }
707
+ }
708
+ watch.insert(agent_id.to_string(), payload);
709
+ }
710
+ }
711
+
712
+ fn coordinator_child_object<'a>(
713
+ state: &'a mut Value,
714
+ key: &str,
715
+ ) -> Option<&'a mut serde_json::Map<String, Value>> {
716
+ if !state.is_object() {
717
+ *state = serde_json::json!({});
718
+ }
719
+ let state_obj = state.as_object_mut()?;
720
+ let coordinator = state_obj
721
+ .entry("coordinator".to_string())
722
+ .or_insert_with(|| serde_json::json!({}));
723
+ if !coordinator.is_object() {
724
+ *coordinator = serde_json::json!({});
725
+ }
726
+ let coord_obj = coordinator.as_object_mut()?;
727
+ let child = coord_obj
728
+ .entry(key.to_string())
729
+ .or_insert_with(|| serde_json::json!({}));
730
+ if !child.is_object() {
731
+ *child = serde_json::json!({});
732
+ }
733
+ child.as_object_mut()
734
+ }
735
+
736
+ fn abnormal_last_notified_key(state: &Value, agent_id: &str) -> Option<String> {
737
+ abnormal_watch_str(state, agent_id, "last_notified_key")
738
+ }
739
+
740
+ fn abnormal_last_suppressed_key(state: &Value, agent_id: &str) -> Option<String> {
741
+ abnormal_watch_str(state, agent_id, "last_suppressed_key")
742
+ }
743
+
744
+ fn abnormal_last_check_key(state: &Value, agent_id: &str) -> Option<String> {
745
+ abnormal_watch_str(state, agent_id, "last_check_key")
746
+ }
747
+
748
+ fn abnormal_last_error_observation_key(state: &Value, agent_id: &str) -> Option<String> {
749
+ abnormal_watch_str(state, agent_id, "last_error_observation_key")
750
+ }
751
+
752
+ fn abnormal_last_error_observation_cohort(state: &Value, agent_id: &str) -> Option<String> {
753
+ abnormal_watch_str(state, agent_id, "last_error_observation_cohort")
754
+ }
755
+
756
+ /// P1: Python `_TAIL_BYTES` parity (idle_takeover_wiring.py:13) — RS must not read less.
757
+ const ABNORMAL_TAIL_BYTES: u64 = 131_072;
758
+
759
+ /// P1: bounded tail read; a partial first line is harmless (the consumer only parses
760
+ /// the latest complete JSONL record) and lossy UTF-8 keeps a mid-codepoint seek safe.
761
+ pub(crate) fn read_tail_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
762
+ use std::io::{Read, Seek, SeekFrom};
763
+ let mut file = std::fs::File::open(path)?;
764
+ let len = file.metadata()?.len();
765
+ if len > max_bytes {
766
+ file.seek(SeekFrom::Start(len - max_bytes))?;
767
+ }
768
+ let mut bytes = Vec::new();
769
+ file.read_to_end(&mut bytes)?;
770
+ Ok(String::from_utf8_lossy(&bytes).into_owned())
771
+ }
772
+
773
+ /// P1: the previous tick's `(size, mtime_ns)` pair from the abnormal watch payload.
774
+ fn abnormal_watch_stored_metadata(state: &Value, agent_id: &str) -> Option<(u64, u64)> {
775
+ let watch = state
776
+ .get("coordinator")?
777
+ .get("abnormal_exit_watch")?
778
+ .get(agent_id)?;
779
+ Some((
780
+ watch.get("size")?.as_u64()?,
781
+ watch.get("mtime_ns")?.as_u64()?,
782
+ ))
783
+ }
784
+
785
+ fn abnormal_watch_str(state: &Value, agent_id: &str, field: &str) -> Option<String> {
786
+ state
787
+ .get("coordinator")
788
+ .and_then(|v| v.get("abnormal_exit_watch"))
789
+ .and_then(|v| v.get(agent_id))
790
+ .and_then(|v| v.get(field))
791
+ .and_then(Value::as_str)
792
+ .map(str::to_string)
793
+ }
794
+
795
+ fn abnormal_watch_field(state: &Value, agent_id: &str, field: &str) -> Option<Value> {
796
+ state
797
+ .get("coordinator")
798
+ .and_then(|v| v.get("abnormal_exit_watch"))
799
+ .and_then(|v| v.get(agent_id))
800
+ .and_then(|v| v.get(field))
801
+ .cloned()
802
+ }
803
+
804
+ fn mark_abnormal_notified(state: &mut Value, agent_id: &str, key: &str) {
805
+ if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
806
+ let entry = watch
807
+ .entry(agent_id.to_string())
808
+ .or_insert_with(|| serde_json::json!({}));
809
+ if !entry.is_object() {
810
+ *entry = serde_json::json!({});
811
+ }
812
+ if let Some(obj) = entry.as_object_mut() {
813
+ obj.insert("last_notified_key".to_string(), serde_json::json!(key));
814
+ obj.insert(
815
+ "last_notified_at".to_string(),
816
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
817
+ );
818
+ }
819
+ }
820
+ }
821
+
822
+ fn mark_abnormal_suppressed(state: &mut Value, agent_id: &str, key: &str) {
823
+ if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
824
+ let entry = watch
825
+ .entry(agent_id.to_string())
826
+ .or_insert_with(|| serde_json::json!({}));
827
+ if !entry.is_object() {
828
+ *entry = serde_json::json!({});
829
+ }
830
+ if let Some(obj) = entry.as_object_mut() {
831
+ obj.insert("last_suppressed_key".to_string(), serde_json::json!(key));
832
+ obj.insert(
833
+ "last_suppressed_at".to_string(),
834
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
835
+ );
836
+ }
837
+ }
838
+ }
839
+
840
+ fn mark_abnormal_checked(state: &mut Value, agent_id: &str, key: &str) {
841
+ if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
842
+ let entry = watch
843
+ .entry(agent_id.to_string())
844
+ .or_insert_with(|| serde_json::json!({}));
845
+ if !entry.is_object() {
846
+ *entry = serde_json::json!({});
847
+ }
848
+ if let Some(obj) = entry.as_object_mut() {
849
+ obj.insert("last_check_key".to_string(), serde_json::json!(key));
850
+ obj.insert(
851
+ "last_check_at".to_string(),
852
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
853
+ );
854
+ }
855
+ }
856
+ }
857
+
858
+ fn mark_abnormal_error_observed(
859
+ state: &mut Value,
860
+ agent_id: &str,
861
+ observation_key: &str,
862
+ cohort_key: &str,
863
+ ) {
864
+ if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
865
+ let entry = watch
866
+ .entry(agent_id.to_string())
867
+ .or_insert_with(|| serde_json::json!({}));
868
+ if !entry.is_object() {
869
+ *entry = serde_json::json!({});
870
+ }
871
+ if let Some(obj) = entry.as_object_mut() {
872
+ obj.insert(
873
+ "last_error_observation_key".to_string(),
874
+ serde_json::json!(observation_key),
875
+ );
876
+ obj.insert(
877
+ "last_error_observation_cohort".to_string(),
878
+ serde_json::json!(cohort_key),
879
+ );
880
+ obj.insert(
881
+ "last_error_observed_at".to_string(),
882
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
883
+ );
884
+ }
885
+ }
886
+ }
887
+
888
+ fn write_abnormal_check(
889
+ event_log: &EventLog,
890
+ team: &str,
891
+ agent: &AbnormalWatchAgent,
892
+ liveness: &ProcessCheck,
893
+ fact: Option<&crate::provider::FaultFact>,
894
+ decision: AbnormalExitDecision,
895
+ error_recency: ErrorRecency,
896
+ size: u64,
897
+ mtime_ns: Option<u64>,
898
+ ) -> Result<(), TickError> {
899
+ let dead_process = liveness.state == ProcessLiveness::Dead;
900
+ let latest_explicit_error = fact.is_some();
901
+ event_log.write(
902
+ "worker.abnormal_exit.check",
903
+ serde_json::json!({
904
+ "team_id": team,
905
+ "agent_id": agent.agent_id.as_str(),
906
+ "provider": provider_wire(agent.provider),
907
+ "path": agent.rollout_path_display.as_str(),
908
+ "size": size,
909
+ "last_offset": size,
910
+ "mtime_ns": mtime_ns,
911
+ "dead_process": dead_process,
912
+ "process_dead": dead_process,
913
+ "provider_process_dead": dead_process,
914
+ "latest_error": latest_explicit_error,
915
+ "latest_explicit_error": latest_explicit_error,
916
+ "error_recency": error_recency.as_str(),
917
+ "fresh_error": error_recency.is_fresh(),
918
+ "dead_process_and_latest_error": dead_process && latest_explicit_error,
919
+ "dead_process_and_latest_explicit_error": dead_process && latest_explicit_error,
920
+ "process_dead_and_latest_explicit_error": dead_process && latest_explicit_error,
921
+ "provider_process_dead_and_latest_explicit_error": dead_process && latest_explicit_error,
922
+ "notification": matches!(decision, AbnormalExitDecision::Notify),
923
+ "suppressed_reason": match decision {
924
+ AbnormalExitDecision::Suppress(reason) => Some(reason),
925
+ AbnormalExitDecision::Notify | AbnormalExitDecision::NoSignal => None,
926
+ },
927
+ "signature": fact.map(|fact| fact.signature.as_str()),
928
+ "turn_id": fact.and_then(|fact| fact.turn_id.as_ref().map(|id| id.as_str())),
929
+ "apiErrorStatus": fact.and_then(|fact| fact.api_error_status),
930
+ "error": fact.and_then(|fact| fact.error.as_deref()),
931
+ "requestId": fact.and_then(|fact| fact.request_id.as_deref()),
932
+ "assistant_uuid": fact.and_then(|fact| fact.assistant_uuid.as_deref()),
933
+ "process_liveness": process_liveness_wire(liveness.state),
934
+ "pid_status": liveness.detail.as_str(),
935
+ }),
936
+ )?;
937
+ Ok(())
938
+ }
939
+
940
+ fn write_abnormal_suppressed(
941
+ event_log: &EventLog,
942
+ team: &str,
943
+ agent: &AbnormalWatchAgent,
944
+ liveness: &ProcessCheck,
945
+ reason: &str,
946
+ ) -> Result<(), TickError> {
947
+ event_log.write(
948
+ "abnormal_exit.single_signal_suppressed",
949
+ serde_json::json!({
950
+ "team_id": team,
951
+ "agent_id": agent.agent_id.as_str(),
952
+ "provider": provider_wire(agent.provider),
953
+ "path": agent.rollout_path_display.as_str(),
954
+ "reason": reason,
955
+ "notification": false,
956
+ "dead_process": liveness.state == ProcessLiveness::Dead,
957
+ "process_dead": liveness.state == ProcessLiveness::Dead,
958
+ "provider_process_dead": liveness.state == ProcessLiveness::Dead,
959
+ "latest_error": false,
960
+ "latest_explicit_error": false,
961
+ "error_recency": ErrorRecency::None.as_str(),
962
+ "fresh_error": false,
963
+ "dead_process_and_latest_error": false,
964
+ "dead_process_and_latest_explicit_error": false,
965
+ "process_dead_and_latest_explicit_error": false,
966
+ "provider_process_dead_and_latest_explicit_error": false,
967
+ "process_liveness": process_liveness_wire(liveness.state),
968
+ "pid_status": liveness.detail.as_str(),
969
+ }),
970
+ )?;
971
+ Ok(())
972
+ }
973
+
974
+ fn abnormal_dedupe_key(
975
+ agent: &AbnormalWatchAgent,
976
+ fact: &crate::provider::FaultFact,
977
+ size: u64,
978
+ ) -> String {
979
+ let bucket = fact
980
+ .turn_id
981
+ .as_ref()
982
+ .map(|id| id.as_str().to_string())
983
+ .unwrap_or_else(|| size.to_string());
984
+ format!(
985
+ "worker.abnormal_exit:{}:{}:{}:{}",
986
+ agent.agent_id,
987
+ agent.rollout_path_display,
988
+ fact.signature.as_str(),
989
+ bucket
990
+ )
991
+ }
992
+
993
+ fn abnormal_error_cohort_key(agent: &AbnormalWatchAgent) -> String {
994
+ let cohort = agent
995
+ .spawn_epoch
996
+ .map(|epoch| format!("spawn_epoch:{epoch}"))
997
+ .or_else(|| {
998
+ agent
999
+ .spawned_at
1000
+ .as_deref()
1001
+ .map(|spawned_at| format!("spawned_at:{spawned_at}"))
1002
+ })
1003
+ .unwrap_or_else(|| "legacy".to_string());
1004
+ format!(
1005
+ "worker.abnormal_exit.cohort:{}:{}:{}",
1006
+ agent.agent_id, agent.rollout_path_display, cohort
1007
+ )
1008
+ }
1009
+
1010
+ fn abnormal_error_observation_key(
1011
+ agent: &AbnormalWatchAgent,
1012
+ fact: &crate::provider::FaultFact,
1013
+ size: u64,
1014
+ ) -> String {
1015
+ let bucket = abnormal_error_fact_identity(fact).unwrap_or_else(|| size.to_string());
1016
+ format!(
1017
+ "worker.abnormal_exit.error:{}:{}:{}:{}",
1018
+ agent.agent_id,
1019
+ agent.rollout_path_display,
1020
+ fact.signature.as_str(),
1021
+ bucket
1022
+ )
1023
+ }
1024
+
1025
+ fn abnormal_error_fact_identity(fact: &crate::provider::FaultFact) -> Option<String> {
1026
+ fact.assistant_uuid
1027
+ .as_deref()
1028
+ .or(fact.request_id.as_deref())
1029
+ .or_else(|| fact.turn_id.as_ref().map(|id| id.as_str()))
1030
+ .map(str::to_string)
1031
+ }
1032
+
1033
+ fn abnormal_error_recency(
1034
+ state: &Value,
1035
+ agent: &AbnormalWatchAgent,
1036
+ observation_key: Option<&str>,
1037
+ cohort_key: Option<&str>,
1038
+ ) -> ErrorRecency {
1039
+ let (Some(observation_key), Some(cohort_key)) = (observation_key, cohort_key) else {
1040
+ return ErrorRecency::None;
1041
+ };
1042
+ let previous_key = abnormal_last_error_observation_key(state, &agent.agent_id);
1043
+ let previous_cohort = abnormal_last_error_observation_cohort(state, &agent.agent_id);
1044
+ match (previous_key.as_deref(), previous_cohort.as_deref()) {
1045
+ (Some(key), Some(cohort)) if cohort == cohort_key && key != observation_key => {
1046
+ ErrorRecency::Fresh
1047
+ }
1048
+ _ => ErrorRecency::Stale,
1049
+ }
1050
+ }
1051
+
1052
+ fn abnormal_suppression_key(
1053
+ agent: &AbnormalWatchAgent,
1054
+ liveness: &ProcessCheck,
1055
+ reason: &str,
1056
+ size: u64,
1057
+ ) -> String {
1058
+ format!(
1059
+ "abnormal_exit.single_signal_suppressed:{}:{}:{}:{}:{}",
1060
+ agent.agent_id,
1061
+ agent.rollout_path_display,
1062
+ reason,
1063
+ process_liveness_wire(liveness.state),
1064
+ size
1065
+ )
1066
+ }
1067
+
1068
+ fn abnormal_check_key(
1069
+ agent: &AbnormalWatchAgent,
1070
+ liveness: &ProcessCheck,
1071
+ fact: Option<&crate::provider::FaultFact>,
1072
+ error_recency: ErrorRecency,
1073
+ error_observation_key: Option<&str>,
1074
+ size: u64,
1075
+ ) -> String {
1076
+ format!(
1077
+ "worker.abnormal_exit.check:{}:{}:{}:{}:{}:{}:{}",
1078
+ agent.agent_id,
1079
+ agent.rollout_path_display,
1080
+ process_liveness_wire(liveness.state),
1081
+ fact.map(|fact| fact.signature.as_str()).unwrap_or("-"),
1082
+ error_recency.as_str(),
1083
+ error_observation_key.unwrap_or("-"),
1084
+ size
1085
+ )
1086
+ }
1087
+
1088
+ fn format_abnormal_exit_message(
1089
+ team: &str,
1090
+ agent: &AbnormalWatchAgent,
1091
+ fact: &crate::provider::FaultFact,
1092
+ liveness: &ProcessCheck,
1093
+ size: u64,
1094
+ ) -> String {
1095
+ let turn_id = fact.turn_id.as_ref().map(|id| id.as_str()).unwrap_or("-");
1096
+ format!(
1097
+ "Team Agent detected a provider abnormal exit.\n\n\
1098
+ event: worker.abnormal_exit\n\
1099
+ team: {team}\n\
1100
+ node: {node}\n\
1101
+ provider: {provider}\n\
1102
+ signature: {signature}\n\
1103
+ turn_id: {turn_id}\n\
1104
+ transcript: {path}\n\
1105
+ last_offset: {size}\n\
1106
+ pid_status: {pid_status}\n\n\
1107
+ No automatic restart was performed.",
1108
+ node = agent.agent_id.as_str(),
1109
+ provider = provider_wire(agent.provider),
1110
+ signature = fact.signature.as_str(),
1111
+ path = agent.rollout_path_display.as_str(),
1112
+ pid_status = liveness.detail.as_str(),
1113
+ )
1114
+ }
1115
+
1116
+ #[cfg(test)]
1117
+ #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1118
+ mod tests {
1119
+ use super::*;
1120
+ use crate::coordinator::tick::Coordinator;
1121
+ use crate::coordinator::types::{ErrorLists, ProviderRegistry, WorkspacePath};
1122
+ use std::io::Write as _;
1123
+
1124
+ struct NormalRegistry;
1125
+
1126
+ impl ProviderRegistry for NormalRegistry {
1127
+ fn adapter_for(
1128
+ &self,
1129
+ provider: crate::provider::Provider,
1130
+ ) -> Box<dyn crate::provider::ProviderAdapter> {
1131
+ crate::provider::get_adapter(provider)
1132
+ }
1133
+
1134
+ fn error_lists(&self, _provider: crate::provider::Provider) -> ErrorLists {
1135
+ ErrorLists::default()
1136
+ }
1137
+ }
1138
+ #[test]
1139
+ fn abnormal_stale_error_baselines_then_fresh_alive_error_notifies() {
1140
+ let dir = temp_abnormal_dir("alive-fresh");
1141
+ let rollout = dir.join("rollout-w1.jsonl");
1142
+ std::fs::write(
1143
+ &rollout,
1144
+ "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t1\",\"status\":\"failed\"}}}\n",
1145
+ )
1146
+ .unwrap();
1147
+ seed_abnormal_state(&dir, &rollout, "alive", 1);
1148
+ let coordinator = abnormal_test_coordinator(&dir);
1149
+
1150
+ coordinator.tick().unwrap();
1151
+
1152
+ let first_events = read_test_events(&dir);
1153
+ assert!(
1154
+ find_event(&first_events, "worker.abnormal_exit").is_none(),
1155
+ "first observed explicit error is stale baseline and must not notify; events={first_events:?}"
1156
+ );
1157
+ let stale_check =
1158
+ find_event(&first_events, "worker.abnormal_exit.check").expect("stale check event");
1159
+ assert_eq!(stale_check["error_recency"], serde_json::json!("stale"));
1160
+ assert_eq!(stale_check["fresh_error"], serde_json::json!(false));
1161
+ assert_eq!(stale_check["notification"], serde_json::json!(false));
1162
+ assert!(
1163
+ find_event(&first_events, "abnormal_exit.single_signal_suppressed").is_none(),
1164
+ "stale/error-only observations must not emit single-signal suppression"
1165
+ );
1166
+ let watch = abnormal_test_watch(&dir);
1167
+ assert_eq!(watch["error_recency"], serde_json::json!("stale"));
1168
+ assert_eq!(watch["fresh_error"], serde_json::json!(false));
1169
+ assert!(
1170
+ watch["last_error_observation_key"].as_str().is_some(),
1171
+ "first explicit error must persist the stale baseline; watch={watch}"
1172
+ );
1173
+
1174
+ std::fs::OpenOptions::new()
1175
+ .append(true)
1176
+ .open(&rollout)
1177
+ .unwrap()
1178
+ .write_all(
1179
+ b"{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t2\",\"status\":\"failed\"}}}\n",
1180
+ )
1181
+ .unwrap();
1182
+ coordinator.tick().unwrap();
1183
+
1184
+ let events = read_test_events(&dir);
1185
+ let abnormal =
1186
+ find_event(&events, "worker.abnormal_exit").expect("fresh alive error notification");
1187
+ assert_eq!(abnormal["provider_process_dead"], serde_json::json!(false));
1188
+ assert_eq!(abnormal["latest_explicit_error"], serde_json::json!(true));
1189
+ assert_eq!(abnormal["error_recency"], serde_json::json!("fresh"));
1190
+ assert_eq!(abnormal["fresh_error"], serde_json::json!(true));
1191
+ assert_eq!(
1192
+ abnormal["provider_process_dead_and_latest_explicit_error"],
1193
+ serde_json::json!(false)
1194
+ );
1195
+ assert_eq!(abnormal["notification_status"], serde_json::json!("queued"));
1196
+ }
1197
+
1198
+ #[test]
1199
+ fn abnormal_claude_assistant_api_error_events_include_structured_details() {
1200
+ let dir = temp_abnormal_dir("claude-api-details");
1201
+ let rollout = dir.join("rollout-w1.jsonl");
1202
+ std::fs::write(
1203
+ &rollout,
1204
+ claude_assistant_api_error_line(
1205
+ "assistant-400",
1206
+ "parent-400",
1207
+ "session-400",
1208
+ 400,
1209
+ "unknown",
1210
+ None,
1211
+ ),
1212
+ )
1213
+ .unwrap();
1214
+ seed_abnormal_state_with_provider(&dir, &rollout, "alive", 1, "claude_code");
1215
+ let coordinator = abnormal_test_coordinator(&dir);
1216
+
1217
+ coordinator.tick().unwrap();
1218
+
1219
+ let first_events = read_test_events(&dir);
1220
+ let stale_check =
1221
+ find_event(&first_events, "worker.abnormal_exit.check").expect("stale check event");
1222
+ assert_eq!(stale_check["error_recency"], serde_json::json!("stale"));
1223
+ assert_eq!(stale_check["notification"], serde_json::json!(false));
1224
+ assert_eq!(stale_check["apiErrorStatus"], serde_json::json!(400));
1225
+ assert_eq!(stale_check["error"], serde_json::json!("unknown"));
1226
+ assert_eq!(stale_check["requestId"], serde_json::json!(null));
1227
+ assert_eq!(
1228
+ stale_check["assistant_uuid"],
1229
+ serde_json::json!("assistant-400")
1230
+ );
1231
+
1232
+ std::fs::OpenOptions::new()
1233
+ .append(true)
1234
+ .open(&rollout)
1235
+ .unwrap()
1236
+ .write_all(
1237
+ claude_assistant_api_error_line(
1238
+ "assistant-404",
1239
+ "parent-404",
1240
+ "session-404",
1241
+ 404,
1242
+ "model_not_found",
1243
+ Some("req_011CceNfWj2aPY5gtCdakULt"),
1244
+ )
1245
+ .as_bytes(),
1246
+ )
1247
+ .unwrap();
1248
+ coordinator.tick().unwrap();
1249
+
1250
+ let events = read_test_events(&dir);
1251
+ let fresh_check =
1252
+ find_event(&events, "worker.abnormal_exit.check").expect("fresh check event");
1253
+ assert_eq!(fresh_check["error_recency"], serde_json::json!("fresh"));
1254
+ assert_eq!(fresh_check["notification"], serde_json::json!(true));
1255
+ assert_eq!(fresh_check["apiErrorStatus"], serde_json::json!(404));
1256
+ assert_eq!(fresh_check["error"], serde_json::json!("model_not_found"));
1257
+ assert_eq!(
1258
+ fresh_check["requestId"],
1259
+ serde_json::json!("req_011CceNfWj2aPY5gtCdakULt")
1260
+ );
1261
+ assert_eq!(
1262
+ fresh_check["assistant_uuid"],
1263
+ serde_json::json!("assistant-404")
1264
+ );
1265
+
1266
+ let abnormal =
1267
+ find_event(&events, "worker.abnormal_exit").expect("fresh alive error notification");
1268
+ assert_eq!(abnormal["signature"], serde_json::json!("api_error"));
1269
+ assert_eq!(abnormal["turn_id"], serde_json::json!("assistant-404"));
1270
+ assert_eq!(abnormal["apiErrorStatus"], serde_json::json!(404));
1271
+ assert_eq!(abnormal["error"], serde_json::json!("model_not_found"));
1272
+ assert_eq!(
1273
+ abnormal["requestId"],
1274
+ serde_json::json!("req_011CceNfWj2aPY5gtCdakULt")
1275
+ );
1276
+ assert_eq!(
1277
+ abnormal["assistant_uuid"],
1278
+ serde_json::json!("assistant-404")
1279
+ );
1280
+ assert_eq!(abnormal["notification_status"], serde_json::json!("queued"));
1281
+ }
1282
+
1283
+ #[test]
1284
+ fn abnormal_claude_assistant_api_error_followed_by_bookkeeping_still_detected() {
1285
+ let dir = temp_abnormal_dir("claude-api-bookkeeping");
1286
+ let rollout = dir.join("rollout-w1.jsonl");
1287
+ let session_id = "97ec1070-f19b-49ed-b60f-3cb158e92053";
1288
+ std::fs::write(
1289
+ &rollout,
1290
+ format!(
1291
+ "{}{}",
1292
+ claude_assistant_api_error_line(
1293
+ "94e88d55-aac7-46bc-85cd-b7fcfc8a9ef6",
1294
+ "245fce6f-2427-4b05-af01-8619df64afab",
1295
+ session_id,
1296
+ 404,
1297
+ "model_not_found",
1298
+ Some("req_011CceUVQ94dxahgwAHf8sdS"),
1299
+ ),
1300
+ claude_turn_duration_line(
1301
+ "6c96328b-8c01-46c0-b68e-a096a89c904f",
1302
+ "94e88d55-aac7-46bc-85cd-b7fcfc8a9ef6",
1303
+ session_id,
1304
+ ),
1305
+ ),
1306
+ )
1307
+ .unwrap();
1308
+ seed_abnormal_state_with_provider(&dir, &rollout, "alive", 1, "claude_code");
1309
+ let coordinator = abnormal_test_coordinator(&dir);
1310
+
1311
+ coordinator.tick().unwrap();
1312
+
1313
+ let first_events = read_test_events(&dir);
1314
+ let first_check =
1315
+ find_event(&first_events, "worker.abnormal_exit.check").expect("stale check event");
1316
+ assert_eq!(
1317
+ first_check["latest_explicit_error"],
1318
+ serde_json::json!(true)
1319
+ );
1320
+ assert_eq!(first_check["error_recency"], serde_json::json!("stale"));
1321
+ assert_eq!(first_check["notification"], serde_json::json!(false));
1322
+ assert_eq!(first_check["apiErrorStatus"], serde_json::json!(404));
1323
+ assert_eq!(first_check["error"], serde_json::json!("model_not_found"));
1324
+ assert_eq!(
1325
+ first_check["requestId"],
1326
+ serde_json::json!("req_011CceUVQ94dxahgwAHf8sdS")
1327
+ );
1328
+ assert_eq!(
1329
+ first_check["assistant_uuid"],
1330
+ serde_json::json!("94e88d55-aac7-46bc-85cd-b7fcfc8a9ef6")
1331
+ );
1332
+ assert!(
1333
+ find_event(&first_events, "worker.abnormal_exit").is_none(),
1334
+ "first observed error baselines stale even when followed by turn_duration"
1335
+ );
1336
+ let first_observation_key = abnormal_test_watch(&dir)["last_error_observation_key"]
1337
+ .as_str()
1338
+ .expect("stale baseline persists observation key")
1339
+ .to_string();
1340
+
1341
+ std::fs::OpenOptions::new()
1342
+ .append(true)
1343
+ .open(&rollout)
1344
+ .unwrap()
1345
+ .write_all(
1346
+ format!(
1347
+ "{}{}{}",
1348
+ claude_file_history_snapshot_line(session_id),
1349
+ claude_last_prompt_line(session_id),
1350
+ claude_mode_line(session_id),
1351
+ )
1352
+ .as_bytes(),
1353
+ )
1354
+ .unwrap();
1355
+ coordinator.tick().unwrap();
1356
+
1357
+ let after_bookkeeping_events = read_test_events(&dir);
1358
+ let bookkeeping_check = find_event(&after_bookkeeping_events, "worker.abnormal_exit.check")
1359
+ .expect("bookkeeping check event");
1360
+ assert_eq!(
1361
+ bookkeeping_check["error_recency"],
1362
+ serde_json::json!("stale")
1363
+ );
1364
+ assert_eq!(bookkeeping_check["notification"], serde_json::json!(false));
1365
+ assert_eq!(
1366
+ bookkeeping_check["assistant_uuid"],
1367
+ serde_json::json!("94e88d55-aac7-46bc-85cd-b7fcfc8a9ef6")
1368
+ );
1369
+ assert_eq!(
1370
+ abnormal_test_watch(&dir)["last_error_observation_key"],
1371
+ serde_json::json!(first_observation_key)
1372
+ );
1373
+ assert!(
1374
+ find_event(&after_bookkeeping_events, "worker.abnormal_exit").is_none(),
1375
+ "bookkeeping-only growth after the same error must not notify"
1376
+ );
1377
+
1378
+ std::fs::OpenOptions::new()
1379
+ .append(true)
1380
+ .open(&rollout)
1381
+ .unwrap()
1382
+ .write_all(
1383
+ format!(
1384
+ "{}{}",
1385
+ claude_assistant_api_error_line(
1386
+ "11b8790e-718b-4910-bf6a-62d19135a93b",
1387
+ "ccea3b0f-23e7-48ed-aa57-d59e321becfb",
1388
+ session_id,
1389
+ 404,
1390
+ "model_not_found",
1391
+ Some("req_011CceUhRwYfdmLPbv9nMmqX"),
1392
+ ),
1393
+ claude_turn_duration_line(
1394
+ "2d382eff-783b-48ca-83c1-c93d3dc8b068",
1395
+ "11b8790e-718b-4910-bf6a-62d19135a93b",
1396
+ session_id,
1397
+ ),
1398
+ )
1399
+ .as_bytes(),
1400
+ )
1401
+ .unwrap();
1402
+ coordinator.tick().unwrap();
1403
+
1404
+ let events = read_test_events(&dir);
1405
+ let fresh_check =
1406
+ find_event(&events, "worker.abnormal_exit.check").expect("fresh check event");
1407
+ assert_eq!(fresh_check["error_recency"], serde_json::json!("fresh"));
1408
+ assert_eq!(fresh_check["notification"], serde_json::json!(true));
1409
+ assert_eq!(
1410
+ fresh_check["assistant_uuid"],
1411
+ serde_json::json!("11b8790e-718b-4910-bf6a-62d19135a93b")
1412
+ );
1413
+ let abnormal =
1414
+ find_event(&events, "worker.abnormal_exit").expect("fresh API error notification");
1415
+ assert_eq!(
1416
+ abnormal["turn_id"],
1417
+ serde_json::json!("11b8790e-718b-4910-bf6a-62d19135a93b")
1418
+ );
1419
+ assert_eq!(
1420
+ abnormal["requestId"],
1421
+ serde_json::json!("req_011CceUhRwYfdmLPbv9nMmqX")
1422
+ );
1423
+ assert_eq!(abnormal["notification_status"], serde_json::json!("queued"));
1424
+ }
1425
+
1426
+ #[test]
1427
+ fn codex_failed_turn_then_completed_turn_no_fresh_notification() {
1428
+ let dir = temp_abnormal_dir("codex-failed-then-completed");
1429
+ let rollout = dir.join("rollout-w1.jsonl");
1430
+ std::fs::write(
1431
+ &rollout,
1432
+ "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t1\",\"status\":\"failed\"}}}\n",
1433
+ )
1434
+ .unwrap();
1435
+ seed_abnormal_state(&dir, &rollout, "alive", 1);
1436
+ let coordinator = abnormal_test_coordinator(&dir);
1437
+
1438
+ coordinator.tick().unwrap();
1439
+
1440
+ let first_events = read_test_events(&dir);
1441
+ let first_check =
1442
+ find_event(&first_events, "worker.abnormal_exit.check").expect("stale check event");
1443
+ assert_eq!(first_check["turn_id"], serde_json::json!("t1"));
1444
+ assert_eq!(first_check["error_recency"], serde_json::json!("stale"));
1445
+ assert_eq!(first_check["notification"], serde_json::json!(false));
1446
+ assert!(
1447
+ find_event(&first_events, "worker.abnormal_exit").is_none(),
1448
+ "first failed turn is only a stale baseline"
1449
+ );
1450
+
1451
+ std::fs::OpenOptions::new()
1452
+ .append(true)
1453
+ .open(&rollout)
1454
+ .unwrap()
1455
+ .write_all(
1456
+ b"{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t-complete\",\"status\":\"completed\"}}}\n",
1457
+ )
1458
+ .unwrap();
1459
+ coordinator.tick().unwrap();
1460
+
1461
+ let after_completed_events = read_test_events(&dir);
1462
+ let completed_check = find_event(&after_completed_events, "worker.abnormal_exit.check")
1463
+ .expect("completed-after-failure check event");
1464
+ assert_eq!(completed_check["turn_id"], serde_json::json!("t1"));
1465
+ assert_eq!(completed_check["error_recency"], serde_json::json!("stale"));
1466
+ assert_eq!(completed_check["fresh_error"], serde_json::json!(false));
1467
+ assert_eq!(completed_check["notification"], serde_json::json!(false));
1468
+ assert!(
1469
+ find_event(&after_completed_events, "worker.abnormal_exit").is_none(),
1470
+ "completed turn after the same old failed turn must not emit a fresh notification"
1471
+ );
1472
+
1473
+ std::fs::OpenOptions::new()
1474
+ .append(true)
1475
+ .open(&rollout)
1476
+ .unwrap()
1477
+ .write_all(
1478
+ b"{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t2\",\"status\":\"failed\"}}}\n",
1479
+ )
1480
+ .unwrap();
1481
+ coordinator.tick().unwrap();
1482
+
1483
+ let events = read_test_events(&dir);
1484
+ let fresh_check =
1485
+ find_event(&events, "worker.abnormal_exit.check").expect("new failed turn check event");
1486
+ assert_eq!(fresh_check["turn_id"], serde_json::json!("t2"));
1487
+ assert_eq!(fresh_check["error_recency"], serde_json::json!("fresh"));
1488
+ assert_eq!(fresh_check["notification"], serde_json::json!(true));
1489
+ let abnormal =
1490
+ find_event(&events, "worker.abnormal_exit").expect("fresh Codex failure notification");
1491
+ assert_eq!(abnormal["turn_id"], serde_json::json!("t2"));
1492
+ assert_eq!(abnormal["notification_status"], serde_json::json!("queued"));
1493
+ }
1494
+
1495
+ #[test]
1496
+ fn abnormal_dead_process_without_explicit_error_stays_dead_only() {
1497
+ let dir = temp_abnormal_dir("dead-only");
1498
+ let rollout = dir.join("rollout-w1.jsonl");
1499
+ std::fs::write(
1500
+ &rollout,
1501
+ "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t1\",\"status\":\"completed\"}}}\n",
1502
+ )
1503
+ .unwrap();
1504
+ seed_abnormal_state(&dir, &rollout, "dead", 1);
1505
+ let coordinator = abnormal_test_coordinator(&dir);
1506
+
1507
+ coordinator.tick().unwrap();
1508
+
1509
+ let events = read_test_events(&dir);
1510
+ assert!(
1511
+ find_event(&events, "worker.abnormal_exit").is_none(),
1512
+ "dead process without explicit error must not notify; events={events:?}"
1513
+ );
1514
+ let suppressed =
1515
+ find_event(&events, "abnormal_exit.single_signal_suppressed").expect("dead_only");
1516
+ assert_eq!(suppressed["reason"], serde_json::json!("dead_only"));
1517
+ assert_eq!(
1518
+ suppressed["latest_explicit_error"],
1519
+ serde_json::json!(false)
1520
+ );
1521
+ assert_eq!(suppressed["error_recency"], serde_json::json!("none"));
1522
+ assert_eq!(suppressed["fresh_error"], serde_json::json!(false));
1523
+ }
1524
+
1525
+ #[test]
1526
+ fn abnormal_recency_treats_cohort_change_as_stale() {
1527
+ let agent = test_abnormal_agent("/tmp/rollout.jsonl", Some(2), None);
1528
+ let fact = crate::provider::latest_explicit_error_fact(
1529
+ crate::provider::Provider::Codex,
1530
+ "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t1\",\"status\":\"failed\"}}}\n",
1531
+ )
1532
+ .unwrap();
1533
+ let observation_key = abnormal_error_observation_key(&agent, &fact, 99);
1534
+ let state = serde_json::json!({
1535
+ "coordinator": {
1536
+ "abnormal_exit_watch": {
1537
+ "w1": {
1538
+ "last_error_observation_key": observation_key,
1539
+ "last_error_observation_cohort": "worker.abnormal_exit.cohort:w1:/tmp/rollout.jsonl:spawn_epoch:1"
1540
+ }
1541
+ }
1542
+ }
1543
+ });
1544
+ let cohort_key = abnormal_error_cohort_key(&agent);
1545
+
1546
+ let recency = abnormal_error_recency(
1547
+ &state,
1548
+ &agent,
1549
+ Some(observation_key.as_str()),
1550
+ Some(cohort_key.as_str()),
1551
+ );
1552
+
1553
+ assert_eq!(
1554
+ recency,
1555
+ ErrorRecency::Stale,
1556
+ "cohort change baselines the observed error instead of treating it as fresh"
1557
+ );
1558
+ }
1559
+
1560
+ #[test]
1561
+ fn abnormal_dead_fresh_error_event_reports_actual_dead_booleans() {
1562
+ let dir = temp_abnormal_dir("dead-fresh");
1563
+ let rollout = dir.join("rollout-w1.jsonl");
1564
+ std::fs::write(
1565
+ &rollout,
1566
+ "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t1\",\"status\":\"failed\"}}}\n",
1567
+ )
1568
+ .unwrap();
1569
+ seed_abnormal_state(&dir, &rollout, "dead", 1);
1570
+ let coordinator = abnormal_test_coordinator(&dir);
1571
+ coordinator.tick().unwrap();
1572
+ std::fs::OpenOptions::new()
1573
+ .append(true)
1574
+ .open(&rollout)
1575
+ .unwrap()
1576
+ .write_all(
1577
+ b"{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"t2\",\"status\":\"failed\"}}}\n",
1578
+ )
1579
+ .unwrap();
1580
+
1581
+ coordinator.tick().unwrap();
1582
+
1583
+ let events = read_test_events(&dir);
1584
+ let abnormal =
1585
+ find_event(&events, "worker.abnormal_exit").expect("fresh dead error notification");
1586
+ assert_eq!(abnormal["provider_process_dead"], serde_json::json!(true));
1587
+ assert_eq!(abnormal["latest_explicit_error"], serde_json::json!(true));
1588
+ assert_eq!(abnormal["error_recency"], serde_json::json!("fresh"));
1589
+ assert_eq!(
1590
+ abnormal["provider_process_dead_and_latest_explicit_error"],
1591
+ serde_json::json!(true)
1592
+ );
1593
+ }
1594
+
1595
+ fn abnormal_test_coordinator(dir: &std::path::Path) -> Coordinator {
1596
+ Coordinator::for_test(
1597
+ WorkspacePath::new(dir.to_path_buf()),
1598
+ Box::new(NormalRegistry),
1599
+ Box::new(
1600
+ crate::transport::test_support::OfflineTransport::new().with_session_present(true),
1601
+ ),
1602
+ None,
1603
+ None,
1604
+ )
1605
+ }
1606
+
1607
+ fn seed_abnormal_state(
1608
+ dir: &std::path::Path,
1609
+ rollout: &std::path::Path,
1610
+ liveness: &str,
1611
+ spawn_epoch: u64,
1612
+ ) {
1613
+ seed_abnormal_state_with_provider(dir, rollout, liveness, spawn_epoch, "codex");
1614
+ }
1615
+
1616
+ fn seed_abnormal_state_with_provider(
1617
+ dir: &std::path::Path,
1618
+ rollout: &std::path::Path,
1619
+ liveness: &str,
1620
+ spawn_epoch: u64,
1621
+ provider: &str,
1622
+ ) {
1623
+ crate::state::persist::save_runtime_state(
1624
+ dir,
1625
+ &serde_json::json!({
1626
+ "active_team_key": "team",
1627
+ "agents": {
1628
+ "w1": {
1629
+ "provider": provider,
1630
+ "status": "running",
1631
+ "agent_id": "w1",
1632
+ "session_id": "session-w1",
1633
+ "rollout_path": rollout.to_string_lossy(),
1634
+ "spawn_cwd": dir.to_string_lossy(),
1635
+ "spawn_epoch": spawn_epoch,
1636
+ "process_liveness": liveness
1637
+ }
1638
+ }
1639
+ }),
1640
+ )
1641
+ .unwrap();
1642
+ }
1643
+
1644
+ fn claude_assistant_api_error_line(
1645
+ uuid: &str,
1646
+ parent_uuid: &str,
1647
+ session_id: &str,
1648
+ status: i64,
1649
+ error: &str,
1650
+ request_id: Option<&str>,
1651
+ ) -> String {
1652
+ let mut record = serde_json::json!({
1653
+ "type": "assistant",
1654
+ "parentUuid": parent_uuid,
1655
+ "uuid": uuid,
1656
+ "message": {
1657
+ "role": "assistant",
1658
+ "content": [{"type": "text", "text": "API Error"}]
1659
+ },
1660
+ "error": error,
1661
+ "isApiErrorMessage": true,
1662
+ "apiErrorStatus": status,
1663
+ "sessionId": session_id,
1664
+ "version": "2.1.181"
1665
+ });
1666
+ if let Some(request_id) = request_id {
1667
+ record
1668
+ .as_object_mut()
1669
+ .unwrap()
1670
+ .insert("requestId".to_string(), serde_json::json!(request_id));
1671
+ }
1672
+ format!("{record}\n")
1673
+ }
1674
+
1675
+ fn claude_turn_duration_line(uuid: &str, parent_uuid: &str, session_id: &str) -> String {
1676
+ format!(
1677
+ "{}\n",
1678
+ serde_json::json!({
1679
+ "type": "system",
1680
+ "subtype": "turn_duration",
1681
+ "uuid": uuid,
1682
+ "parentUuid": parent_uuid,
1683
+ "sessionId": session_id
1684
+ })
1685
+ )
1686
+ }
1687
+
1688
+ fn claude_file_history_snapshot_line(session_id: &str) -> String {
1689
+ format!(
1690
+ "{}\n",
1691
+ serde_json::json!({
1692
+ "type": "file-history-snapshot",
1693
+ "sessionId": session_id
1694
+ })
1695
+ )
1696
+ }
1697
+
1698
+ fn claude_last_prompt_line(session_id: &str) -> String {
1699
+ format!(
1700
+ "{}\n",
1701
+ serde_json::json!({
1702
+ "type": "last-prompt",
1703
+ "sessionId": session_id,
1704
+ "prompt": "sanitized"
1705
+ })
1706
+ )
1707
+ }
1708
+
1709
+ fn claude_mode_line(session_id: &str) -> String {
1710
+ format!(
1711
+ "{}\n",
1712
+ serde_json::json!({
1713
+ "type": "mode",
1714
+ "sessionId": session_id,
1715
+ "mode": "default"
1716
+ })
1717
+ )
1718
+ }
1719
+
1720
+ fn abnormal_test_watch(dir: &std::path::Path) -> Value {
1721
+ crate::state::persist::load_runtime_state(dir)
1722
+ .unwrap()
1723
+ .pointer("/coordinator/abnormal_exit_watch/w1")
1724
+ .cloned()
1725
+ .unwrap()
1726
+ }
1727
+
1728
+ fn read_test_events(dir: &std::path::Path) -> Vec<Value> {
1729
+ let events_path = crate::model::paths::logs_dir(dir).join("events.jsonl");
1730
+ std::fs::read_to_string(events_path)
1731
+ .unwrap_or_default()
1732
+ .lines()
1733
+ .filter_map(|line| serde_json::from_str(line).ok())
1734
+ .collect()
1735
+ }
1736
+
1737
+ fn find_event(events: &[Value], name: &str) -> Option<Value> {
1738
+ events
1739
+ .iter()
1740
+ .rev()
1741
+ .find(|event| event.get("event").and_then(Value::as_str) == Some(name))
1742
+ .cloned()
1743
+ }
1744
+
1745
+ fn temp_abnormal_dir(tag: &str) -> std::path::PathBuf {
1746
+ let dir = std::env::temp_dir().join(format!(
1747
+ "team-agent-abnormal-{tag}-{}-{}",
1748
+ std::process::id(),
1749
+ std::time::SystemTime::now()
1750
+ .duration_since(std::time::UNIX_EPOCH)
1751
+ .unwrap()
1752
+ .as_nanos()
1753
+ ));
1754
+ std::fs::create_dir_all(&dir).unwrap();
1755
+ dir
1756
+ }
1757
+
1758
+ fn test_abnormal_agent(
1759
+ rollout_path: &str,
1760
+ spawn_epoch: Option<u64>,
1761
+ spawned_at: Option<&str>,
1762
+ ) -> AbnormalWatchAgent {
1763
+ AbnormalWatchAgent {
1764
+ agent_id: "w1".to_string(),
1765
+ provider: crate::provider::Provider::Codex,
1766
+ rollout_path: PathBuf::from(rollout_path),
1767
+ rollout_path_display: rollout_path.to_string(),
1768
+ spawn_epoch,
1769
+ spawned_at: spawned_at.map(str::to_string),
1770
+ status: Some("running".to_string()),
1771
+ process_liveness: Some(ProcessLiveness::Alive),
1772
+ window: None,
1773
+ pane_id: None,
1774
+ pid: None,
1775
+ current_command: None,
1776
+ }
1777
+ }
1778
+ }