@team-agent/installer 0.5.34 → 0.5.36

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.
@@ -167,7 +167,43 @@ pub(crate) fn detect_abnormal_exits(
167
167
  {
168
168
  continue;
169
169
  }
170
- let content = format_abnormal_exit_message(&team, &agent, &fact, &liveness, size);
170
+ // 0.5.36 (`.team/artifacts/supermarket-api-error-recovery-locate.md`
171
+ // §7.1/§7.6/§7.5): classify, update backpressure, record recovery
172
+ // intent (retryable only), then compose the policy-aware tail. This
173
+ // is INTENT WRITE only — actual lifecycle work happens post-save
174
+ // via `attempt_due_recoveries` (§7.3, R6 guard).
175
+ let manual_command = recovery_manual_command(
176
+ agent.agent_id.as_str(),
177
+ workspace,
178
+ team.as_str(),
179
+ );
180
+ let error_observation_key_string = error_observation_key.clone().unwrap_or_default();
181
+ let (recovery_class, recovery_schedule, recovery_cohort_key, recovery_backpressure_until) =
182
+ process_api_error_recovery_intent(
183
+ state,
184
+ event_log,
185
+ &team,
186
+ &agent,
187
+ &fact,
188
+ &manual_command,
189
+ error_observation_key_string.as_str(),
190
+ )?;
191
+ let recovery_tail = recovery_notification_tail(
192
+ recovery_class,
193
+ recovery_schedule.as_ref(),
194
+ recovery_backpressure_until,
195
+ &recovery_cohort_key,
196
+ &fact,
197
+ &manual_command,
198
+ );
199
+ let content = format_abnormal_exit_message(
200
+ &team,
201
+ &agent,
202
+ &fact,
203
+ &liveness,
204
+ size,
205
+ &recovery_tail,
206
+ );
171
207
  let outcome = crate::messaging::send_to_leader_receiver(
172
208
  workspace,
173
209
  state,
@@ -1091,6 +1127,7 @@ fn format_abnormal_exit_message(
1091
1127
  fact: &crate::provider::FaultFact,
1092
1128
  liveness: &ProcessCheck,
1093
1129
  size: u64,
1130
+ recovery_tail: &str,
1094
1131
  ) -> String {
1095
1132
  let turn_id = fact.turn_id.as_ref().map(|id| id.as_str()).unwrap_or("-");
1096
1133
  format!(
@@ -1104,7 +1141,7 @@ turn_id: {turn_id}\n\
1104
1141
  transcript: {path}\n\
1105
1142
  last_offset: {size}\n\
1106
1143
  pid_status: {pid_status}\n\n\
1107
- No automatic restart was performed.",
1144
+ {recovery_tail}",
1108
1145
  node = agent.agent_id.as_str(),
1109
1146
  provider = provider_wire(agent.provider),
1110
1147
  signature = fact.signature.as_str(),
@@ -1113,6 +1150,777 @@ No automatic restart was performed.",
1113
1150
  )
1114
1151
  }
1115
1152
 
1153
+ // ─────────────────────────────────────────────────────────────────────────
1154
+ // 0.5.36 (`.team/artifacts/supermarket-api-error-recovery-locate.md` §7-§8):
1155
+ // api_error recovery classifier + policy + state I/O + notification tail.
1156
+ // Recovery EXECUTION lives post-atomic_save in `attempt_due_recoveries`
1157
+ // below; `detect_abnormal_exits` only RECORDS intent (§7.3, R6 guard).
1158
+ // ─────────────────────────────────────────────────────────────────────────
1159
+
1160
+ /// 0.5.36 §7.1 classifier. Retryable = transient provider outage; recovery
1161
+ /// may be scheduled. NonRetryable = configuration / auth issue; guide only.
1162
+ /// Unknown = neither confirmed retryable nor known-bad; guide only.
1163
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1164
+ pub(crate) enum ApiErrorRecoveryClass {
1165
+ Retryable,
1166
+ NonRetryable,
1167
+ Unknown,
1168
+ }
1169
+
1170
+ impl ApiErrorRecoveryClass {
1171
+ fn as_str(self) -> &'static str {
1172
+ match self {
1173
+ Self::Retryable => "retryable",
1174
+ Self::NonRetryable => "non_retryable",
1175
+ Self::Unknown => "unknown",
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ /// 0.5.36 §7.1: rules keyed on status/error text.
1181
+ pub(crate) fn classify_api_error_recovery(
1182
+ signature: &str,
1183
+ api_error_status: Option<i64>,
1184
+ error: Option<&str>,
1185
+ ) -> ApiErrorRecoveryClass {
1186
+ if signature != "api_error" {
1187
+ return ApiErrorRecoveryClass::Unknown;
1188
+ }
1189
+ if let Some(status) = api_error_status {
1190
+ return match status {
1191
+ 408 | 429 => ApiErrorRecoveryClass::Retryable,
1192
+ s if (500..600).contains(&s) => ApiErrorRecoveryClass::Retryable,
1193
+ 400 | 401 | 403 | 404 => ApiErrorRecoveryClass::NonRetryable,
1194
+ _ => classify_error_text(error),
1195
+ };
1196
+ }
1197
+ classify_error_text(error)
1198
+ }
1199
+
1200
+ fn classify_error_text(error: Option<&str>) -> ApiErrorRecoveryClass {
1201
+ match error {
1202
+ Some(text) => {
1203
+ let lower = text.to_ascii_lowercase();
1204
+ if ["rate_limit", "overloaded", "timeout"]
1205
+ .iter()
1206
+ .any(|needle| lower.contains(needle))
1207
+ {
1208
+ ApiErrorRecoveryClass::Retryable
1209
+ } else if [
1210
+ "model_not_found",
1211
+ "auth",
1212
+ "invalid_request",
1213
+ "not_found_error",
1214
+ ]
1215
+ .iter()
1216
+ .any(|needle| lower.contains(needle))
1217
+ {
1218
+ ApiErrorRecoveryClass::NonRetryable
1219
+ } else {
1220
+ ApiErrorRecoveryClass::Unknown
1221
+ }
1222
+ }
1223
+ None => ApiErrorRecoveryClass::Unknown,
1224
+ }
1225
+ }
1226
+
1227
+ // 0.5.36 §7.2 defaults — code constants; a later config slice may expose
1228
+ // them without a new CLI flag.
1229
+ pub(crate) const RECOVERY_MAX_ATTEMPTS: u64 = 2;
1230
+ pub(crate) const RECOVERY_BACKOFF_SECS: [i64; 2] = [30, 120];
1231
+ pub(crate) const BACKPRESSURE_THRESHOLD: usize = 3;
1232
+ pub(crate) const BACKPRESSURE_WINDOW_SECS: i64 = 120;
1233
+ pub(crate) const BACKPRESSURE_COOLDOWN_SECS: i64 = 300;
1234
+
1235
+ fn now_utc() -> chrono::DateTime<chrono::Utc> {
1236
+ chrono::Utc::now()
1237
+ }
1238
+
1239
+ fn cohort_key_for(team: &str, provider: &str, fact: &crate::provider::FaultFact) -> String {
1240
+ let status = fact
1241
+ .api_error_status
1242
+ .map(|s| s.to_string())
1243
+ .unwrap_or_else(|| "-".to_string());
1244
+ let error = fact.error.as_deref().unwrap_or("-");
1245
+ format!(
1246
+ "{team}:{provider}:{signature}:{status}:{error}",
1247
+ signature = fact.signature.as_str()
1248
+ )
1249
+ }
1250
+
1251
+ /// 0.5.36 §7.2 manual command line. shell-single-quoted workspace so paths
1252
+ /// with spaces survive copy/paste (§7.5).
1253
+ fn recovery_manual_command(agent_id: &str, workspace: &Path, team: &str) -> String {
1254
+ let workspace_str = workspace.to_string_lossy();
1255
+ let shell_workspace = workspace_str.replace('\'', "'\\''");
1256
+ format!(
1257
+ "team-agent start-agent {agent_id} --workspace '{shell_workspace}' --team {team} --force --json"
1258
+ )
1259
+ }
1260
+
1261
+ fn recovery_intent_root<'a>(
1262
+ state: &'a mut Value,
1263
+ ) -> Option<&'a mut serde_json::Map<String, Value>> {
1264
+ coordinator_child_object(state, "abnormal_api_error_recovery")
1265
+ }
1266
+
1267
+ fn recovery_intent_agents<'a>(
1268
+ state: &'a mut Value,
1269
+ ) -> Option<&'a mut serde_json::Map<String, Value>> {
1270
+ let root = recovery_intent_root(state)?;
1271
+ let agents = root
1272
+ .entry("agents".to_string())
1273
+ .or_insert_with(|| serde_json::json!({}));
1274
+ if !agents.is_object() {
1275
+ *agents = serde_json::json!({});
1276
+ }
1277
+ agents.as_object_mut()
1278
+ }
1279
+
1280
+ fn recovery_backpressure<'a>(
1281
+ state: &'a mut Value,
1282
+ ) -> Option<&'a mut serde_json::Map<String, Value>> {
1283
+ let root = recovery_intent_root(state)?;
1284
+ let bp = root
1285
+ .entry("backpressure".to_string())
1286
+ .or_insert_with(|| serde_json::json!({}));
1287
+ if !bp.is_object() {
1288
+ *bp = serde_json::json!({});
1289
+ }
1290
+ bp.as_object_mut()
1291
+ }
1292
+
1293
+ /// 0.5.36 §7.6: increment the cohort counter, prune stale events outside the
1294
+ /// sliding window, and return the current active-cooldown state (if any) so
1295
+ /// the caller can decide canary vs deferral. Only fresh retryable errors get
1296
+ /// here — non-retryable/unknown never touch backpressure.
1297
+ fn record_backpressure_event(
1298
+ state: &mut Value,
1299
+ team: &str,
1300
+ provider: &str,
1301
+ fact: &crate::provider::FaultFact,
1302
+ agent_id: &str,
1303
+ now: chrono::DateTime<chrono::Utc>,
1304
+ ) -> BackpressureDecision {
1305
+ let cohort = cohort_key_for(team, provider, fact);
1306
+ let now_str = now.to_rfc3339();
1307
+ let window = chrono::Duration::seconds(BACKPRESSURE_WINDOW_SECS);
1308
+ let cooldown = chrono::Duration::seconds(BACKPRESSURE_COOLDOWN_SECS);
1309
+ let Some(bp) = recovery_backpressure(state) else {
1310
+ return BackpressureDecision {
1311
+ cooldown_until: None,
1312
+ just_activated: false,
1313
+ cohort_key: cohort,
1314
+ };
1315
+ };
1316
+ let entry = bp
1317
+ .entry(cohort.clone())
1318
+ .or_insert_with(|| serde_json::json!({}));
1319
+ let obj = match entry.as_object_mut() {
1320
+ Some(obj) => obj,
1321
+ None => {
1322
+ *entry = serde_json::json!({});
1323
+ entry.as_object_mut().expect("just replaced with object")
1324
+ }
1325
+ };
1326
+ let window_started = obj
1327
+ .get("window_started_at")
1328
+ .and_then(Value::as_str)
1329
+ .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1330
+ .map(|dt| dt.with_timezone(&chrono::Utc));
1331
+ let reset_window = window_started
1332
+ .map(|start| now.signed_duration_since(start) > window)
1333
+ .unwrap_or(true);
1334
+ if reset_window {
1335
+ obj.insert("window_started_at".to_string(), serde_json::json!(now_str));
1336
+ obj.insert("count".to_string(), serde_json::json!(0u64));
1337
+ obj.insert("agents".to_string(), serde_json::json!(Vec::<String>::new()));
1338
+ }
1339
+ obj.insert("last_seen_at".to_string(), serde_json::json!(now_str));
1340
+ let count = obj
1341
+ .get("count")
1342
+ .and_then(Value::as_u64)
1343
+ .unwrap_or(0)
1344
+ .saturating_add(1);
1345
+ obj.insert("count".to_string(), serde_json::json!(count));
1346
+ let agents_arr = obj
1347
+ .entry("agents".to_string())
1348
+ .or_insert_with(|| serde_json::json!([]));
1349
+ if let Some(list) = agents_arr.as_array_mut() {
1350
+ if !list.iter().any(|v| v.as_str() == Some(agent_id)) {
1351
+ list.push(serde_json::json!(agent_id));
1352
+ }
1353
+ }
1354
+ let previously_active = obj
1355
+ .get("cooldown_until")
1356
+ .and_then(Value::as_str)
1357
+ .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1358
+ .map(|dt| dt.with_timezone(&chrono::Utc) > now)
1359
+ .unwrap_or(false);
1360
+ let mut just_activated = false;
1361
+ let mut cooldown_until: Option<chrono::DateTime<chrono::Utc>> = None;
1362
+ if previously_active {
1363
+ cooldown_until = obj
1364
+ .get("cooldown_until")
1365
+ .and_then(Value::as_str)
1366
+ .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1367
+ .map(|dt| dt.with_timezone(&chrono::Utc));
1368
+ } else if (count as usize) >= BACKPRESSURE_THRESHOLD {
1369
+ let until = now + cooldown;
1370
+ obj.insert("cooldown_until".to_string(), serde_json::json!(until.to_rfc3339()));
1371
+ obj.insert("status".to_string(), serde_json::json!("active"));
1372
+ cooldown_until = Some(until);
1373
+ just_activated = true;
1374
+ }
1375
+ BackpressureDecision {
1376
+ cooldown_until,
1377
+ just_activated,
1378
+ cohort_key: cohort,
1379
+ }
1380
+ }
1381
+
1382
+ struct BackpressureDecision {
1383
+ cooldown_until: Option<chrono::DateTime<chrono::Utc>>,
1384
+ just_activated: bool,
1385
+ cohort_key: String,
1386
+ }
1387
+
1388
+ /// 0.5.36 §7.2: reserve or update the per-agent recovery intent. Returns the
1389
+ /// scheduled `next_retry_at` for the notification/event.
1390
+ fn schedule_recovery_intent(
1391
+ state: &mut Value,
1392
+ agent_id: &str,
1393
+ cohort_key: &str,
1394
+ error_key: &str,
1395
+ manual_command: &str,
1396
+ now: chrono::DateTime<chrono::Utc>,
1397
+ cooldown_until: Option<chrono::DateTime<chrono::Utc>>,
1398
+ ) -> Option<RecoveryIntentSchedule> {
1399
+ let Some(agents) = recovery_intent_agents(state) else {
1400
+ return None;
1401
+ };
1402
+ let existing = agents.get(agent_id).cloned();
1403
+ let attempts = existing
1404
+ .as_ref()
1405
+ .and_then(|v| v.get("attempts").and_then(Value::as_u64))
1406
+ .unwrap_or(0);
1407
+ if attempts >= RECOVERY_MAX_ATTEMPTS {
1408
+ return None;
1409
+ }
1410
+ let backoff = RECOVERY_BACKOFF_SECS
1411
+ .get(attempts as usize)
1412
+ .copied()
1413
+ .unwrap_or(*RECOVERY_BACKOFF_SECS.last().unwrap_or(&120));
1414
+ let mut next_retry = now + chrono::Duration::seconds(backoff);
1415
+ let mut backpressured = false;
1416
+ if let Some(until) = cooldown_until {
1417
+ if until > next_retry {
1418
+ next_retry = until;
1419
+ backpressured = true;
1420
+ }
1421
+ }
1422
+ let status = if backpressured {
1423
+ "backpressured"
1424
+ } else {
1425
+ "scheduled"
1426
+ };
1427
+ let payload = serde_json::json!({
1428
+ "error_key": error_key,
1429
+ "cohort_key": cohort_key,
1430
+ "status": status,
1431
+ "attempts": attempts,
1432
+ "max_attempts": RECOVERY_MAX_ATTEMPTS,
1433
+ "next_retry_at": next_retry.to_rfc3339(),
1434
+ "last_attempt_at": Value::Null,
1435
+ "last_error": Value::Null,
1436
+ "backoff_seconds": backoff,
1437
+ "manual_command": manual_command,
1438
+ });
1439
+ agents.insert(agent_id.to_string(), payload);
1440
+ Some(RecoveryIntentSchedule {
1441
+ next_retry_at: next_retry.to_rfc3339(),
1442
+ attempt: attempts,
1443
+ backoff_seconds: backoff,
1444
+ backpressured,
1445
+ })
1446
+ }
1447
+
1448
+ struct RecoveryIntentSchedule {
1449
+ next_retry_at: String,
1450
+ attempt: u64,
1451
+ backoff_seconds: i64,
1452
+ backpressured: bool,
1453
+ }
1454
+
1455
+ /// 0.5.36 §7.6: returns true when any agent already holds a `scheduled`
1456
+ /// recovery intent for the given cohort.
1457
+ fn has_active_canary_in_cohort(state: &Value, cohort_key: &str) -> bool {
1458
+ let Some(agents) = state
1459
+ .pointer("/coordinator/abnormal_api_error_recovery/agents")
1460
+ .and_then(Value::as_object)
1461
+ else {
1462
+ return false;
1463
+ };
1464
+ agents.values().any(|entry| {
1465
+ entry.get("status").and_then(Value::as_str) == Some("scheduled")
1466
+ && entry.get("cohort_key").and_then(Value::as_str) == Some(cohort_key)
1467
+ })
1468
+ }
1469
+
1470
+ fn recovery_intent_status(state: &Value, agent_id: &str) -> Option<String> {
1471
+ state
1472
+ .pointer(&format!(
1473
+ "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/status"
1474
+ ))
1475
+ .and_then(Value::as_str)
1476
+ .map(str::to_string)
1477
+ }
1478
+
1479
+ fn recovery_intent_field(state: &Value, agent_id: &str, field: &str) -> Option<Value> {
1480
+ state
1481
+ .pointer(&format!(
1482
+ "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/{field}"
1483
+ ))
1484
+ .cloned()
1485
+ }
1486
+
1487
+ /// 0.5.36 §7.5 policy-aware notification tail.
1488
+ fn recovery_notification_tail(
1489
+ class: ApiErrorRecoveryClass,
1490
+ schedule: Option<&RecoveryIntentSchedule>,
1491
+ backpressure_until: Option<chrono::DateTime<chrono::Utc>>,
1492
+ cohort_key: &str,
1493
+ fact: &crate::provider::FaultFact,
1494
+ manual_command: &str,
1495
+ ) -> String {
1496
+ let manual_line = format!("manual_recovery: {manual_command}");
1497
+ let backpressure_line = match backpressure_until {
1498
+ Some(until) => {
1499
+ let status = fact
1500
+ .api_error_status
1501
+ .map(|s| s.to_string())
1502
+ .unwrap_or_else(|| "-".to_string());
1503
+ format!(
1504
+ "backpressure: active cohort={cohort_key} status={status} until={until}",
1505
+ until = until.to_rfc3339()
1506
+ )
1507
+ }
1508
+ None => "backpressure: inactive".to_string(),
1509
+ };
1510
+ let auto_line = match class {
1511
+ ApiErrorRecoveryClass::Retryable => match schedule {
1512
+ Some(sched) if sched.backpressured => format!(
1513
+ "auto_recovery: delayed by provider backpressure until {}",
1514
+ sched.next_retry_at
1515
+ ),
1516
+ Some(sched) => format!(
1517
+ "auto_recovery: scheduled attempt {attempt}/{max} in {backoff}s (due {due})",
1518
+ attempt = sched.attempt + 1,
1519
+ max = RECOVERY_MAX_ATTEMPTS,
1520
+ backoff = sched.backoff_seconds,
1521
+ due = sched.next_retry_at,
1522
+ ),
1523
+ None => "auto_recovery: not scheduled (recovery_exhausted); use manual command below"
1524
+ .to_string(),
1525
+ },
1526
+ ApiErrorRecoveryClass::NonRetryable => {
1527
+ let error = fact.error.as_deref().unwrap_or("non_retryable");
1528
+ format!("auto_recovery: not scheduled (non_retryable_api_error: {error})")
1529
+ }
1530
+ ApiErrorRecoveryClass::Unknown => {
1531
+ "auto_recovery: not scheduled (unknown_api_error_class)".to_string()
1532
+ }
1533
+ };
1534
+ format!("{auto_line}\n{manual_line}\n{backpressure_line}")
1535
+ }
1536
+
1537
+ /// 0.5.36 §7 orchestration for a single fresh abnormal notification. Runs
1538
+ /// inside `detect_abnormal_exits`; only classifies + records intent +
1539
+ /// emits recovery-family events. Returns the classifier verdict, the
1540
+ /// scheduled intent (if any), the cohort key, and the active backpressure
1541
+ /// window (if any) so the caller can build the notification tail.
1542
+ ///
1543
+ /// R6 guard: this function must not call any lifecycle helpers. Actual
1544
+ /// lifecycle work runs post-atomic_save in `attempt_due_recoveries`.
1545
+ fn process_api_error_recovery_intent(
1546
+ state: &mut Value,
1547
+ event_log: &EventLog,
1548
+ team: &str,
1549
+ agent: &AbnormalWatchAgent,
1550
+ fact: &crate::provider::FaultFact,
1551
+ manual_command: &str,
1552
+ error_key: &str,
1553
+ ) -> Result<
1554
+ (
1555
+ ApiErrorRecoveryClass,
1556
+ Option<RecoveryIntentSchedule>,
1557
+ String,
1558
+ Option<chrono::DateTime<chrono::Utc>>,
1559
+ ),
1560
+ TickError,
1561
+ > {
1562
+ let class = classify_api_error_recovery(
1563
+ fact.signature.as_str(),
1564
+ fact.api_error_status,
1565
+ fact.error.as_deref(),
1566
+ );
1567
+ let provider_str = provider_wire(agent.provider);
1568
+ let cohort_key = cohort_key_for(team, provider_str, fact);
1569
+ if !matches!(class, ApiErrorRecoveryClass::Retryable) {
1570
+ return Ok((class, None, cohort_key, None));
1571
+ }
1572
+ let now = now_utc();
1573
+ let cohort_hint = cohort_key_for(team, provider_str, fact);
1574
+ let canary_active = has_active_canary_in_cohort(state, &cohort_hint);
1575
+ let mut bp_decision = record_backpressure_event(
1576
+ state,
1577
+ team,
1578
+ provider_str,
1579
+ fact,
1580
+ agent.agent_id.as_str(),
1581
+ now,
1582
+ );
1583
+ // 0.5.36 §7.6: "at most one canary". If another agent in the same
1584
+ // cohort already holds a scheduled intent, defer the new arrival to
1585
+ // the cohort cooldown (or a synthetic short cooldown if none yet).
1586
+ if canary_active {
1587
+ let synthetic = bp_decision.cooldown_until.unwrap_or_else(|| {
1588
+ now + chrono::Duration::seconds(BACKPRESSURE_COOLDOWN_SECS)
1589
+ });
1590
+ bp_decision.cooldown_until = Some(synthetic);
1591
+ }
1592
+ if bp_decision.just_activated {
1593
+ let bp_agents = state
1594
+ .pointer(&format!(
1595
+ "/coordinator/abnormal_api_error_recovery/backpressure/{}/agents",
1596
+ bp_decision.cohort_key
1597
+ ))
1598
+ .cloned()
1599
+ .unwrap_or_else(|| Value::Array(Vec::new()));
1600
+ let cooldown_until_str = bp_decision
1601
+ .cooldown_until
1602
+ .map(|dt| dt.to_rfc3339())
1603
+ .unwrap_or_default();
1604
+ event_log.write(
1605
+ "worker.abnormal_exit.backpressure_started",
1606
+ serde_json::json!({
1607
+ "team_id": team,
1608
+ "provider": provider_str,
1609
+ "signature": fact.signature.as_str(),
1610
+ "apiErrorStatus": fact.api_error_status,
1611
+ "error": fact.error.as_deref(),
1612
+ "cohort_key": bp_decision.cohort_key,
1613
+ "threshold": BACKPRESSURE_THRESHOLD,
1614
+ "window_seconds": BACKPRESSURE_WINDOW_SECS,
1615
+ "cooldown_until": cooldown_until_str,
1616
+ "agents": bp_agents,
1617
+ }),
1618
+ )?;
1619
+ }
1620
+ let schedule = schedule_recovery_intent(
1621
+ state,
1622
+ agent.agent_id.as_str(),
1623
+ &bp_decision.cohort_key,
1624
+ error_key,
1625
+ manual_command,
1626
+ now,
1627
+ bp_decision.cooldown_until,
1628
+ );
1629
+ if let Some(sched) = schedule.as_ref() {
1630
+ if sched.backpressured {
1631
+ // Backpressured workers are not scheduled for the canary window;
1632
+ // emit a distinct `backpressure_active` event so the leader
1633
+ // notification tail can trace which cohort deferred which agent.
1634
+ event_log.write(
1635
+ "worker.abnormal_exit.backpressure_active",
1636
+ serde_json::json!({
1637
+ "team_id": team,
1638
+ "agent_id": agent.agent_id.as_str(),
1639
+ "cohort_key": bp_decision.cohort_key,
1640
+ "cooldown_until": bp_decision
1641
+ .cooldown_until
1642
+ .map(|dt| dt.to_rfc3339())
1643
+ .unwrap_or_default(),
1644
+ "action": "deferred_recovery",
1645
+ "manual_command": manual_command,
1646
+ }),
1647
+ )?;
1648
+ } else {
1649
+ event_log.write(
1650
+ "worker.abnormal_exit.recovery_scheduled",
1651
+ serde_json::json!({
1652
+ "team_id": team,
1653
+ "agent_id": agent.agent_id.as_str(),
1654
+ "provider": provider_str,
1655
+ "signature": fact.signature.as_str(),
1656
+ "apiErrorStatus": fact.api_error_status,
1657
+ "error": fact.error.as_deref(),
1658
+ "attempt": sched.attempt,
1659
+ "max_attempts": RECOVERY_MAX_ATTEMPTS,
1660
+ "due_at": sched.next_retry_at,
1661
+ "backoff_seconds": sched.backoff_seconds,
1662
+ "error_key": error_key,
1663
+ "cohort_key": bp_decision.cohort_key,
1664
+ "backpressured": false,
1665
+ "manual_command": manual_command,
1666
+ }),
1667
+ )?;
1668
+ }
1669
+ } else {
1670
+ // schedule_recovery_intent returns None ONLY when max_attempts hit.
1671
+ event_log.write(
1672
+ "worker.abnormal_exit.recovery_exhausted",
1673
+ serde_json::json!({
1674
+ "team_id": team,
1675
+ "agent_id": agent.agent_id.as_str(),
1676
+ "attempts": RECOVERY_MAX_ATTEMPTS,
1677
+ "last_error": fact.error.as_deref(),
1678
+ "manual_command": manual_command,
1679
+ }),
1680
+ )?;
1681
+ }
1682
+ Ok((class, schedule, bp_decision.cohort_key, bp_decision.cooldown_until))
1683
+ }
1684
+
1685
+ // ─────────────────────────────────────────────────────────────────────────
1686
+ // 0.5.36 §7.3 recovery execution — runs post-atomic_save, reloads fresh
1687
+ // state, consumes due intents, invokes the lifecycle `start_agent_at_paths`
1688
+ // with force=true (stop-before-start semantics for live panes, so noop is
1689
+ // impossible), and writes the outcome back to state via the lifecycle
1690
+ // path (which owns its own save).
1691
+ // ─────────────────────────────────────────────────────────────────────────
1692
+
1693
+ /// 0.5.36 §7.3: process all due recovery intents. Called from tick.rs
1694
+ /// AFTER atomic_save has flushed the detector-written intent. Reloads a
1695
+ /// fresh state each call so the caller's stale in-memory state cannot
1696
+ /// clobber lifecycle writes. Best-effort: recovery failure produces
1697
+ /// events + updated intent, never a tick failure.
1698
+ pub(crate) fn attempt_due_recoveries(
1699
+ workspace: &Path,
1700
+ event_log: &EventLog,
1701
+ transport: &dyn crate::transport::Transport,
1702
+ ) {
1703
+ let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
1704
+ return;
1705
+ };
1706
+ let due_agents = collect_due_recovery_agents(&state);
1707
+ for agent_id in due_agents {
1708
+ run_single_recovery(workspace, event_log, transport, &agent_id);
1709
+ }
1710
+ }
1711
+
1712
+ fn collect_due_recovery_agents(state: &Value) -> Vec<String> {
1713
+ let now = now_utc();
1714
+ let Some(agents) = state
1715
+ .pointer("/coordinator/abnormal_api_error_recovery/agents")
1716
+ .and_then(Value::as_object)
1717
+ else {
1718
+ return Vec::new();
1719
+ };
1720
+ let mut due = Vec::new();
1721
+ for (agent_id, intent) in agents {
1722
+ let status = intent
1723
+ .get("status")
1724
+ .and_then(Value::as_str)
1725
+ .unwrap_or("");
1726
+ if !matches!(status, "scheduled") {
1727
+ continue;
1728
+ }
1729
+ let Some(next_retry_at) = intent
1730
+ .get("next_retry_at")
1731
+ .and_then(Value::as_str)
1732
+ .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1733
+ .map(|dt| dt.with_timezone(&chrono::Utc))
1734
+ else {
1735
+ continue;
1736
+ };
1737
+ if next_retry_at <= now {
1738
+ due.push(agent_id.clone());
1739
+ }
1740
+ }
1741
+ due
1742
+ }
1743
+
1744
+ fn run_single_recovery(
1745
+ workspace: &Path,
1746
+ event_log: &EventLog,
1747
+ transport: &dyn crate::transport::Transport,
1748
+ agent_id: &str,
1749
+ ) {
1750
+ // Read the current intent so we can bump attempts / write result fields
1751
+ // through the shared writer (`write_recovery_intent_field`); the actual
1752
+ // lifecycle helper below owns its own state save.
1753
+ let Ok(state_before) = crate::state::persist::load_runtime_state(workspace) else {
1754
+ return;
1755
+ };
1756
+ let team_key = crate::state::projection::team_state_key(&state_before);
1757
+ let attempt = state_before
1758
+ .pointer(&format!(
1759
+ "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/attempts"
1760
+ ))
1761
+ .and_then(Value::as_u64)
1762
+ .unwrap_or(0);
1763
+ let manual_command = state_before
1764
+ .pointer(&format!(
1765
+ "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/manual_command"
1766
+ ))
1767
+ .and_then(Value::as_str)
1768
+ .map(str::to_string)
1769
+ .unwrap_or_else(|| recovery_manual_command(agent_id, workspace, team_key.as_str()));
1770
+ let _ = event_log.write(
1771
+ "worker.abnormal_exit.recovery_started",
1772
+ serde_json::json!({
1773
+ "team_id": team_key.as_str(),
1774
+ "agent_id": agent_id,
1775
+ "attempt": attempt,
1776
+ "mode": "start_agent_force",
1777
+ "allow_fresh": false,
1778
+ }),
1779
+ );
1780
+ let agent_id_typed = crate::model::ids::AgentId::new(agent_id.to_string());
1781
+ let outcome = crate::lifecycle::restart::start_agent_at_paths_for_recovery(
1782
+ workspace,
1783
+ &agent_id_typed,
1784
+ Some(team_key.as_str()),
1785
+ transport,
1786
+ );
1787
+ let now = now_utc();
1788
+ match outcome {
1789
+ Ok(recovery_outcome) => {
1790
+ let _ = event_log.write(
1791
+ "worker.abnormal_exit.recovery_succeeded",
1792
+ serde_json::json!({
1793
+ "team_id": team_key.as_str(),
1794
+ "agent_id": agent_id,
1795
+ "start_mode": recovery_outcome.start_mode,
1796
+ "target": recovery_outcome.target,
1797
+ "coordinator_started": recovery_outcome.coordinator_started,
1798
+ }),
1799
+ );
1800
+ write_recovery_intent_result(
1801
+ workspace,
1802
+ agent_id,
1803
+ RecoveryIntentUpdate {
1804
+ status: "succeeded",
1805
+ attempts: attempt + 1,
1806
+ last_attempt_at: now.to_rfc3339(),
1807
+ last_error: None,
1808
+ blocked_reason: None,
1809
+ },
1810
+ );
1811
+ }
1812
+ Err(RecoveryError::NoopBlocked) => {
1813
+ let _ = event_log.write(
1814
+ "worker.abnormal_exit.recovery_blocked",
1815
+ serde_json::json!({
1816
+ "team_id": team_key.as_str(),
1817
+ "agent_id": agent_id,
1818
+ "reason": "noop_not_recovery",
1819
+ "manual_command": manual_command,
1820
+ }),
1821
+ );
1822
+ write_recovery_intent_result(
1823
+ workspace,
1824
+ agent_id,
1825
+ RecoveryIntentUpdate {
1826
+ status: "blocked",
1827
+ attempts: attempt + 1,
1828
+ last_attempt_at: now.to_rfc3339(),
1829
+ last_error: None,
1830
+ blocked_reason: Some("noop_not_recovery"),
1831
+ },
1832
+ );
1833
+ }
1834
+ Err(RecoveryError::Lifecycle(reason)) => {
1835
+ let _ = event_log.write(
1836
+ "worker.abnormal_exit.recovery_blocked",
1837
+ serde_json::json!({
1838
+ "team_id": team_key.as_str(),
1839
+ "agent_id": agent_id,
1840
+ "reason": "lifecycle_error",
1841
+ "detail": reason,
1842
+ "manual_command": manual_command,
1843
+ }),
1844
+ );
1845
+ write_recovery_intent_result(
1846
+ workspace,
1847
+ agent_id,
1848
+ RecoveryIntentUpdate {
1849
+ status: "blocked",
1850
+ attempts: attempt + 1,
1851
+ last_attempt_at: now.to_rfc3339(),
1852
+ last_error: Some(reason),
1853
+ blocked_reason: Some("lifecycle_error"),
1854
+ },
1855
+ );
1856
+ }
1857
+ }
1858
+ }
1859
+
1860
+ pub(crate) enum RecoveryError {
1861
+ NoopBlocked,
1862
+ Lifecycle(String),
1863
+ }
1864
+
1865
+ /// 0.5.36 §7.3 typed outcome returned from
1866
+ /// `lifecycle::restart::start_agent_at_paths_for_recovery` to the post-save
1867
+ /// step. Small on purpose — just the fields the `recovery_succeeded` event
1868
+ /// needs.
1869
+ pub(crate) struct RecoveryLifecycleOutcome {
1870
+ pub start_mode: String,
1871
+ pub target: String,
1872
+ pub coordinator_started: bool,
1873
+ }
1874
+
1875
+ struct RecoveryIntentUpdate {
1876
+ status: &'static str,
1877
+ attempts: u64,
1878
+ last_attempt_at: String,
1879
+ last_error: Option<String>,
1880
+ blocked_reason: Option<&'static str>,
1881
+ }
1882
+
1883
+ fn write_recovery_intent_result(workspace: &Path, agent_id: &str, update: RecoveryIntentUpdate) {
1884
+ let Ok(mut state) = crate::state::persist::load_runtime_state(workspace) else {
1885
+ return;
1886
+ };
1887
+ if let Some(agents) = recovery_intent_agents(&mut state) {
1888
+ let entry = agents
1889
+ .entry(agent_id.to_string())
1890
+ .or_insert_with(|| serde_json::json!({}));
1891
+ if !entry.is_object() {
1892
+ *entry = serde_json::json!({});
1893
+ }
1894
+ if let Some(obj) = entry.as_object_mut() {
1895
+ obj.insert("status".to_string(), serde_json::json!(update.status));
1896
+ obj.insert("attempts".to_string(), serde_json::json!(update.attempts));
1897
+ obj.insert(
1898
+ "last_attempt_at".to_string(),
1899
+ serde_json::json!(update.last_attempt_at),
1900
+ );
1901
+ obj.insert(
1902
+ "last_error".to_string(),
1903
+ match update.last_error.as_ref() {
1904
+ Some(text) => serde_json::json!(text),
1905
+ None => Value::Null,
1906
+ },
1907
+ );
1908
+ match update.blocked_reason {
1909
+ Some(reason) => {
1910
+ obj.insert(
1911
+ "blocked_reason".to_string(),
1912
+ serde_json::json!(reason),
1913
+ );
1914
+ }
1915
+ None => {
1916
+ obj.remove("blocked_reason");
1917
+ }
1918
+ }
1919
+ }
1920
+ }
1921
+ let _ = crate::state::persist::save_runtime_state(workspace, &state);
1922
+ }
1923
+
1116
1924
  #[cfg(test)]
1117
1925
  #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1118
1926
  mod tests {