@team-agent/installer 0.5.62 → 0.5.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +11 -863
- package/crates/team-agent/src/coordinator/tests/api_error_recovery.rs +139 -366
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +0 -7
- package/crates/team-agent/src/coordinator/tick.rs +0 -13
- package/crates/team-agent/src/lifecycle/restart/agent.rs +51 -72
- package/crates/team-agent/src/lifecycle/restart/remove.rs +7 -139
- package/crates/team-agent/src/lifecycle/restart.rs +0 -4
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +145 -2
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +45 -13
- package/crates/team-agent/src/state/repository.rs +0 -13
- package/package.json +4 -4
|
@@ -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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
&
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|