@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
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
use std::sync::{
|
|
4
|
+
atomic::{AtomicU32, Ordering},
|
|
5
|
+
Arc,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// 0.5.36 supermarket api_error recovery contracts.
|
|
9
|
+
//
|
|
10
|
+
// User-facing promise: a fresh retryable provider api_error either schedules a
|
|
11
|
+
// bounded, visible worker recovery or loudly explains why not. Recovery must not
|
|
12
|
+
// run inside the abnormal detector's pre-save window, must not treat a live-pid
|
|
13
|
+
// Noop as success, and must never synthesize a hidden provider turn.
|
|
14
|
+
|
|
15
|
+
#[test]
|
|
16
|
+
fn r1_retryable_429_schedules_recovery_and_copyable_command() {
|
|
17
|
+
let case = RecoveryCase::new("r1-retryable");
|
|
18
|
+
case.seed_agents(&[("fe-admin", 429, "rate_limit", "baseline-429")]);
|
|
19
|
+
let coord = case.coord();
|
|
20
|
+
coord.tick().expect("baseline tick");
|
|
21
|
+
|
|
22
|
+
case.append_api_error("fe-admin", 429, "rate_limit", "fresh-429");
|
|
23
|
+
coord.tick().expect("fresh retryable tick");
|
|
24
|
+
|
|
25
|
+
let events = case.events();
|
|
26
|
+
let abnormal = find_event(&events, "worker.abnormal_exit").expect("abnormal event");
|
|
27
|
+
assert_eq!(abnormal["agent_id"], serde_json::json!("fe-admin"));
|
|
28
|
+
let scheduled = find_event(&events, "worker.abnormal_exit.recovery_scheduled")
|
|
29
|
+
.expect("R1: retryable api_error/429 must emit recovery_scheduled");
|
|
30
|
+
assert_eq!(scheduled["agent_id"], serde_json::json!("fe-admin"));
|
|
31
|
+
assert_eq!(scheduled["apiErrorStatus"], serde_json::json!(429));
|
|
32
|
+
assert_eq!(scheduled["error"], serde_json::json!("rate_limit"));
|
|
33
|
+
assert_eq!(scheduled["attempt"], serde_json::json!(0));
|
|
34
|
+
assert!(
|
|
35
|
+
scheduled
|
|
36
|
+
.get("due_at")
|
|
37
|
+
.and_then(serde_json::Value::as_str)
|
|
38
|
+
.is_some(),
|
|
39
|
+
"R1: recovery_scheduled must include a concrete due_at; event={scheduled}"
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
let state = case.state();
|
|
43
|
+
let intent = state
|
|
44
|
+
.pointer("/coordinator/abnormal_api_error_recovery/agents/fe-admin")
|
|
45
|
+
.expect("R1: state must persist recovery intent");
|
|
46
|
+
assert_eq!(intent["status"], serde_json::json!("scheduled"));
|
|
47
|
+
assert_eq!(intent["attempts"], serde_json::json!(0));
|
|
48
|
+
assert!(
|
|
49
|
+
intent
|
|
50
|
+
.get("next_retry_at")
|
|
51
|
+
.and_then(serde_json::Value::as_str)
|
|
52
|
+
.is_some(),
|
|
53
|
+
"R1: intent must carry next_retry_at; intent={intent}"
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
let content = case.latest_leader_notification();
|
|
57
|
+
assert!(
|
|
58
|
+
content.contains("team-agent start-agent fe-admin")
|
|
59
|
+
&& content.contains("--workspace")
|
|
60
|
+
&& content.contains("--team research")
|
|
61
|
+
&& content.contains("--force")
|
|
62
|
+
&& content.contains("--json"),
|
|
63
|
+
"R1: leader notification must include one copyable forced start-agent command; content={content}"
|
|
64
|
+
);
|
|
65
|
+
assert!(
|
|
66
|
+
!content.contains("No automatic restart was performed."),
|
|
67
|
+
"R1: old unconditional no-auto-restart text must be replaced; content={content}"
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#[test]
|
|
72
|
+
fn r2_non_retryable_api_error_guides_only_without_intent() {
|
|
73
|
+
let case = RecoveryCase::new("r2-nonretry");
|
|
74
|
+
case.seed_agents(&[("fe-admin", 404, "model_not_found", "baseline-404")]);
|
|
75
|
+
let coord = case.coord();
|
|
76
|
+
coord.tick().expect("baseline tick");
|
|
77
|
+
|
|
78
|
+
case.append_api_error("fe-admin", 404, "model_not_found", "fresh-404");
|
|
79
|
+
coord.tick().expect("fresh non-retryable tick");
|
|
80
|
+
|
|
81
|
+
let state = case.state();
|
|
82
|
+
assert!(
|
|
83
|
+
state
|
|
84
|
+
.pointer("/coordinator/abnormal_api_error_recovery/agents/fe-admin")
|
|
85
|
+
.is_none(),
|
|
86
|
+
"R2: non-retryable model errors must not schedule auto recovery; state={state}"
|
|
87
|
+
);
|
|
88
|
+
let content = case.latest_leader_notification();
|
|
89
|
+
assert!(
|
|
90
|
+
content.contains("non_retryable_api_error")
|
|
91
|
+
&& content.contains("model_not_found")
|
|
92
|
+
&& content.contains("team-agent start-agent fe-admin")
|
|
93
|
+
&& content.contains("--force"),
|
|
94
|
+
"R2: notification must be guide-only with model/config context and manual command; content={content}"
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#[test]
|
|
99
|
+
fn r3_live_pid_noop_is_never_counted_as_recovery_success() {
|
|
100
|
+
let abnormal = source("src/coordinator/steps/abnormal.rs");
|
|
101
|
+
let lifecycle = source("src/lifecycle/restart/agent.rs");
|
|
102
|
+
let recovery_mentions_noop_block = abnormal.contains("noop_not_recovery")
|
|
103
|
+
&& abnormal.contains("worker.abnormal_exit.recovery_blocked");
|
|
104
|
+
let lifecycle_has_stop_then_start_mode =
|
|
105
|
+
abnormal.contains("stop_then_start") || lifecycle.contains("stop_before_start");
|
|
106
|
+
assert!(
|
|
107
|
+
recovery_mentions_noop_block || lifecycle_has_stop_then_start_mode,
|
|
108
|
+
"R3: api_error recovery must either stop-before-start live panes or record recovery_blocked(reason=noop_not_recovery); abnormal.rs/lifecycle.rs missing both guards"
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#[test]
|
|
113
|
+
fn r4_short_window_429_cohort_enters_backpressure_with_one_canary() {
|
|
114
|
+
let case = RecoveryCase::new("r4-backpressure");
|
|
115
|
+
let agents = [
|
|
116
|
+
("architect", 429, "rate_limit", "baseline-a"),
|
|
117
|
+
("fe-admin", 429, "rate_limit", "baseline-b"),
|
|
118
|
+
("fe-staff", 429, "rate_limit", "baseline-c"),
|
|
119
|
+
("fe-shop", 429, "rate_limit", "baseline-d"),
|
|
120
|
+
];
|
|
121
|
+
case.seed_agents(&agents);
|
|
122
|
+
let coord = case.coord();
|
|
123
|
+
coord.tick().expect("baseline tick");
|
|
124
|
+
for (agent, _, _, _) in agents {
|
|
125
|
+
case.append_api_error(agent, 429, "rate_limit", &format!("fresh-{agent}"));
|
|
126
|
+
}
|
|
127
|
+
coord.tick().expect("fresh cohort tick");
|
|
128
|
+
|
|
129
|
+
let events = case.events();
|
|
130
|
+
let backpressure = find_event(&events, "worker.abnormal_exit.backpressure_started")
|
|
131
|
+
.expect("R4: four same-team/provider 429s inside 120s must start backpressure");
|
|
132
|
+
assert_eq!(backpressure["team_id"], serde_json::json!("research"));
|
|
133
|
+
assert_eq!(backpressure["provider"], serde_json::json!("claude_code"));
|
|
134
|
+
assert_eq!(backpressure["apiErrorStatus"], serde_json::json!(429));
|
|
135
|
+
assert!(
|
|
136
|
+
backpressure
|
|
137
|
+
.get("cooldown_until")
|
|
138
|
+
.and_then(serde_json::Value::as_str)
|
|
139
|
+
.is_some(),
|
|
140
|
+
"R4: backpressure event must include shared cooldown_until; event={backpressure}"
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
let scheduled_count = events
|
|
144
|
+
.iter()
|
|
145
|
+
.filter(|event| {
|
|
146
|
+
event.get("event").and_then(serde_json::Value::as_str)
|
|
147
|
+
== Some("worker.abnormal_exit.recovery_scheduled")
|
|
148
|
+
})
|
|
149
|
+
.count();
|
|
150
|
+
assert!(
|
|
151
|
+
scheduled_count <= 1,
|
|
152
|
+
"R4: at most one canary recovery may be scheduled while cohort is backpressured; scheduled_count={scheduled_count}"
|
|
153
|
+
);
|
|
154
|
+
let state = case.state();
|
|
155
|
+
let bp = state
|
|
156
|
+
.pointer("/coordinator/abnormal_api_error_recovery/backpressure")
|
|
157
|
+
.and_then(serde_json::Value::as_object)
|
|
158
|
+
.expect("R4: backpressure state must be persisted");
|
|
159
|
+
assert_eq!(
|
|
160
|
+
bp.len(),
|
|
161
|
+
1,
|
|
162
|
+
"R4: cohort must share one backpressure record; state={state}"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#[test]
|
|
167
|
+
fn r5_retry_budget_exhausts_and_stays_loud() {
|
|
168
|
+
let abnormal = source("src/coordinator/steps/abnormal.rs");
|
|
169
|
+
for needle in [
|
|
170
|
+
"max_attempts",
|
|
171
|
+
"worker.abnormal_exit.recovery_exhausted",
|
|
172
|
+
"manual_command",
|
|
173
|
+
"attempts",
|
|
174
|
+
] {
|
|
175
|
+
assert!(
|
|
176
|
+
abnormal.contains(needle),
|
|
177
|
+
"R5: retry budget contract requires `{needle}` in abnormal api_error recovery state/events"
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[test]
|
|
183
|
+
fn r6_abnormal_detector_does_not_mutate_lifecycle_inside_pre_save_window() {
|
|
184
|
+
let body = detect_abnormal_body();
|
|
185
|
+
for forbidden in [
|
|
186
|
+
"start_agent_at_paths",
|
|
187
|
+
"start_agent(",
|
|
188
|
+
"stop_agent_at_paths",
|
|
189
|
+
"reset_agent",
|
|
190
|
+
] {
|
|
191
|
+
assert!(
|
|
192
|
+
!body.contains(forbidden),
|
|
193
|
+
"R6: detect_abnormal_exits is pre-save detection only and must not call `{forbidden}`"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#[test]
|
|
199
|
+
fn r6_recovery_fields_survive_the_next_tick_after_atomic_save() {
|
|
200
|
+
let case = RecoveryCase::new("r6-save-boundary");
|
|
201
|
+
case.seed_agents(&[("fe-admin", 429, "rate_limit", "baseline-429")]);
|
|
202
|
+
case.seed_due_recovery("fe-admin");
|
|
203
|
+
let coord = case.coord();
|
|
204
|
+
coord.tick().expect("recovery tick");
|
|
205
|
+
|
|
206
|
+
let events = case.events();
|
|
207
|
+
assert!(
|
|
208
|
+
find_event(&events, "worker.abnormal_exit.recovery_started").is_some()
|
|
209
|
+
|| find_event(&events, "worker.abnormal_exit.recovery_blocked").is_some(),
|
|
210
|
+
"R6: due recovery must run after atomic_save and emit started or blocked; events={events:?}"
|
|
211
|
+
);
|
|
212
|
+
let state = case.state();
|
|
213
|
+
let recovery = state
|
|
214
|
+
.pointer("/coordinator/abnormal_api_error_recovery/agents/fe-admin")
|
|
215
|
+
.expect("R6: recovery entry must survive tick");
|
|
216
|
+
assert!(
|
|
217
|
+
recovery.get("last_attempt_at").is_some()
|
|
218
|
+
|| recovery.get("last_error").is_some()
|
|
219
|
+
|| recovery.get("blocked_reason").is_some(),
|
|
220
|
+
"R6: lifecycle-written recovery result fields must survive next tick save; recovery={recovery}"
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#[test]
|
|
225
|
+
fn r7_recovery_never_synthesizes_hidden_provider_turn_or_sdk_call() {
|
|
226
|
+
let case = RecoveryCase::new("r7-no-hidden-turn");
|
|
227
|
+
case.seed_agents(&[("fe-admin", 429, "rate_limit", "baseline-429")]);
|
|
228
|
+
case.seed_due_recovery("fe-admin");
|
|
229
|
+
let registry = CountingRegistry::new();
|
|
230
|
+
let counters = registry.counters();
|
|
231
|
+
let transport = MockTransport::new(true);
|
|
232
|
+
let calls = std::sync::Arc::clone(&transport.calls);
|
|
233
|
+
let coord = Coordinator::for_test(
|
|
234
|
+
WorkspacePath::new(case.root.clone()),
|
|
235
|
+
Box::new(registry),
|
|
236
|
+
Box::new(transport),
|
|
237
|
+
None,
|
|
238
|
+
None,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
coord.tick().expect("recovery tick");
|
|
242
|
+
|
|
243
|
+
assert_eq!(
|
|
244
|
+
counters.adapter_calls.load(Ordering::SeqCst),
|
|
245
|
+
0,
|
|
246
|
+
"R7: recovery must not call provider adapter/client APIs"
|
|
247
|
+
);
|
|
248
|
+
assert!(
|
|
249
|
+
!calls.lock().unwrap().iter().any(|call| *call == "inject"),
|
|
250
|
+
"R7: recovery must not inject a synthetic worker turn; transport calls={:?}",
|
|
251
|
+
calls.lock().unwrap()
|
|
252
|
+
);
|
|
253
|
+
assert_eq!(
|
|
254
|
+
case.non_leader_message_count(),
|
|
255
|
+
0,
|
|
256
|
+
"R7: recovery may notify leader, but must not create hidden worker prompt messages"
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
struct RecoveryCase {
|
|
261
|
+
root: std::path::PathBuf,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
impl RecoveryCase {
|
|
265
|
+
fn new(tag: &str) -> Self {
|
|
266
|
+
let base = std::env::var_os("TEAM_AGENT_TEST_TMP")
|
|
267
|
+
.map(std::path::PathBuf::from)
|
|
268
|
+
.unwrap_or_else(std::env::temp_dir);
|
|
269
|
+
std::fs::create_dir_all(&base).unwrap();
|
|
270
|
+
let root = base.join(format!(
|
|
271
|
+
"team-agent-0536-{tag}-{}-{}",
|
|
272
|
+
std::process::id(),
|
|
273
|
+
std::time::SystemTime::now()
|
|
274
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
275
|
+
.unwrap()
|
|
276
|
+
.as_nanos()
|
|
277
|
+
));
|
|
278
|
+
std::fs::create_dir_all(&root).unwrap();
|
|
279
|
+
Self { root }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fn coord(&self) -> Coordinator {
|
|
283
|
+
Coordinator::for_test(
|
|
284
|
+
WorkspacePath::new(self.root.clone()),
|
|
285
|
+
Box::new(MockRegistry::new(&[], &[])),
|
|
286
|
+
Box::new(MockTransport::new(true)),
|
|
287
|
+
None,
|
|
288
|
+
None,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
fn seed_agents(&self, agents: &[(&str, i64, &str, &str)]) {
|
|
293
|
+
let mut agent_map = serde_json::Map::new();
|
|
294
|
+
for (agent, status, error, uuid) in agents {
|
|
295
|
+
let rollout = self.rollout(agent);
|
|
296
|
+
std::fs::write(&rollout, claude_api_error_line(uuid, *status, error)).unwrap();
|
|
297
|
+
agent_map.insert(
|
|
298
|
+
(*agent).to_string(),
|
|
299
|
+
serde_json::json!({
|
|
300
|
+
"agent_id": agent,
|
|
301
|
+
"id": agent,
|
|
302
|
+
"provider": "claude_code",
|
|
303
|
+
"model": "sonnet",
|
|
304
|
+
"status": "running",
|
|
305
|
+
"window": agent,
|
|
306
|
+
"rollout_path": rollout,
|
|
307
|
+
"provider_process_alive": true,
|
|
308
|
+
"spawn_epoch": 1,
|
|
309
|
+
"spawned_at": "2026-07-12T10:28:00Z"
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
let state = serde_json::json!({
|
|
314
|
+
"active_team_key": "research",
|
|
315
|
+
"team_key": "research",
|
|
316
|
+
"workspace": self.root,
|
|
317
|
+
"session_name": "team-research",
|
|
318
|
+
"leader_receiver": {
|
|
319
|
+
"mode": "direct_tmux",
|
|
320
|
+
"status": "attached",
|
|
321
|
+
"pane_id": "%leader",
|
|
322
|
+
"provider": "codex"
|
|
323
|
+
},
|
|
324
|
+
"agents": serde_json::Value::Object(agent_map),
|
|
325
|
+
"tasks": []
|
|
326
|
+
});
|
|
327
|
+
crate::state::persist::save_runtime_state(&self.root, &state).unwrap();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
fn seed_due_recovery(&self, agent: &str) {
|
|
331
|
+
let mut state = self.state();
|
|
332
|
+
state["coordinator"]["abnormal_api_error_recovery"]["agents"][agent] = serde_json::json!({
|
|
333
|
+
"error_key": format!("worker.abnormal_exit.error:{agent}:rollout:api_error:seed"),
|
|
334
|
+
"cohort_key": "research:claude_code:api_error:429:rate_limit",
|
|
335
|
+
"status": "scheduled",
|
|
336
|
+
"attempts": 0,
|
|
337
|
+
"max_attempts": 2,
|
|
338
|
+
"next_retry_at": "2000-01-01T00:00:00Z",
|
|
339
|
+
"last_attempt_at": null,
|
|
340
|
+
"last_error": null,
|
|
341
|
+
"manual_command": format!("team-agent start-agent {agent} --workspace '{}' --team research --force --json", self.root.display())
|
|
342
|
+
});
|
|
343
|
+
crate::state::persist::save_runtime_state(&self.root, &state).unwrap();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
fn append_api_error(&self, agent: &str, status: i64, error: &str, uuid: &str) {
|
|
347
|
+
use std::io::Write as _;
|
|
348
|
+
let mut file = std::fs::OpenOptions::new()
|
|
349
|
+
.append(true)
|
|
350
|
+
.open(self.rollout(agent))
|
|
351
|
+
.unwrap();
|
|
352
|
+
file.write_all(claude_api_error_line(uuid, status, error).as_bytes())
|
|
353
|
+
.unwrap();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fn rollout(&self, agent: &str) -> std::path::PathBuf {
|
|
357
|
+
self.root.join(format!("rollout-{agent}.jsonl"))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
fn state(&self) -> serde_json::Value {
|
|
361
|
+
crate::state::persist::load_runtime_state(&self.root).unwrap()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
fn events(&self) -> Vec<serde_json::Value> {
|
|
365
|
+
read_event_log_dir(&self.root)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
fn latest_leader_notification(&self) -> String {
|
|
369
|
+
let db = crate::model::paths::runtime_dir(&self.root).join("team.db");
|
|
370
|
+
let conn = crate::db::schema::open_db(&db).unwrap();
|
|
371
|
+
conn.query_row(
|
|
372
|
+
"select content from messages where recipient = 'leader' order by created_at desc limit 1",
|
|
373
|
+
[],
|
|
374
|
+
|row| row.get::<_, String>(0),
|
|
375
|
+
)
|
|
376
|
+
.unwrap_or_default()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
fn non_leader_message_count(&self) -> i64 {
|
|
380
|
+
let db = crate::model::paths::runtime_dir(&self.root).join("team.db");
|
|
381
|
+
let conn = crate::db::schema::open_db(&db).unwrap();
|
|
382
|
+
conn.query_row(
|
|
383
|
+
"select count(*) from messages where recipient != 'leader'",
|
|
384
|
+
[],
|
|
385
|
+
|row| row.get::<_, i64>(0),
|
|
386
|
+
)
|
|
387
|
+
.unwrap()
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
impl Drop for RecoveryCase {
|
|
392
|
+
fn drop(&mut self) {
|
|
393
|
+
if std::env::var("TEAM_AGENT_KEEP_TEST_TMP").as_deref() != Ok("1") {
|
|
394
|
+
let _ = std::fs::remove_dir_all(&self.root);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#[derive(Clone)]
|
|
400
|
+
struct CounterState {
|
|
401
|
+
adapter_calls: Arc<AtomicU32>,
|
|
402
|
+
error_list_calls: Arc<AtomicU32>,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
struct CountingRegistry {
|
|
406
|
+
counters: CounterState,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
impl CountingRegistry {
|
|
410
|
+
fn new() -> Self {
|
|
411
|
+
Self {
|
|
412
|
+
counters: CounterState {
|
|
413
|
+
adapter_calls: Arc::new(AtomicU32::new(0)),
|
|
414
|
+
error_list_calls: Arc::new(AtomicU32::new(0)),
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
fn counters(&self) -> CounterState {
|
|
420
|
+
self.counters.clone()
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
impl ProviderRegistry for CountingRegistry {
|
|
425
|
+
fn adapter_for(&self, provider: Provider) -> Box<dyn ProviderAdapter> {
|
|
426
|
+
self.counters.adapter_calls.fetch_add(1, Ordering::SeqCst);
|
|
427
|
+
crate::provider::get_adapter(provider)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
fn error_lists(&self, _provider: Provider) -> ErrorLists {
|
|
431
|
+
self.counters
|
|
432
|
+
.error_list_calls
|
|
433
|
+
.fetch_add(1, Ordering::SeqCst);
|
|
434
|
+
ErrorLists::default()
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
fn claude_api_error_line(uuid: &str, status: i64, error: &str) -> String {
|
|
439
|
+
format!(
|
|
440
|
+
"{}\n",
|
|
441
|
+
serde_json::json!({
|
|
442
|
+
"type": "assistant",
|
|
443
|
+
"parentUuid": format!("parent-{uuid}"),
|
|
444
|
+
"uuid": uuid,
|
|
445
|
+
"requestId": format!("req-{uuid}"),
|
|
446
|
+
"message": {"role": "assistant", "content": [
|
|
447
|
+
{"type": "text", "text": format!("API Error: {status} {error}")}
|
|
448
|
+
]},
|
|
449
|
+
"error": error,
|
|
450
|
+
"isApiErrorMessage": true,
|
|
451
|
+
"apiErrorStatus": status,
|
|
452
|
+
"sessionId": "session-supermarket",
|
|
453
|
+
"version": "2.1.181"
|
|
454
|
+
})
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
fn find_event<'a>(events: &'a [serde_json::Value], name: &str) -> Option<&'a serde_json::Value> {
|
|
459
|
+
events
|
|
460
|
+
.iter()
|
|
461
|
+
.rev()
|
|
462
|
+
.find(|event| event.get("event").and_then(serde_json::Value::as_str) == Some(name))
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
fn source(rel: &str) -> String {
|
|
466
|
+
std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(rel))
|
|
467
|
+
.expect("read source")
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
fn detect_abnormal_body() -> String {
|
|
471
|
+
let source = source("src/coordinator/steps/abnormal.rs");
|
|
472
|
+
let start = source
|
|
473
|
+
.find("pub(crate) fn detect_abnormal_exits")
|
|
474
|
+
.expect("detect_abnormal_exits exists");
|
|
475
|
+
let end = source[start..]
|
|
476
|
+
.find("#[derive")
|
|
477
|
+
.map(|offset| start + offset)
|
|
478
|
+
.expect("derive after detect_abnormal_exits");
|
|
479
|
+
source[start..end].to_string()
|
|
480
|
+
}
|
|
@@ -91,6 +91,13 @@ fn tick_side_effect_order_is_the_fixed_sequence() {
|
|
|
91
91
|
"detect_drift",
|
|
92
92
|
"detect_api_errors",
|
|
93
93
|
"atomic_save",
|
|
94
|
+
// 0.5.36 (`.team/artifacts/supermarket-api-error-recovery-locate.md` §7.3):
|
|
95
|
+
// post-save recovery step reloads fresh state, consumes due
|
|
96
|
+
// recovery intents written pre-save, and drives the lifecycle
|
|
97
|
+
// helper. Sits between atomic_save and collect_results by design
|
|
98
|
+
// so nested lifecycle saves cannot be clobbered by tick's final
|
|
99
|
+
// in-memory save.
|
|
100
|
+
"attempt_api_error_recoveries",
|
|
94
101
|
"collect_results",
|
|
95
102
|
"prune_dedupe_log",
|
|
96
103
|
];
|
|
@@ -437,6 +437,19 @@ impl Coordinator {
|
|
|
437
437
|
));
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
+
// 0.5.36 (`.team/artifacts/supermarket-api-error-recovery-locate.md` §7.3):
|
|
441
|
+
// post-save recovery step. Reloads fresh state, consumes due
|
|
442
|
+
// recovery intents that were WRITTEN during the pre-save detector
|
|
443
|
+
// step, and drives the lifecycle helper. Runs OUTSIDE the pre-save
|
|
444
|
+
// window so nested lifecycle saves cannot be clobbered by this
|
|
445
|
+
// tick's final in-memory save (R6 boundary guard).
|
|
446
|
+
self.record_step("attempt_api_error_recoveries");
|
|
447
|
+
crate::coordinator::steps::abnormal::attempt_due_recoveries(
|
|
448
|
+
self.workspace.as_path(),
|
|
449
|
+
&event_log,
|
|
450
|
+
self.transport.as_ref(),
|
|
451
|
+
);
|
|
452
|
+
|
|
440
453
|
self.record_step("collect_results");
|
|
441
454
|
collections.results =
|
|
442
455
|
collect_results(crate::messaging::collect_results_and_notify_watchers(
|