@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/status.rs +28 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +810 -2
- package/crates/team-agent/src/coordinator/tests/api_error_recovery.rs +480 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +7 -0
- package/crates/team-agent/src/coordinator/tick.rs +13 -0
- package/crates/team-agent/src/leader/start.rs +194 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +65 -0
- package/crates/team-agent/src/lifecycle/restart.rs +4 -0
- package/crates/team-agent/src/state/repository.rs +15 -0
- package/package.json +4 -4
|
@@ -167,7 +167,43 @@ pub(crate) fn detect_abnormal_exits(
|
|
|
167
167
|
{
|
|
168
168
|
continue;
|
|
169
169
|
}
|
|
170
|
-
|
|
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
|
-
|
|
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 {
|