@team-agent/installer 0.5.62 → 0.5.63

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.
@@ -178,34 +178,19 @@ pub(crate) fn detect_abnormal_exits(
178
178
  {
179
179
  continue;
180
180
  }
181
- // 0.5.36 (`.team/artifacts/supermarket-api-error-recovery-locate.md`
182
- // §7.1/§7.6/§7.5): classify, update backpressure, record recovery
183
- // intent (retryable only), then compose the policy-aware tail. This
184
- // is INTENT WRITE only — actual lifecycle work happens post-save
185
- // via `attempt_due_recoveries` (§7.3, R6 guard).
186
181
  let manual_command =
187
182
  recovery_manual_command(agent.agent_id.as_str(), workspace, team.as_str());
188
- let error_observation_key_string = error_observation_key.clone().unwrap_or_default();
189
- let (recovery_class, recovery_schedule, recovery_cohort_key, recovery_backpressure_until) =
190
- process_api_error_recovery_intent(
191
- state,
192
- event_log,
193
- &team,
194
- &agent,
195
- &fact,
196
- &manual_command,
197
- error_observation_key_string.as_str(),
198
- )?;
199
- let recovery_tail = recovery_notification_tail(
200
- recovery_class,
201
- recovery_schedule.as_ref(),
202
- recovery_backpressure_until,
203
- &recovery_cohort_key,
183
+ let manual_recovery_hint = format!(
184
+ "No automatic restart was performed.\nTo recover manually, run: {manual_command}"
185
+ );
186
+ let content = format_abnormal_exit_message(
187
+ &team,
188
+ &agent,
204
189
  &fact,
205
- &manual_command,
190
+ &liveness,
191
+ size,
192
+ &manual_recovery_hint,
206
193
  );
207
- let content =
208
- format_abnormal_exit_message(&team, &agent, &fact, &liveness, size, &recovery_tail);
209
194
  let outcome = crate::messaging::send_to_leader_receiver(
210
195
  workspace,
211
196
  state,
@@ -1300,7 +1285,7 @@ fn format_abnormal_exit_message(
1300
1285
  fact: &crate::provider::FaultFact,
1301
1286
  liveness: &ProcessCheck,
1302
1287
  size: u64,
1303
- recovery_tail: &str,
1288
+ manual_recovery_hint: &str,
1304
1289
  ) -> String {
1305
1290
  let turn_id = fact.turn_id.as_ref().map(|id| id.as_str()).unwrap_or("-");
1306
1291
  format!(
@@ -1314,7 +1299,7 @@ turn_id: {turn_id}\n\
1314
1299
  transcript: {path}\n\
1315
1300
  last_offset: {size}\n\
1316
1301
  pid_status: {pid_status}\n\n\
1317
- {recovery_tail}",
1302
+ {manual_recovery_hint}",
1318
1303
  node = agent.agent_id.as_str(),
1319
1304
  provider = provider_wire(agent.provider),
1320
1305
  signature = fact.signature.as_str(),
@@ -1323,107 +1308,6 @@ pid_status: {pid_status}\n\n\
1323
1308
  )
1324
1309
  }
1325
1310
 
1326
- // ─────────────────────────────────────────────────────────────────────────
1327
- // 0.5.36 (`.team/artifacts/supermarket-api-error-recovery-locate.md` §7-§8):
1328
- // api_error recovery classifier + policy + state I/O + notification tail.
1329
- // Recovery EXECUTION lives post-atomic_save in `attempt_due_recoveries`
1330
- // below; `detect_abnormal_exits` only RECORDS intent (§7.3, R6 guard).
1331
- // ─────────────────────────────────────────────────────────────────────────
1332
-
1333
- /// 0.5.36 §7.1 classifier. Retryable = transient provider outage; recovery
1334
- /// may be scheduled. NonRetryable = configuration / auth issue; guide only.
1335
- /// Unknown = neither confirmed retryable nor known-bad; guide only.
1336
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1337
- pub(crate) enum ApiErrorRecoveryClass {
1338
- Retryable,
1339
- NonRetryable,
1340
- Unknown,
1341
- }
1342
-
1343
- impl ApiErrorRecoveryClass {
1344
- fn as_str(self) -> &'static str {
1345
- match self {
1346
- Self::Retryable => "retryable",
1347
- Self::NonRetryable => "non_retryable",
1348
- Self::Unknown => "unknown",
1349
- }
1350
- }
1351
- }
1352
-
1353
- ///
1354
- /// 0.5.36 §7.1: rules keyed on status/error text.
1355
- pub(crate) fn classify_api_error_recovery(
1356
- signature: &str,
1357
- api_error_status: Option<i64>,
1358
- error: Option<&str>,
1359
- ) -> ApiErrorRecoveryClass {
1360
- if signature != "api_error" {
1361
- return ApiErrorRecoveryClass::Unknown;
1362
- }
1363
- if let Some(status) = api_error_status {
1364
- return match status {
1365
- 408 | 429 => ApiErrorRecoveryClass::Retryable,
1366
- s if (500..600).contains(&s) => ApiErrorRecoveryClass::Retryable,
1367
- 400 | 401 | 403 | 404 => ApiErrorRecoveryClass::NonRetryable,
1368
- _ => classify_error_text(error),
1369
- };
1370
- }
1371
- classify_error_text(error)
1372
- }
1373
-
1374
- fn classify_error_text(error: Option<&str>) -> ApiErrorRecoveryClass {
1375
- match error {
1376
- Some(text) => {
1377
- let lower = text.to_ascii_lowercase();
1378
- if ["rate_limit", "overloaded", "timeout"]
1379
- .iter()
1380
- .any(|needle| lower.contains(needle))
1381
- {
1382
- ApiErrorRecoveryClass::Retryable
1383
- } else if [
1384
- "model_not_found",
1385
- "auth",
1386
- "invalid_request",
1387
- "not_found_error",
1388
- ]
1389
- .iter()
1390
- .any(|needle| lower.contains(needle))
1391
- {
1392
- ApiErrorRecoveryClass::NonRetryable
1393
- } else {
1394
- ApiErrorRecoveryClass::Unknown
1395
- }
1396
- }
1397
- None => ApiErrorRecoveryClass::Unknown,
1398
- }
1399
- }
1400
-
1401
- // 0.5.36 §7.2 defaults — code constants; a later config slice may expose
1402
- // them without a new CLI flag.
1403
- pub(crate) const RECOVERY_MAX_ATTEMPTS: u64 = 2;
1404
- pub(crate) const RECOVERY_BACKOFF_SECS: [i64; 2] = [30, 120];
1405
- pub(crate) const BACKPRESSURE_THRESHOLD: usize = 3;
1406
- pub(crate) const BACKPRESSURE_WINDOW_SECS: i64 = 120;
1407
- pub(crate) const BACKPRESSURE_COOLDOWN_SECS: i64 = 300;
1408
-
1409
- fn now_utc() -> chrono::DateTime<chrono::Utc> {
1410
- chrono::Utc::now()
1411
- }
1412
-
1413
- fn cohort_key_for(team: &str, provider: &str, fact: &crate::provider::FaultFact) -> String {
1414
- let status = fact
1415
- .api_error_status
1416
- .map(|s| s.to_string())
1417
- .unwrap_or_else(|| "-".to_string());
1418
- let error = fact.error.as_deref().unwrap_or("-");
1419
- format!(
1420
- "{team}:{provider}:{signature}:{status}:{error}",
1421
- signature = fact.signature.as_str()
1422
- )
1423
- }
1424
-
1425
- /// 0.5.36 §7.2 manual command line. shell-single-quoted workspace so paths
1426
- /// with spaces survive copy/paste (§7.5).
1427
1311
  fn recovery_manual_command(agent_id: &str, workspace: &Path, team: &str) -> String {
1428
1312
  let workspace_str = workspace.to_string_lossy();
1429
1313
  let shell_workspace = workspace_str.replace('\'', "'\\''");
@@ -1432,742 +1316,6 @@ fn recovery_manual_command(agent_id: &str, workspace: &Path, team: &str) -> Stri
1432
1316
  )
1433
1317
  }
1434
1318
 
1435
- fn recovery_intent_root<'a>(
1436
- state: &'a mut Value,
1437
- ) -> Option<&'a mut serde_json::Map<String, Value>> {
1438
- coordinator_child_object(state, "abnormal_api_error_recovery")
1439
- }
1440
-
1441
- fn recovery_intent_agents<'a>(
1442
- state: &'a mut Value,
1443
- ) -> Option<&'a mut serde_json::Map<String, Value>> {
1444
- let root = recovery_intent_root(state)?;
1445
- let agents = root
1446
- .entry("agents".to_string())
1447
- .or_insert_with(|| serde_json::json!({}));
1448
- if !agents.is_object() {
1449
- *agents = serde_json::json!({});
1450
- }
1451
- agents.as_object_mut()
1452
- }
1453
-
1454
- fn recovery_backpressure<'a>(
1455
- state: &'a mut Value,
1456
- ) -> Option<&'a mut serde_json::Map<String, Value>> {
1457
- let root = recovery_intent_root(state)?;
1458
- let bp = root
1459
- .entry("backpressure".to_string())
1460
- .or_insert_with(|| serde_json::json!({}));
1461
- if !bp.is_object() {
1462
- *bp = serde_json::json!({});
1463
- }
1464
- bp.as_object_mut()
1465
- }
1466
-
1467
- /// 0.5.36 §7.6: increment the cohort counter, prune stale events outside the
1468
- /// sliding window, and return the current active-cooldown state (if any) so
1469
- /// the caller can decide canary vs deferral. Only fresh retryable errors get
1470
- /// here — non-retryable/unknown never touch backpressure.
1471
- fn record_backpressure_event(
1472
- state: &mut Value,
1473
- team: &str,
1474
- provider: &str,
1475
- fact: &crate::provider::FaultFact,
1476
- agent_id: &str,
1477
- now: chrono::DateTime<chrono::Utc>,
1478
- ) -> BackpressureDecision {
1479
- let cohort = cohort_key_for(team, provider, fact);
1480
- let now_str = now.to_rfc3339();
1481
- let window = chrono::Duration::seconds(BACKPRESSURE_WINDOW_SECS);
1482
- let cooldown = chrono::Duration::seconds(BACKPRESSURE_COOLDOWN_SECS);
1483
- let Some(bp) = recovery_backpressure(state) else {
1484
- return BackpressureDecision {
1485
- cooldown_until: None,
1486
- just_activated: false,
1487
- cohort_key: cohort,
1488
- };
1489
- };
1490
- let entry = bp
1491
- .entry(cohort.clone())
1492
- .or_insert_with(|| serde_json::json!({}));
1493
- let obj = match entry.as_object_mut() {
1494
- Some(obj) => obj,
1495
- None => {
1496
- *entry = serde_json::json!({});
1497
- entry.as_object_mut().expect("just replaced with object")
1498
- }
1499
- };
1500
- let window_started = obj
1501
- .get("window_started_at")
1502
- .and_then(Value::as_str)
1503
- .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1504
- .map(|dt| dt.with_timezone(&chrono::Utc));
1505
- let reset_window = window_started
1506
- .map(|start| now.signed_duration_since(start) > window)
1507
- .unwrap_or(true);
1508
- if reset_window {
1509
- obj.insert("window_started_at".to_string(), serde_json::json!(now_str));
1510
- obj.insert("count".to_string(), serde_json::json!(0u64));
1511
- obj.insert(
1512
- "agents".to_string(),
1513
- serde_json::json!(Vec::<String>::new()),
1514
- );
1515
- }
1516
- obj.insert("last_seen_at".to_string(), serde_json::json!(now_str));
1517
- let count = obj
1518
- .get("count")
1519
- .and_then(Value::as_u64)
1520
- .unwrap_or(0)
1521
- .saturating_add(1);
1522
- obj.insert("count".to_string(), serde_json::json!(count));
1523
- let agents_arr = obj
1524
- .entry("agents".to_string())
1525
- .or_insert_with(|| serde_json::json!([]));
1526
- if let Some(list) = agents_arr.as_array_mut() {
1527
- if !list.iter().any(|v| v.as_str() == Some(agent_id)) {
1528
- list.push(serde_json::json!(agent_id));
1529
- }
1530
- }
1531
- let previously_active = obj
1532
- .get("cooldown_until")
1533
- .and_then(Value::as_str)
1534
- .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1535
- .map(|dt| dt.with_timezone(&chrono::Utc) > now)
1536
- .unwrap_or(false);
1537
- let mut just_activated = false;
1538
- let mut cooldown_until: Option<chrono::DateTime<chrono::Utc>> = None;
1539
- if previously_active {
1540
- cooldown_until = obj
1541
- .get("cooldown_until")
1542
- .and_then(Value::as_str)
1543
- .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1544
- .map(|dt| dt.with_timezone(&chrono::Utc));
1545
- } else if (count as usize) >= BACKPRESSURE_THRESHOLD {
1546
- let until = now + cooldown;
1547
- obj.insert(
1548
- "cooldown_until".to_string(),
1549
- serde_json::json!(until.to_rfc3339()),
1550
- );
1551
- obj.insert("status".to_string(), serde_json::json!("active"));
1552
- cooldown_until = Some(until);
1553
- just_activated = true;
1554
- }
1555
- BackpressureDecision {
1556
- cooldown_until,
1557
- just_activated,
1558
- cohort_key: cohort,
1559
- }
1560
- }
1561
-
1562
- struct BackpressureDecision {
1563
- cooldown_until: Option<chrono::DateTime<chrono::Utc>>,
1564
- just_activated: bool,
1565
- cohort_key: String,
1566
- }
1567
-
1568
- /// 0.5.36 §7.2: reserve or update the per-agent recovery intent. Returns the
1569
- /// scheduled `next_retry_at` for the notification/event.
1570
- fn schedule_recovery_intent(
1571
- state: &mut Value,
1572
- agent_id: &str,
1573
- cohort_key: &str,
1574
- error_key: &str,
1575
- manual_command: &str,
1576
- now: chrono::DateTime<chrono::Utc>,
1577
- cooldown_until: Option<chrono::DateTime<chrono::Utc>>,
1578
- ) -> Option<RecoveryIntentSchedule> {
1579
- let Some(agents) = recovery_intent_agents(state) else {
1580
- return None;
1581
- };
1582
- let existing = agents.get(agent_id).cloned();
1583
- let attempts = existing
1584
- .as_ref()
1585
- .and_then(|v| v.get("attempts").and_then(Value::as_u64))
1586
- .unwrap_or(0);
1587
- if attempts >= RECOVERY_MAX_ATTEMPTS {
1588
- return None;
1589
- }
1590
- let backoff = RECOVERY_BACKOFF_SECS
1591
- .get(attempts as usize)
1592
- .copied()
1593
- .unwrap_or(*RECOVERY_BACKOFF_SECS.last().unwrap_or(&120));
1594
- let mut next_retry = now + chrono::Duration::seconds(backoff);
1595
- let mut backpressured = false;
1596
- if let Some(until) = cooldown_until {
1597
- if until > next_retry {
1598
- next_retry = until;
1599
- backpressured = true;
1600
- }
1601
- }
1602
- let status = if backpressured {
1603
- "backpressured"
1604
- } else {
1605
- "scheduled"
1606
- };
1607
- let payload = serde_json::json!({
1608
- "error_key": error_key,
1609
- "cohort_key": cohort_key,
1610
- "status": status,
1611
- "attempts": attempts,
1612
- "max_attempts": RECOVERY_MAX_ATTEMPTS,
1613
- "next_retry_at": next_retry.to_rfc3339(),
1614
- "last_attempt_at": Value::Null,
1615
- "last_error": Value::Null,
1616
- "backoff_seconds": backoff,
1617
- "manual_command": manual_command,
1618
- });
1619
- agents.insert(agent_id.to_string(), payload);
1620
- Some(RecoveryIntentSchedule {
1621
- next_retry_at: next_retry.to_rfc3339(),
1622
- attempt: attempts,
1623
- backoff_seconds: backoff,
1624
- backpressured,
1625
- })
1626
- }
1627
-
1628
- struct RecoveryIntentSchedule {
1629
- next_retry_at: String,
1630
- attempt: u64,
1631
- backoff_seconds: i64,
1632
- backpressured: bool,
1633
- }
1634
-
1635
- /// 0.5.36 §7.6: returns true when any agent already holds a `scheduled`
1636
- /// recovery intent for the given cohort.
1637
- fn has_active_canary_in_cohort(state: &Value, cohort_key: &str) -> bool {
1638
- let Some(agents) = state
1639
- .pointer("/coordinator/abnormal_api_error_recovery/agents")
1640
- .and_then(Value::as_object)
1641
- else {
1642
- return false;
1643
- };
1644
- agents.values().any(|entry| {
1645
- entry.get("status").and_then(Value::as_str) == Some("scheduled")
1646
- && entry.get("cohort_key").and_then(Value::as_str) == Some(cohort_key)
1647
- })
1648
- }
1649
-
1650
- fn recovery_intent_status(state: &Value, agent_id: &str) -> Option<String> {
1651
- state
1652
- .pointer(&format!(
1653
- "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/status"
1654
- ))
1655
- .and_then(Value::as_str)
1656
- .map(str::to_string)
1657
- }
1658
-
1659
- fn recovery_intent_field(state: &Value, agent_id: &str, field: &str) -> Option<Value> {
1660
- state
1661
- .pointer(&format!(
1662
- "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/{field}"
1663
- ))
1664
- .cloned()
1665
- }
1666
-
1667
- /// 0.5.36 §7.5 policy-aware notification tail.
1668
- fn recovery_notification_tail(
1669
- class: ApiErrorRecoveryClass,
1670
- schedule: Option<&RecoveryIntentSchedule>,
1671
- backpressure_until: Option<chrono::DateTime<chrono::Utc>>,
1672
- cohort_key: &str,
1673
- fact: &crate::provider::FaultFact,
1674
- manual_command: &str,
1675
- ) -> String {
1676
- let manual_line = format!("manual_recovery: {manual_command}");
1677
- let backpressure_line = match backpressure_until {
1678
- Some(until) => {
1679
- let status = fact
1680
- .api_error_status
1681
- .map(|s| s.to_string())
1682
- .unwrap_or_else(|| "-".to_string());
1683
- format!(
1684
- "backpressure: active cohort={cohort_key} status={status} until={until}",
1685
- until = until.to_rfc3339()
1686
- )
1687
- }
1688
- None => "backpressure: inactive".to_string(),
1689
- };
1690
- let auto_line = match class {
1691
- ApiErrorRecoveryClass::Retryable => match schedule {
1692
- Some(sched) if sched.backpressured => format!(
1693
- "auto_recovery: delayed by provider backpressure until {}",
1694
- sched.next_retry_at
1695
- ),
1696
- Some(sched) => format!(
1697
- "auto_recovery: scheduled attempt {attempt}/{max} in {backoff}s (due {due})",
1698
- attempt = sched.attempt + 1,
1699
- max = RECOVERY_MAX_ATTEMPTS,
1700
- backoff = sched.backoff_seconds,
1701
- due = sched.next_retry_at,
1702
- ),
1703
- None => "auto_recovery: not scheduled (recovery_exhausted); use manual command below"
1704
- .to_string(),
1705
- },
1706
- ApiErrorRecoveryClass::NonRetryable => {
1707
- let error = fact.error.as_deref().unwrap_or("non_retryable");
1708
- format!("auto_recovery: not scheduled (non_retryable_api_error: {error})")
1709
- }
1710
- ApiErrorRecoveryClass::Unknown => {
1711
- "auto_recovery: not scheduled (unknown_api_error_class)".to_string()
1712
- }
1713
- };
1714
- format!("{auto_line}\n{manual_line}\n{backpressure_line}")
1715
- }
1716
-
1717
- /// 0.5.36 §7 orchestration for a single fresh abnormal notification. Runs
1718
- /// inside `detect_abnormal_exits`; only classifies + records intent +
1719
- /// emits recovery-family events. Returns the classifier verdict, the
1720
- /// scheduled intent (if any), the cohort key, and the active backpressure
1721
- /// window (if any) so the caller can build the notification tail.
1722
- ///
1723
- /// R6 guard: this function must not call any lifecycle helpers. Actual
1724
- /// lifecycle work runs post-atomic_save in `attempt_due_recoveries`.
1725
- fn process_api_error_recovery_intent(
1726
- state: &mut Value,
1727
- event_log: &EventLog,
1728
- team: &str,
1729
- agent: &AbnormalWatchAgent,
1730
- fact: &crate::provider::FaultFact,
1731
- manual_command: &str,
1732
- error_key: &str,
1733
- ) -> Result<
1734
- (
1735
- ApiErrorRecoveryClass,
1736
- Option<RecoveryIntentSchedule>,
1737
- String,
1738
- Option<chrono::DateTime<chrono::Utc>>,
1739
- ),
1740
- TickError,
1741
- > {
1742
- let class = classify_api_error_recovery(
1743
- fact.signature.as_str(),
1744
- fact.api_error_status,
1745
- fact.error.as_deref(),
1746
- );
1747
- let provider_str = provider_wire(agent.provider);
1748
- let cohort_key = cohort_key_for(team, provider_str, fact);
1749
- if !matches!(class, ApiErrorRecoveryClass::Retryable) {
1750
- return Ok((class, None, cohort_key, None));
1751
- }
1752
- let now = now_utc();
1753
- let cohort_hint = cohort_key_for(team, provider_str, fact);
1754
- let canary_active = has_active_canary_in_cohort(state, &cohort_hint);
1755
- let mut bp_decision = record_backpressure_event(
1756
- state,
1757
- team,
1758
- provider_str,
1759
- fact,
1760
- agent.agent_id.as_str(),
1761
- now,
1762
- );
1763
- // 0.5.36 §7.6: "at most one canary". If another agent in the same
1764
- // cohort already holds a scheduled intent, defer the new arrival to
1765
- // the cohort cooldown (or a synthetic short cooldown if none yet).
1766
- if canary_active {
1767
- let synthetic = bp_decision
1768
- .cooldown_until
1769
- .unwrap_or_else(|| now + chrono::Duration::seconds(BACKPRESSURE_COOLDOWN_SECS));
1770
- bp_decision.cooldown_until = Some(synthetic);
1771
- }
1772
- if bp_decision.just_activated {
1773
- let bp_agents = state
1774
- .pointer(&format!(
1775
- "/coordinator/abnormal_api_error_recovery/backpressure/{}/agents",
1776
- bp_decision.cohort_key
1777
- ))
1778
- .cloned()
1779
- .unwrap_or_else(|| Value::Array(Vec::new()));
1780
- let cooldown_until_str = bp_decision
1781
- .cooldown_until
1782
- .map(|dt| dt.to_rfc3339())
1783
- .unwrap_or_default();
1784
- event_log.write(
1785
- "worker.abnormal_exit.backpressure_started",
1786
- serde_json::json!({
1787
- "team_id": team,
1788
- "provider": provider_str,
1789
- "signature": fact.signature.as_str(),
1790
- "apiErrorStatus": fact.api_error_status,
1791
- "error": fact.error.as_deref(),
1792
- "cohort_key": bp_decision.cohort_key,
1793
- "threshold": BACKPRESSURE_THRESHOLD,
1794
- "window_seconds": BACKPRESSURE_WINDOW_SECS,
1795
- "cooldown_until": cooldown_until_str,
1796
- "agents": bp_agents,
1797
- }),
1798
- )?;
1799
- }
1800
- let schedule = schedule_recovery_intent(
1801
- state,
1802
- agent.agent_id.as_str(),
1803
- &bp_decision.cohort_key,
1804
- error_key,
1805
- manual_command,
1806
- now,
1807
- bp_decision.cooldown_until,
1808
- );
1809
- if let Some(sched) = schedule.as_ref() {
1810
- if sched.backpressured {
1811
- // Backpressured workers are not scheduled for the canary window;
1812
- // emit a distinct `backpressure_active` event so the leader
1813
- // notification tail can trace which cohort deferred which agent.
1814
- event_log.write(
1815
- "worker.abnormal_exit.backpressure_active",
1816
- serde_json::json!({
1817
- "team_id": team,
1818
- "agent_id": agent.agent_id.as_str(),
1819
- "cohort_key": bp_decision.cohort_key,
1820
- "cooldown_until": bp_decision
1821
- .cooldown_until
1822
- .map(|dt| dt.to_rfc3339())
1823
- .unwrap_or_default(),
1824
- "action": "deferred_recovery",
1825
- "manual_command": manual_command,
1826
- }),
1827
- )?;
1828
- } else {
1829
- event_log.write(
1830
- "worker.abnormal_exit.recovery_scheduled",
1831
- serde_json::json!({
1832
- "team_id": team,
1833
- "agent_id": agent.agent_id.as_str(),
1834
- "provider": provider_str,
1835
- "signature": fact.signature.as_str(),
1836
- "apiErrorStatus": fact.api_error_status,
1837
- "error": fact.error.as_deref(),
1838
- "attempt": sched.attempt,
1839
- "max_attempts": RECOVERY_MAX_ATTEMPTS,
1840
- "due_at": sched.next_retry_at,
1841
- "backoff_seconds": sched.backoff_seconds,
1842
- "error_key": error_key,
1843
- "cohort_key": bp_decision.cohort_key,
1844
- "backpressured": false,
1845
- "manual_command": manual_command,
1846
- }),
1847
- )?;
1848
- }
1849
- } else {
1850
- // schedule_recovery_intent returns None ONLY when max_attempts hit.
1851
- event_log.write(
1852
- "worker.abnormal_exit.recovery_exhausted",
1853
- serde_json::json!({
1854
- "team_id": team,
1855
- "agent_id": agent.agent_id.as_str(),
1856
- "attempts": RECOVERY_MAX_ATTEMPTS,
1857
- "last_error": fact.error.as_deref(),
1858
- "manual_command": manual_command,
1859
- }),
1860
- )?;
1861
- }
1862
- Ok((
1863
- class,
1864
- schedule,
1865
- bp_decision.cohort_key,
1866
- bp_decision.cooldown_until,
1867
- ))
1868
- }
1869
-
1870
- // ─────────────────────────────────────────────────────────────────────────
1871
- // 0.5.36 §7.3 recovery execution — runs post-atomic_save, reloads fresh
1872
- // state, consumes due intents, invokes the lifecycle `start_agent_at_paths`
1873
- // with force=true (stop-before-start semantics for live panes, so noop is
1874
- // impossible), and writes the outcome back to state via the lifecycle
1875
- // path (which owns its own save).
1876
- // ─────────────────────────────────────────────────────────────────────────
1877
-
1878
- ///
1879
- /// 0.5.36 §7.3: process all due recovery intents. Called from tick.rs
1880
- /// AFTER atomic_save has flushed the detector-written intent. Reloads a
1881
- /// fresh state each call so the caller's stale in-memory state cannot
1882
- /// clobber lifecycle writes. Best-effort: recovery failure produces
1883
- /// events + updated intent, never a tick failure.
1884
- pub(crate) fn attempt_due_recoveries(
1885
- workspace: &Path,
1886
- event_log: &EventLog,
1887
- transport: &dyn crate::transport::Transport,
1888
- ) {
1889
- // 0.5.43 debt-sweep (§5 D-j): single fresh post-save load. Do NOT
1890
- // re-use the tick's pre-save Value — that would reopen the 0.5.36
1891
- // R6 lifecycle-save-clobbered-by-tick window (highest risk item in
1892
- // this slice per locate). We own our load here, mutate in memory
1893
- // (`clear_stale_terminal_next_retry_at` is now a pure
1894
- // `(&mut Value) -> bool` scrub), persist through the existing
1895
- // allowlisted root save when the scrub actually changed a row, and
1896
- // then collect due agents from the SAME post-scrub Value so
1897
- // terminal intents newly stripped of `next_retry_at` cannot double-
1898
- // fire below.
1899
- let Ok(mut state) = crate::state::persist::load_runtime_state(workspace) else {
1900
- return;
1901
- };
1902
- if clear_stale_terminal_next_retry_at(&mut state) {
1903
- let _ = crate::state::repository::StateRepository::new(workspace).save(
1904
- crate::state::repository::StateWriteIntent::CoordinatorApiErrorRecovery {
1905
- team_key: state.get("active_team_key").and_then(Value::as_str),
1906
- agent_id: None,
1907
- },
1908
- &state,
1909
- );
1910
- }
1911
- let due_agents = collect_due_recovery_agents(&state);
1912
- for agent_id in due_agents {
1913
- run_single_recovery(workspace, event_log, transport, &agent_id);
1914
- }
1915
- }
1916
-
1917
- fn collect_due_recovery_agents(state: &Value) -> Vec<String> {
1918
- let now = now_utc();
1919
- let Some(agents) = state
1920
- .pointer("/coordinator/abnormal_api_error_recovery/agents")
1921
- .and_then(Value::as_object)
1922
- else {
1923
- return Vec::new();
1924
- };
1925
- let mut due = Vec::new();
1926
- for (agent_id, intent) in agents {
1927
- let status = intent.get("status").and_then(Value::as_str).unwrap_or("");
1928
- // 0.5.37 R8: dispatch only for non-terminal states. `succeeded`,
1929
- // `blocked`, `exhausted`, `backpressured` are terminal / awaiting
1930
- // manual action — a lifecycle dispatch here would be double-fire
1931
- // or wasted work. `scheduled` / `running` remain dispatchable so
1932
- // an interrupted tick can resume.
1933
- if !matches!(status, "scheduled" | "running") {
1934
- continue;
1935
- }
1936
- let Some(next_retry_at) = intent
1937
- .get("next_retry_at")
1938
- .and_then(Value::as_str)
1939
- .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1940
- .map(|dt| dt.with_timezone(&chrono::Utc))
1941
- else {
1942
- continue;
1943
- };
1944
- if next_retry_at <= now {
1945
- due.push(agent_id.clone());
1946
- }
1947
- }
1948
- due
1949
- }
1950
-
1951
- /// 0.5.37 R8 + 0.5.43 debt-sweep §5 D-j: pure scrub helper. Terminal
1952
- /// recovery intents (`succeeded`/`blocked`/`exhausted`) must not carry
1953
- /// a stale `next_retry_at`. Returns true when at least one row was
1954
- /// stripped, so the caller can save through its own load/write cycle
1955
- /// (no double-load, no double-save). No I/O in this function — the
1956
- /// only responsibility is mutation on a caller-owned `Value`.
1957
- fn clear_stale_terminal_next_retry_at(state: &mut Value) -> bool {
1958
- let mut mutated = false;
1959
- if let Some(agents) = recovery_intent_agents(state) {
1960
- for (_agent_id, entry) in agents.iter_mut() {
1961
- let Some(obj) = entry.as_object_mut() else {
1962
- continue;
1963
- };
1964
- let status = obj
1965
- .get("status")
1966
- .and_then(Value::as_str)
1967
- .unwrap_or("")
1968
- .to_string();
1969
- if !matches!(status.as_str(), "succeeded" | "blocked" | "exhausted") {
1970
- continue;
1971
- }
1972
- let had_stale = obj.get("next_retry_at").filter(|v| !v.is_null()).is_some();
1973
- if had_stale {
1974
- obj.remove("next_retry_at");
1975
- mutated = true;
1976
- }
1977
- }
1978
- }
1979
- mutated
1980
- }
1981
-
1982
- fn run_single_recovery(
1983
- workspace: &Path,
1984
- event_log: &EventLog,
1985
- transport: &dyn crate::transport::Transport,
1986
- agent_id: &str,
1987
- ) {
1988
- // Read the current intent so we can bump attempts / write result fields
1989
- // through the shared writer (`write_recovery_intent_field`); the actual
1990
- // lifecycle helper below owns its own state save.
1991
- let Ok(state_before) = crate::state::persist::load_runtime_state(workspace) else {
1992
- return;
1993
- };
1994
- let team_key = crate::state::projection::team_state_key(&state_before);
1995
- let attempt = state_before
1996
- .pointer(&format!(
1997
- "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/attempts"
1998
- ))
1999
- .and_then(Value::as_u64)
2000
- .unwrap_or(0);
2001
- let manual_command = state_before
2002
- .pointer(&format!(
2003
- "/coordinator/abnormal_api_error_recovery/agents/{agent_id}/manual_command"
2004
- ))
2005
- .and_then(Value::as_str)
2006
- .map(str::to_string)
2007
- .unwrap_or_else(|| recovery_manual_command(agent_id, workspace, team_key.as_str()));
2008
- let _ = event_log.write(
2009
- "worker.abnormal_exit.recovery_started",
2010
- serde_json::json!({
2011
- "team_id": team_key.as_str(),
2012
- "agent_id": agent_id,
2013
- "attempt": attempt,
2014
- "mode": "start_agent_force",
2015
- "allow_fresh": false,
2016
- }),
2017
- );
2018
- let agent_id_typed = crate::model::ids::AgentId::new(agent_id.to_string());
2019
- let outcome = crate::lifecycle::restart::start_agent_at_paths_for_recovery(
2020
- workspace,
2021
- &agent_id_typed,
2022
- Some(team_key.as_str()),
2023
- transport,
2024
- );
2025
- let now = now_utc();
2026
- match outcome {
2027
- Ok(recovery_outcome) => {
2028
- let _ = event_log.write(
2029
- "worker.abnormal_exit.recovery_succeeded",
2030
- serde_json::json!({
2031
- "team_id": team_key.as_str(),
2032
- "agent_id": agent_id,
2033
- "start_mode": recovery_outcome.start_mode,
2034
- "target": recovery_outcome.target,
2035
- "coordinator_started": recovery_outcome.coordinator_started,
2036
- }),
2037
- );
2038
- write_recovery_intent_result(
2039
- workspace,
2040
- agent_id,
2041
- RecoveryIntentUpdate {
2042
- status: "succeeded",
2043
- attempts: attempt + 1,
2044
- last_attempt_at: now.to_rfc3339(),
2045
- last_error: None,
2046
- blocked_reason: None,
2047
- },
2048
- );
2049
- }
2050
- Err(RecoveryError::NoopBlocked) => {
2051
- let _ = event_log.write(
2052
- "worker.abnormal_exit.recovery_blocked",
2053
- serde_json::json!({
2054
- "team_id": team_key.as_str(),
2055
- "agent_id": agent_id,
2056
- "reason": "noop_not_recovery",
2057
- "manual_command": manual_command,
2058
- }),
2059
- );
2060
- write_recovery_intent_result(
2061
- workspace,
2062
- agent_id,
2063
- RecoveryIntentUpdate {
2064
- status: "blocked",
2065
- attempts: attempt + 1,
2066
- last_attempt_at: now.to_rfc3339(),
2067
- last_error: None,
2068
- blocked_reason: Some("noop_not_recovery"),
2069
- },
2070
- );
2071
- }
2072
- Err(RecoveryError::Lifecycle(reason)) => {
2073
- let _ = event_log.write(
2074
- "worker.abnormal_exit.recovery_blocked",
2075
- serde_json::json!({
2076
- "team_id": team_key.as_str(),
2077
- "agent_id": agent_id,
2078
- "reason": "lifecycle_error",
2079
- "detail": reason,
2080
- "manual_command": manual_command,
2081
- }),
2082
- );
2083
- write_recovery_intent_result(
2084
- workspace,
2085
- agent_id,
2086
- RecoveryIntentUpdate {
2087
- status: "blocked",
2088
- attempts: attempt + 1,
2089
- last_attempt_at: now.to_rfc3339(),
2090
- last_error: Some(reason),
2091
- blocked_reason: Some("lifecycle_error"),
2092
- },
2093
- );
2094
- }
2095
- }
2096
- }
2097
-
2098
- pub(crate) enum RecoveryError {
2099
- NoopBlocked,
2100
- Lifecycle(String),
2101
- }
2102
-
2103
- /// 0.5.36 §7.3 typed outcome returned from
2104
- /// `lifecycle::restart::start_agent_at_paths_for_recovery` to the post-save
2105
- /// step. Small on purpose — just the fields the `recovery_succeeded` event
2106
- /// needs.
2107
- pub(crate) struct RecoveryLifecycleOutcome {
2108
- pub start_mode: String,
2109
- pub target: String,
2110
- pub coordinator_started: bool,
2111
- }
2112
-
2113
- struct RecoveryIntentUpdate {
2114
- status: &'static str,
2115
- attempts: u64,
2116
- last_attempt_at: String,
2117
- last_error: Option<String>,
2118
- blocked_reason: Option<&'static str>,
2119
- }
2120
-
2121
- fn write_recovery_intent_result(workspace: &Path, agent_id: &str, update: RecoveryIntentUpdate) {
2122
- let Ok(mut state) = crate::state::persist::load_runtime_state(workspace) else {
2123
- return;
2124
- };
2125
- if let Some(agents) = recovery_intent_agents(&mut state) {
2126
- let entry = agents
2127
- .entry(agent_id.to_string())
2128
- .or_insert_with(|| serde_json::json!({}));
2129
- if !entry.is_object() {
2130
- *entry = serde_json::json!({});
2131
- }
2132
- if let Some(obj) = entry.as_object_mut() {
2133
- obj.insert("status".to_string(), serde_json::json!(update.status));
2134
- obj.insert("attempts".to_string(), serde_json::json!(update.attempts));
2135
- obj.insert(
2136
- "last_attempt_at".to_string(),
2137
- serde_json::json!(update.last_attempt_at),
2138
- );
2139
- obj.insert(
2140
- "last_error".to_string(),
2141
- match update.last_error.as_ref() {
2142
- Some(text) => serde_json::json!(text),
2143
- None => Value::Null,
2144
- },
2145
- );
2146
- match update.blocked_reason {
2147
- Some(reason) => {
2148
- obj.insert("blocked_reason".to_string(), serde_json::json!(reason));
2149
- }
2150
- None => {
2151
- obj.remove("blocked_reason");
2152
- }
2153
- }
2154
- // 0.5.37 R8: transitioning into a terminal state clears the
2155
- // stale `next_retry_at`; a future retry earns a fresh due
2156
- // time when it re-enters `scheduled` through the detector.
2157
- if matches!(update.status, "succeeded" | "blocked" | "exhausted") {
2158
- obj.remove("next_retry_at");
2159
- }
2160
- }
2161
- }
2162
- let _ = crate::state::repository::StateRepository::new(workspace).save(
2163
- crate::state::repository::StateWriteIntent::CoordinatorApiErrorRecovery {
2164
- team_key: state.get("active_team_key").and_then(Value::as_str),
2165
- agent_id: Some(agent_id),
2166
- },
2167
- &state,
2168
- );
2169
- }
2170
-
2171
1319
  #[cfg(test)]
2172
1320
  #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2173
1321
  mod tests {