@team-agent/installer 0.5.78 → 0.5.80
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/runtime.rs +3 -1
- package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
- 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/messaging/delivery.rs +154 -72
- 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 +5 -3
- package/crates/team-agent/src/state/projection.rs +7 -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
|
@@ -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")
|
|
@@ -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
|
+
}
|
|
@@ -441,9 +441,11 @@ fn collect_candidate_files(
|
|
|
441
441
|
allowed_team_root: Option<&Path>,
|
|
442
442
|
out: &mut Vec<SessionCandidate>,
|
|
443
443
|
) -> Result<(), ProviderError> {
|
|
444
|
-
if depth > 4 {
|
|
444
|
+
if depth > 4 || !may_traverse_session_directory(dir, allowed_team_root) {
|
|
445
445
|
return Ok(());
|
|
446
446
|
}
|
|
447
|
+
#[cfg(test)]
|
|
448
|
+
tests::record_directory_read(dir);
|
|
447
449
|
let entries = match std::fs::read_dir(dir) {
|
|
448
450
|
Ok(entries) => entries,
|
|
449
451
|
Err(e) if depth == 0 => return Err(ProviderError::Io(format!("{}: {e}", dir.display()))),
|
|
@@ -453,8 +455,23 @@ fn collect_candidate_files(
|
|
|
453
455
|
let Ok(entry) = entry else {
|
|
454
456
|
continue;
|
|
455
457
|
};
|
|
458
|
+
#[cfg(test)]
|
|
459
|
+
tests::record_entry();
|
|
456
460
|
let path = entry.path();
|
|
457
|
-
if path
|
|
461
|
+
if !may_traverse_session_directory(&path, allowed_team_root) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
// read_dir already supplies the type for ordinary entries. Only links
|
|
465
|
+
// (or an unavailable type) need the old, following metadata lookup.
|
|
466
|
+
let is_dir = match entry.file_type() {
|
|
467
|
+
Ok(kind) if !kind.is_symlink() => kind.is_dir(),
|
|
468
|
+
_ => {
|
|
469
|
+
#[cfg(test)]
|
|
470
|
+
tests::record_following_metadata();
|
|
471
|
+
path.is_dir()
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
if is_dir {
|
|
458
475
|
collect_candidate_files(
|
|
459
476
|
&path,
|
|
460
477
|
agent_id,
|
|
@@ -473,6 +490,11 @@ fn collect_candidate_files(
|
|
|
473
490
|
Ok(())
|
|
474
491
|
}
|
|
475
492
|
|
|
493
|
+
fn may_traverse_session_directory(path: &Path, allowed_team_root: Option<&Path>) -> bool {
|
|
494
|
+
!path_is_under_team_runtime(path)
|
|
495
|
+
|| allowed_team_root.is_some_and(|root| path.starts_with(root) || root.starts_with(path))
|
|
496
|
+
}
|
|
497
|
+
|
|
476
498
|
fn looks_like_session_file(path: &Path, agent_id: &str, allowed_team_root: Option<&Path>) -> bool {
|
|
477
499
|
if path_is_under_team_runtime(path)
|
|
478
500
|
&& !allowed_team_root.is_some_and(|root| path.starts_with(root))
|