@team-agent/installer 0.5.78 → 0.5.81
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/models.rs +31 -0
- package/crates/team-agent/src/cli/status_port/brief.rs +40 -16
- package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
- package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
- package/crates/team-agent/src/cli/tests/status_brief.rs +199 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/s3_bounded.rs +239 -0
- package/crates/team-agent/src/coordinator/tick.rs +21 -13
- package/crates/team-agent/src/leader/start.rs +46 -74
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +21 -5
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +2 -11
- package/crates/team-agent/src/mcp_server/tools.rs +1 -3
- package/crates/team-agent/src/messaging/delivery.rs +154 -72
- package/crates/team-agent/src/messaging/results.rs +1 -2
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
- package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
- package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
- package/crates/team-agent/src/state/mod.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +47 -5
- package/crates/team-agent/src/state/projection.rs +31 -1
- package/crates/team-agent/src/state/repository/tests.rs +81 -0
- package/crates/team-agent/src/state/repository.rs +169 -1
- package/crates/team-agent/src/tmux_backend/tests.rs +28 -0
- package/crates/team-agent/src/tmux_backend.rs +31 -9
- package/crates/team-agent/src/transport.rs +6 -0
- package/package.json +4 -4
|
@@ -24,14 +24,10 @@ pub(crate) fn update_state(
|
|
|
24
24
|
}
|
|
25
25
|
Err(err) => return Err(tool_runtime_error(err)),
|
|
26
26
|
};
|
|
27
|
-
let
|
|
28
|
-
ensure_object(&mut state);
|
|
29
|
-
append_note(&mut state, note);
|
|
30
|
-
crate::state::repository::StateRepository::new(&selected.run_workspace).save_reapplying(
|
|
27
|
+
let state = crate::state::repository::StateRepository::new(&selected.run_workspace).commit(
|
|
31
28
|
crate::state::repository::StateWriteIntent::McpUpdateStateNote {
|
|
32
29
|
team_key: Some(&selected.team_key),
|
|
33
30
|
},
|
|
34
|
-
&state,
|
|
35
31
|
|latest| {
|
|
36
32
|
ensure_object(latest);
|
|
37
33
|
append_note(latest, note);
|
|
@@ -65,15 +61,10 @@ fn update_state_without_spec(
|
|
|
65
61
|
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
66
62
|
)
|
|
67
63
|
.map_err(tool_runtime_error)?;
|
|
68
|
-
let
|
|
69
|
-
ensure_object(&mut state);
|
|
70
|
-
seed_legacy_team_key(&mut state, &selected.run_workspace, &selected.team_key);
|
|
71
|
-
append_note(&mut state, note);
|
|
72
|
-
crate::state::repository::StateRepository::new(&selected.run_workspace).save_reapplying(
|
|
64
|
+
let state = crate::state::repository::StateRepository::new(&selected.run_workspace).commit(
|
|
73
65
|
crate::state::repository::StateWriteIntent::McpUpdateStateNote {
|
|
74
66
|
team_key: Some(&selected.team_key),
|
|
75
67
|
},
|
|
76
|
-
&state,
|
|
77
68
|
|latest| {
|
|
78
69
|
ensure_object(latest);
|
|
79
70
|
seed_legacy_team_key(latest, &selected.run_workspace, &selected.team_key);
|
|
@@ -142,14 +142,12 @@ impl TeamOrchestratorTools {
|
|
|
142
142
|
.canonical_owner_team_key()?
|
|
143
143
|
.map(|team| team.as_str().to_string())
|
|
144
144
|
.or_else(|| assignment_team_key(&state));
|
|
145
|
-
reconcile_assigned_task(&mut state, team_key.as_deref(), &task_value);
|
|
146
145
|
crate::state::repository::StateRepository::new(&self.workspace)
|
|
147
|
-
.
|
|
146
|
+
.commit(
|
|
148
147
|
crate::state::repository::StateWriteIntent::McpAssignTask {
|
|
149
148
|
team_key: team_key.as_deref(),
|
|
150
149
|
task_id,
|
|
151
150
|
},
|
|
152
|
-
&state,
|
|
153
151
|
|latest| {
|
|
154
152
|
ensure_object(latest);
|
|
155
153
|
let latest_team_key = team_key.clone().or_else(|| assignment_team_key(latest));
|
|
@@ -220,31 +220,128 @@ pub fn deliver_pending_message(
|
|
|
220
220
|
turn_verification: None,
|
|
221
221
|
});
|
|
222
222
|
};
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
223
|
+
match prepare_message_state(
|
|
224
|
+
workspace, store, message_id, event_log, state, &message, false,
|
|
225
|
+
)? {
|
|
226
|
+
Ok(prepared) => deliver_prepared_message(
|
|
227
|
+
workspace, store, transport, message_id, event_log, state, message, prepared,
|
|
228
|
+
),
|
|
229
|
+
Err(outcome) => Ok(outcome),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Thread-local observation only; no production cache or scheduling hook.
|
|
234
|
+
#[cfg(test)]
|
|
235
|
+
pub(super) mod preparation_probe {
|
|
236
|
+
std::thread_local! {
|
|
237
|
+
pub static COUNTS: std::cell::Cell<(usize, usize, usize)> = const { std::cell::Cell::new((0, 0, 0)) };
|
|
238
|
+
pub static BEFORE_CLAIM: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = const { std::cell::RefCell::new(None) };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
struct PreparedMessageState {
|
|
243
|
+
scoped: Option<serde_json::Value>,
|
|
244
|
+
canonical_owner_team_id: Option<String>,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
fn prepare_message_state(
|
|
248
|
+
workspace: &Path,
|
|
249
|
+
store: &MessageStore,
|
|
250
|
+
message_id: &str,
|
|
251
|
+
event_log: &EventLog,
|
|
252
|
+
state: &serde_json::Value,
|
|
253
|
+
message: &PendingMessage,
|
|
254
|
+
batch: bool,
|
|
255
|
+
) -> Result<Result<PreparedMessageState, DeliveryOutcome>, MessagingError> {
|
|
256
|
+
#[cfg(test)]
|
|
257
|
+
preparation_probe::COUNTS.with(|counts| {
|
|
258
|
+
let (prepare, project, claim) = counts.get();
|
|
259
|
+
counts.set((prepare + 1, project, claim));
|
|
260
|
+
});
|
|
261
|
+
let mut prepared = PreparedMessageState {
|
|
262
|
+
scoped: None,
|
|
263
|
+
canonical_owner_team_id: message.owner_team_id.clone(),
|
|
264
|
+
};
|
|
265
|
+
if let Some(team) = message
|
|
266
|
+
.owner_team_id
|
|
267
|
+
.as_deref()
|
|
268
|
+
.filter(|team| !team.is_empty())
|
|
269
|
+
{
|
|
270
|
+
match project_state_for_owner_team(
|
|
271
|
+
workspace,
|
|
272
|
+
team,
|
|
273
|
+
state,
|
|
274
|
+
Some(store),
|
|
275
|
+
Some(message_id),
|
|
276
|
+
Some(event_log),
|
|
277
|
+
)? {
|
|
278
|
+
OwnerTeamProjection::Projected {
|
|
230
279
|
state,
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
Some(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
280
|
+
canonical_team,
|
|
281
|
+
} => {
|
|
282
|
+
prepared.scoped = Some(state);
|
|
283
|
+
prepared.canonical_owner_team_id = Some(canonical_team);
|
|
284
|
+
}
|
|
285
|
+
OwnerTeamProjection::Refused(outcome) => {
|
|
286
|
+
if batch {
|
|
287
|
+
let _ = event_log.write(
|
|
288
|
+
"delivery.projection_refused",
|
|
289
|
+
serde_json::json!({
|
|
290
|
+
"message_id": message_id,
|
|
291
|
+
"owner_team_id": team,
|
|
292
|
+
"status": super::helpers::status_wire(outcome.status),
|
|
293
|
+
"verification": outcome.verification,
|
|
294
|
+
"reason": format!("{:?}", outcome.reason),
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
242
297
|
}
|
|
243
|
-
|
|
298
|
+
return Ok(Err(outcome));
|
|
244
299
|
}
|
|
245
300
|
}
|
|
246
|
-
|
|
247
|
-
|
|
301
|
+
}
|
|
302
|
+
if batch
|
|
303
|
+
&& recipient_is_busy(
|
|
304
|
+
prepared.scoped.as_ref().unwrap_or(state),
|
|
305
|
+
&message.recipient,
|
|
306
|
+
)
|
|
307
|
+
{
|
|
308
|
+
event_log.write(
|
|
309
|
+
"send.deferred_busy",
|
|
310
|
+
serde_json::json!({
|
|
311
|
+
"message_id": message_id,
|
|
312
|
+
"sender": message.sender,
|
|
313
|
+
"recipient": message.recipient,
|
|
314
|
+
"reason": "recipient_busy",
|
|
315
|
+
}),
|
|
316
|
+
)?;
|
|
317
|
+
return Ok(Err(DeliveryOutcome {
|
|
318
|
+
ok: false,
|
|
319
|
+
status: DeliveryStatus::Queued,
|
|
320
|
+
message_status: MessageStatusShadow(message.status.clone()),
|
|
321
|
+
message_id: Some(message_id.to_string()),
|
|
322
|
+
verification: None,
|
|
323
|
+
stage: None,
|
|
324
|
+
reason: None,
|
|
325
|
+
channel: None,
|
|
326
|
+
ack_forced_off: false,
|
|
327
|
+
turn_verification: None,
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
Ok(Ok(prepared))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
fn deliver_prepared_message(
|
|
334
|
+
workspace: &Path,
|
|
335
|
+
store: &MessageStore,
|
|
336
|
+
transport: &dyn Transport,
|
|
337
|
+
message_id: &str,
|
|
338
|
+
event_log: &EventLog,
|
|
339
|
+
fallback: &serde_json::Value,
|
|
340
|
+
message: PendingMessage,
|
|
341
|
+
prepared: PreparedMessageState,
|
|
342
|
+
) -> Result<DeliveryOutcome, MessagingError> {
|
|
343
|
+
let canonical_owner_team_id = prepared.canonical_owner_team_id;
|
|
344
|
+
let state = prepared.scoped.as_ref().unwrap_or(fallback);
|
|
248
345
|
if message.recipient == "leader"
|
|
249
346
|
&& matches!(
|
|
250
347
|
message.status.as_str(),
|
|
@@ -291,6 +388,18 @@ pub fn deliver_pending_message(
|
|
|
291
388
|
}),
|
|
292
389
|
)?;
|
|
293
390
|
}
|
|
391
|
+
#[cfg(test)]
|
|
392
|
+
{
|
|
393
|
+
preparation_probe::BEFORE_CLAIM.with(|hook| {
|
|
394
|
+
if let Some(hook) = hook.borrow_mut().take() {
|
|
395
|
+
hook();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
preparation_probe::COUNTS.with(|counts| {
|
|
399
|
+
let (prepare, project, claim) = counts.get();
|
|
400
|
+
counts.set((prepare, project, claim + 1));
|
|
401
|
+
});
|
|
402
|
+
}
|
|
294
403
|
let attempt = if store.claim_for_delivery(message_id)? {
|
|
295
404
|
message.delivery_attempts.saturating_add(1)
|
|
296
405
|
} else {
|
|
@@ -1946,10 +2055,7 @@ fn merge_inject_readback_fields(
|
|
|
1946
2055
|
};
|
|
1947
2056
|
let inject_pane = target.and_then(inject_pane_id);
|
|
1948
2057
|
if let Some(pane) = inject_pane {
|
|
1949
|
-
obj.insert(
|
|
1950
|
-
"inject_target_pane_id".to_string(),
|
|
1951
|
-
serde_json::json!(pane),
|
|
1952
|
-
);
|
|
2058
|
+
obj.insert("inject_target_pane_id".to_string(), serde_json::json!(pane));
|
|
1953
2059
|
}
|
|
1954
2060
|
let resolved_pane = metadata.map(|meta| meta.target_pane_id.as_str());
|
|
1955
2061
|
obj.insert(
|
|
@@ -2190,59 +2296,30 @@ pub fn deliver_pending_messages(
|
|
|
2190
2296
|
};
|
|
2191
2297
|
let mut delivered = Vec::new();
|
|
2192
2298
|
for message_id in message_ids {
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
OwnerTeamProjection::Refused(outcome) => {
|
|
2210
|
-
let _ = event_log.write(
|
|
2211
|
-
"delivery.projection_refused",
|
|
2212
|
-
serde_json::json!({
|
|
2213
|
-
"message_id": message_id.as_str(),
|
|
2214
|
-
"owner_team_id": team,
|
|
2215
|
-
"status": super::helpers::status_wire(outcome.status),
|
|
2216
|
-
"verification": outcome.verification,
|
|
2217
|
-
"reason": format!("{:?}", outcome.reason),
|
|
2218
|
-
}),
|
|
2219
|
-
);
|
|
2220
|
-
continue;
|
|
2221
|
-
}
|
|
2222
|
-
}
|
|
2223
|
-
}
|
|
2224
|
-
_ => state,
|
|
2225
|
-
};
|
|
2226
|
-
if recipient_is_busy(state, &message.recipient) {
|
|
2227
|
-
event_log.write(
|
|
2228
|
-
"send.deferred_busy",
|
|
2229
|
-
serde_json::json!({
|
|
2230
|
-
"message_id": message_id,
|
|
2231
|
-
"sender": message.sender,
|
|
2232
|
-
"recipient": message.recipient,
|
|
2233
|
-
"reason": "recipient_busy",
|
|
2234
|
-
}),
|
|
2235
|
-
)?;
|
|
2236
|
-
continue;
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2239
|
-
let outcome = match deliver_pending_message(
|
|
2299
|
+
let Some(message) = message_for_delivery(&store, &message_id)? else {
|
|
2300
|
+
continue;
|
|
2301
|
+
};
|
|
2302
|
+
let prepared = match prepare_message_state(
|
|
2303
|
+
workspace,
|
|
2304
|
+
&store,
|
|
2305
|
+
&message_id,
|
|
2306
|
+
event_log,
|
|
2307
|
+
state,
|
|
2308
|
+
&message,
|
|
2309
|
+
true,
|
|
2310
|
+
)? {
|
|
2311
|
+
Ok(prepared) => prepared,
|
|
2312
|
+
Err(_) => continue,
|
|
2313
|
+
};
|
|
2314
|
+
let outcome = match deliver_prepared_message(
|
|
2240
2315
|
workspace,
|
|
2241
2316
|
&store,
|
|
2242
2317
|
transport,
|
|
2243
2318
|
&message_id,
|
|
2244
2319
|
event_log,
|
|
2245
2320
|
state,
|
|
2321
|
+
message,
|
|
2322
|
+
prepared,
|
|
2246
2323
|
) {
|
|
2247
2324
|
Ok(outcome) => outcome,
|
|
2248
2325
|
Err(error) => {
|
|
@@ -2963,6 +3040,11 @@ fn project_state_for_owner_team(
|
|
|
2963
3040
|
message_id: Option<&str>,
|
|
2964
3041
|
event_log: Option<&EventLog>,
|
|
2965
3042
|
) -> Result<OwnerTeamProjection, MessagingError> {
|
|
3043
|
+
#[cfg(test)]
|
|
3044
|
+
preparation_probe::COUNTS.with(|counts| {
|
|
3045
|
+
let (prepare, project, claim) = counts.get();
|
|
3046
|
+
counts.set((prepare, project + 1, claim));
|
|
3047
|
+
});
|
|
2966
3048
|
let raw = crate::state::persist::load_runtime_state(workspace)?;
|
|
2967
3049
|
let fallback_has_teams = fallback
|
|
2968
3050
|
.get("teams")
|
|
@@ -287,9 +287,8 @@ fn collect_scoped(
|
|
|
287
287
|
collected_results.push(summary);
|
|
288
288
|
}
|
|
289
289
|
if state_dirty {
|
|
290
|
-
crate::state::repository::StateRepository::new(&paths.run_workspace).
|
|
290
|
+
state = crate::state::repository::StateRepository::new(&paths.run_workspace).commit(
|
|
291
291
|
crate::state::repository::StateWriteIntent::ResultCollection { owner_team_id },
|
|
292
|
-
&state,
|
|
293
292
|
|latest| {
|
|
294
293
|
for (task_id, result_id) in &task_updates {
|
|
295
294
|
mark_task_done(latest, task_id, result_id);
|
|
@@ -556,3 +556,130 @@ impl Transport for RecordingTransport {
|
|
|
556
556
|
Ok(AttachOutcome::Attached)
|
|
557
557
|
}
|
|
558
558
|
}
|
|
559
|
+
|
|
560
|
+
#[test]
|
|
561
|
+
fn resource188_batch_and_public_single_prepare_once_and_keep_fresh_leader_load() {
|
|
562
|
+
use crate::messaging::delivery::preparation_probe::COUNTS;
|
|
563
|
+
for batch in [true, false] {
|
|
564
|
+
let case = Case::new(if batch { "188-batch" } else { "188-single" });
|
|
565
|
+
let transport = RecordingTransport::new("");
|
|
566
|
+
let message = case
|
|
567
|
+
.store
|
|
568
|
+
.create_message(
|
|
569
|
+
None,
|
|
570
|
+
"worker",
|
|
571
|
+
"leader",
|
|
572
|
+
"count",
|
|
573
|
+
None,
|
|
574
|
+
false,
|
|
575
|
+
Some("acceptance-team"),
|
|
576
|
+
)
|
|
577
|
+
.unwrap();
|
|
578
|
+
COUNTS.with(|counts| counts.set((0, 0, 0)));
|
|
579
|
+
if batch {
|
|
580
|
+
deliver_pending_messages(&case.workspace, &case.state, &transport, &case.event_log)
|
|
581
|
+
.unwrap();
|
|
582
|
+
} else {
|
|
583
|
+
deliver_pending_message(
|
|
584
|
+
&case.workspace,
|
|
585
|
+
&case.store,
|
|
586
|
+
&transport,
|
|
587
|
+
&message,
|
|
588
|
+
&case.event_log,
|
|
589
|
+
&case.state,
|
|
590
|
+
)
|
|
591
|
+
.unwrap();
|
|
592
|
+
}
|
|
593
|
+
assert_eq!(
|
|
594
|
+
COUNTS.with(|counts| counts.get()),
|
|
595
|
+
(1, 2, 1),
|
|
596
|
+
"one preparation, pre/post-claim projections, one claim"
|
|
597
|
+
);
|
|
598
|
+
assert_eq!(transport.inject_count(), 1);
|
|
599
|
+
assert_eq!(
|
|
600
|
+
case.message_status(&message),
|
|
601
|
+
"submitted_pending_acceptance"
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
#[test]
|
|
607
|
+
fn resource188_busy_and_refused_candidates_prepare_once_without_claiming() {
|
|
608
|
+
use crate::messaging::delivery::preparation_probe::COUNTS;
|
|
609
|
+
let mut case = Case::new("188-busy");
|
|
610
|
+
case.state["agents"] = serde_json::json!({"worker": {"agent_id": "worker", "status": "busy"}});
|
|
611
|
+
case.state["teams"] =
|
|
612
|
+
serde_json::json!({"acceptance-team": {"agents": case.state["agents"].clone()}});
|
|
613
|
+
crate::state::persist::save_runtime_state(&case.workspace, &case.state).unwrap();
|
|
614
|
+
let transport = RecordingTransport::new("");
|
|
615
|
+
let busy = case
|
|
616
|
+
.store
|
|
617
|
+
.create_message(
|
|
618
|
+
None,
|
|
619
|
+
"leader",
|
|
620
|
+
"worker",
|
|
621
|
+
"busy",
|
|
622
|
+
None,
|
|
623
|
+
false,
|
|
624
|
+
Some("acceptance-team"),
|
|
625
|
+
)
|
|
626
|
+
.unwrap();
|
|
627
|
+
let refused = case
|
|
628
|
+
.store
|
|
629
|
+
.create_message(
|
|
630
|
+
None,
|
|
631
|
+
"leader",
|
|
632
|
+
"worker",
|
|
633
|
+
"refuse",
|
|
634
|
+
None,
|
|
635
|
+
false,
|
|
636
|
+
Some("missing-team"),
|
|
637
|
+
)
|
|
638
|
+
.unwrap();
|
|
639
|
+
let before = case.message_status(&busy);
|
|
640
|
+
COUNTS.with(|counts| counts.set((0, 0, 0)));
|
|
641
|
+
deliver_pending_messages(&case.workspace, &case.state, &transport, &case.event_log).unwrap();
|
|
642
|
+
assert_eq!(COUNTS.with(|counts| counts.get()), (2, 2, 0));
|
|
643
|
+
assert_eq!(case.message_status(&busy), before);
|
|
644
|
+
assert_ne!(case.message_status(&refused), "delivered");
|
|
645
|
+
assert_eq!(transport.inject_count(), 0);
|
|
646
|
+
assert!(case.events().contains("send.deferred_busy"));
|
|
647
|
+
assert!(case.events().contains("delivery.projection_refused"));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
#[test]
|
|
651
|
+
fn resource188_receiver_revoked_between_prepare_and_claim_is_reloaded() {
|
|
652
|
+
use crate::messaging::delivery::preparation_probe::{BEFORE_CLAIM, COUNTS};
|
|
653
|
+
let case = Case::new("188-revoke");
|
|
654
|
+
let transport = RecordingTransport::new("");
|
|
655
|
+
let message = case
|
|
656
|
+
.store
|
|
657
|
+
.create_message(
|
|
658
|
+
None,
|
|
659
|
+
"worker",
|
|
660
|
+
"leader",
|
|
661
|
+
"race",
|
|
662
|
+
None,
|
|
663
|
+
false,
|
|
664
|
+
Some("acceptance-team"),
|
|
665
|
+
)
|
|
666
|
+
.unwrap();
|
|
667
|
+
let workspace = case.workspace.clone();
|
|
668
|
+
BEFORE_CLAIM.with(|hook| {
|
|
669
|
+
*hook.borrow_mut() = Some(Box::new(move || {
|
|
670
|
+
let mut latest = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
671
|
+
latest["leader_receiver"]["status"] = serde_json::json!("rebind_required");
|
|
672
|
+
crate::state::persist::save_runtime_state(&workspace, &latest).unwrap();
|
|
673
|
+
}))
|
|
674
|
+
});
|
|
675
|
+
COUNTS.with(|counts| counts.set((0, 0, 0)));
|
|
676
|
+
deliver_pending_messages(&case.workspace, &case.state, &transport, &case.event_log).unwrap();
|
|
677
|
+
assert_eq!(COUNTS.with(|counts| counts.get()), (1, 2, 1));
|
|
678
|
+
assert_eq!(
|
|
679
|
+
transport.inject_count(),
|
|
680
|
+
0,
|
|
681
|
+
"must not use the prepared attached receiver after revoke"
|
|
682
|
+
);
|
|
683
|
+
assert_eq!(case.message_status(&message), "failed");
|
|
684
|
+
assert!(case.events().contains("leader_receiver.delivery_blocked"));
|
|
685
|
+
}
|
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
use super::*;
|
|
2
2
|
|
|
3
|
+
#[derive(Default, Debug)]
|
|
4
|
+
struct EnumerationCounts {
|
|
5
|
+
directories: Vec<PathBuf>,
|
|
6
|
+
entries: usize,
|
|
7
|
+
following_metadata: usize,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
thread_local! {
|
|
11
|
+
static ENUMERATION: std::cell::RefCell<EnumerationCounts> =
|
|
12
|
+
std::cell::RefCell::new(EnumerationCounts::default());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
pub(super) fn record_directory_read(path: &Path) {
|
|
16
|
+
ENUMERATION.with(|counts| counts.borrow_mut().directories.push(path.to_path_buf()));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
pub(super) fn record_entry() {
|
|
20
|
+
ENUMERATION.with(|counts| counts.borrow_mut().entries += 1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
pub(super) fn record_following_metadata() {
|
|
24
|
+
ENUMERATION.with(|counts| counts.borrow_mut().following_metadata += 1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn take_counts() -> EnumerationCounts {
|
|
28
|
+
ENUMERATION.with(|counts| std::mem::take(&mut *counts.borrow_mut()))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The previous traversal boundary, run only on the synthetic fixture. Count
|
|
32
|
+
// explicit metadata calls, not inferred OS syscalls or elapsed-time thresholds.
|
|
33
|
+
fn legacy_enumeration_counts(dir: &Path, depth: usize, counts: &mut EnumerationCounts) {
|
|
34
|
+
if depth > 4 {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
counts.directories.push(dir.to_path_buf());
|
|
38
|
+
for entry in std::fs::read_dir(dir).unwrap() {
|
|
39
|
+
let path = entry.unwrap().path();
|
|
40
|
+
counts.entries += 1;
|
|
41
|
+
counts.following_metadata += 1;
|
|
42
|
+
if path.is_dir() {
|
|
43
|
+
legacy_enumeration_counts(&path, depth + 1, counts);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
3
48
|
fn temp_dir(name: &str) -> PathBuf {
|
|
4
49
|
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
5
50
|
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
@@ -110,3 +155,162 @@ fn codex_prompt_worker_identity_mismatch_is_rejected_for_that_agent() {
|
|
|
110
155
|
assert!(candidates.is_empty());
|
|
111
156
|
let _ = std::fs::remove_dir_all(&dir);
|
|
112
157
|
}
|
|
158
|
+
|
|
159
|
+
#[test]
|
|
160
|
+
fn repeated_wide_directory_scans_remove_leaf_metadata_without_hiding_new_files() {
|
|
161
|
+
let dir = temp_dir("wide-discovery");
|
|
162
|
+
for group in 0..4 {
|
|
163
|
+
let child = dir.join("source").join(format!("group-{group}"));
|
|
164
|
+
std::fs::create_dir_all(&child).unwrap();
|
|
165
|
+
for index in 0..128 {
|
|
166
|
+
std::fs::write(child.join(format!("code-{index}.rs")), "").unwrap();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for _ in 0..2 {
|
|
170
|
+
let mut before = EnumerationCounts::default();
|
|
171
|
+
legacy_enumeration_counts(&dir, 0, &mut before);
|
|
172
|
+
assert_eq!((before.directories.len(), before.entries), (6, 517));
|
|
173
|
+
assert_eq!(before.following_metadata, 517);
|
|
174
|
+
|
|
175
|
+
take_counts();
|
|
176
|
+
let mut candidates = Vec::new();
|
|
177
|
+
collect_candidate_files(&dir, "worker", 0, false, None, &mut candidates).unwrap();
|
|
178
|
+
let after = take_counts();
|
|
179
|
+
assert!(candidates.is_empty());
|
|
180
|
+
assert_eq!((after.directories.len(), after.entries), (6, 517));
|
|
181
|
+
assert_eq!(after.following_metadata, 0);
|
|
182
|
+
}
|
|
183
|
+
// No .team subtree is needed to reproduce the redundant metadata work.
|
|
184
|
+
// A negative scan must not suppress a later file in the same nested cwd.
|
|
185
|
+
let rollout = dir.join("source/group-3/rollout-worker.jsonl");
|
|
186
|
+
write_codex_rollout(&rollout, &dir, "late-worker", "worker");
|
|
187
|
+
let mut candidates = Vec::new();
|
|
188
|
+
collect_candidate_files(&dir, "worker", 0, false, None, &mut candidates).unwrap();
|
|
189
|
+
let after = take_counts();
|
|
190
|
+
assert_eq!((after.directories.len(), after.entries), (6, 518));
|
|
191
|
+
assert_eq!(after.following_metadata, 0);
|
|
192
|
+
assert_eq!(candidates.len(), 1);
|
|
193
|
+
assert_eq!(candidates[0].path, rollout);
|
|
194
|
+
assert!(!candidates[0].requires_cwd_match);
|
|
195
|
+
std::fs::remove_dir_all(dir).unwrap();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#[test]
|
|
199
|
+
fn team_subtrees_are_pruned_before_enumeration_but_claude_root_and_ancestors_survive() {
|
|
200
|
+
let dir = temp_dir("team-pruning");
|
|
201
|
+
let allowed = dir.join(".team/runtime/claude/projects");
|
|
202
|
+
let owned = allowed.join("cwd/owned.jsonl");
|
|
203
|
+
let unrelated = dir.join(".team/artifacts/deep/ignored.jsonl");
|
|
204
|
+
let sibling = dir.join(".team/runtime/other/ignored.jsonl");
|
|
205
|
+
for path in [&owned, &unrelated, &sibling] {
|
|
206
|
+
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
207
|
+
std::fs::write(path, "{}").unwrap();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let mut before = EnumerationCounts::default();
|
|
211
|
+
legacy_enumeration_counts(&dir, 0, &mut before);
|
|
212
|
+
assert_eq!((before.directories.len(), before.entries), (8, 10));
|
|
213
|
+
assert_eq!(before.following_metadata, 10);
|
|
214
|
+
|
|
215
|
+
take_counts();
|
|
216
|
+
let mut candidates = Vec::new();
|
|
217
|
+
collect_candidate_files(&dir, "worker", 0, false, None, &mut candidates).unwrap();
|
|
218
|
+
let counts = take_counts();
|
|
219
|
+
assert!(candidates.is_empty());
|
|
220
|
+
assert_eq!(counts.directories, vec![dir.clone()]);
|
|
221
|
+
assert_eq!((counts.entries, counts.following_metadata), (1, 0));
|
|
222
|
+
|
|
223
|
+
// Starting at the runtime ancestor must reach the explicit Claude root,
|
|
224
|
+
// while siblings inside .team remain excluded before read_dir.
|
|
225
|
+
let ancestor = dir.join(".team");
|
|
226
|
+
collect_candidate_files(&ancestor, "worker", 0, true, Some(&allowed), &mut candidates)
|
|
227
|
+
.unwrap();
|
|
228
|
+
let counts = take_counts();
|
|
229
|
+
assert_eq!(counts.directories.len(), 5);
|
|
230
|
+
assert_eq!((counts.entries, counts.following_metadata), (7, 0));
|
|
231
|
+
assert!(!counts
|
|
232
|
+
.directories
|
|
233
|
+
.iter()
|
|
234
|
+
.any(|path| path.starts_with(dir.join(".team/artifacts"))));
|
|
235
|
+
assert!(!counts
|
|
236
|
+
.directories
|
|
237
|
+
.iter()
|
|
238
|
+
.any(|path| path.starts_with(dir.join(".team/runtime/other"))));
|
|
239
|
+
assert_eq!(candidates.len(), 1);
|
|
240
|
+
assert_eq!(candidates[0].path, owned);
|
|
241
|
+
assert!(candidates[0].requires_cwd_match);
|
|
242
|
+
|
|
243
|
+
candidates.clear();
|
|
244
|
+
collect_optional_candidate_files(&allowed, "worker", Some(&allowed), &mut candidates)
|
|
245
|
+
.unwrap();
|
|
246
|
+
assert_eq!(take_counts().directories.len(), 2);
|
|
247
|
+
assert_eq!(candidates.len(), 1);
|
|
248
|
+
std::fs::remove_dir_all(dir).unwrap();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#[test]
|
|
252
|
+
fn traversal_keeps_depth_limit_and_required_root_errors() {
|
|
253
|
+
let dir = temp_dir("depth-limit");
|
|
254
|
+
let at_limit = dir.join("one/two/three/four/at-limit.jsonl");
|
|
255
|
+
let beyond = dir.join("one/two/three/four/five/beyond.jsonl");
|
|
256
|
+
std::fs::create_dir_all(beyond.parent().unwrap()).unwrap();
|
|
257
|
+
std::fs::write(&at_limit, "{}").unwrap();
|
|
258
|
+
std::fs::write(&beyond, "{}").unwrap();
|
|
259
|
+
take_counts();
|
|
260
|
+
let mut candidates = Vec::new();
|
|
261
|
+
collect_candidate_files(&dir, "worker", 0, false, None, &mut candidates).unwrap();
|
|
262
|
+
let counts = take_counts();
|
|
263
|
+
assert_eq!(counts.directories.len(), 5);
|
|
264
|
+
assert_eq!(counts.following_metadata, 0);
|
|
265
|
+
assert_eq!(candidates.len(), 1);
|
|
266
|
+
assert_eq!(candidates[0].path, at_limit);
|
|
267
|
+
assert!(collect_candidate_files(
|
|
268
|
+
&dir.join("missing"),
|
|
269
|
+
"worker",
|
|
270
|
+
0,
|
|
271
|
+
false,
|
|
272
|
+
None,
|
|
273
|
+
&mut Vec::new()
|
|
274
|
+
)
|
|
275
|
+
.is_err());
|
|
276
|
+
assert!(collect_optional_candidate_files(
|
|
277
|
+
&dir.join("missing"),
|
|
278
|
+
"worker",
|
|
279
|
+
None,
|
|
280
|
+
&mut Vec::new()
|
|
281
|
+
)
|
|
282
|
+
.is_ok());
|
|
283
|
+
std::fs::remove_dir_all(dir).unwrap();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#[test]
|
|
287
|
+
#[cfg(unix)]
|
|
288
|
+
fn symlink_files_directories_and_cycles_keep_following_metadata_and_depth_semantics() {
|
|
289
|
+
use std::os::unix::fs::symlink;
|
|
290
|
+
let dir = temp_dir("symlink-discovery");
|
|
291
|
+
let target = dir.join("target");
|
|
292
|
+
std::fs::create_dir(&target).unwrap();
|
|
293
|
+
std::fs::write(target.join("session.jsonl"), "{}").unwrap();
|
|
294
|
+
symlink(&target, dir.join("linked-dir")).unwrap();
|
|
295
|
+
symlink(target.join("session.jsonl"), dir.join("linked-file.jsonl")).unwrap();
|
|
296
|
+
symlink(dir.join("absent"), dir.join("dangling.jsonl")).unwrap();
|
|
297
|
+
symlink(&target, target.join("cycle")).unwrap();
|
|
298
|
+
take_counts();
|
|
299
|
+
let mut candidates = Vec::new();
|
|
300
|
+
collect_candidate_files(&dir, "worker", 0, false, None, &mut candidates).unwrap();
|
|
301
|
+
let counts = take_counts();
|
|
302
|
+
assert_eq!(counts.directories.len(), 9);
|
|
303
|
+
assert_eq!(counts.entries, 20);
|
|
304
|
+
assert_eq!(counts.following_metadata, 11);
|
|
305
|
+
assert_eq!(candidates.len(), 10);
|
|
306
|
+
assert!(candidates
|
|
307
|
+
.iter()
|
|
308
|
+
.any(|item| item.path == dir.join("linked-file.jsonl")));
|
|
309
|
+
assert!(candidates
|
|
310
|
+
.iter()
|
|
311
|
+
.any(|item| item.path == dir.join("linked-dir/session.jsonl")));
|
|
312
|
+
assert!(candidates
|
|
313
|
+
.iter()
|
|
314
|
+
.any(|item| item.path == dir.join("dangling.jsonl")));
|
|
315
|
+
std::fs::remove_dir_all(dir).unwrap();
|
|
316
|
+
}
|