@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.
@@ -220,31 +220,128 @@ pub fn deliver_pending_message(
220
220
  turn_verification: None,
221
221
  });
222
222
  };
223
- let mut canonical_owner_team_id = message.owner_team_id.clone();
224
- let scoped_state;
225
- let state = match message.owner_team_id.as_deref() {
226
- Some(team) if !team.is_empty() => {
227
- match project_state_for_owner_team(
228
- workspace,
229
- team,
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
- Some(store),
232
- Some(message_id),
233
- Some(event_log),
234
- )? {
235
- OwnerTeamProjection::Projected {
236
- state,
237
- canonical_team,
238
- } => {
239
- canonical_owner_team_id = Some(canonical_team);
240
- scoped_state = state;
241
- &scoped_state
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
- OwnerTeamProjection::Refused(outcome) => return Ok(outcome),
298
+ return Ok(Err(outcome));
244
299
  }
245
300
  }
246
- _ => state,
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
- if let Some(message) = message_for_delivery(&store, &message_id)? {
2194
- let scoped_state;
2195
- let state = match message.owner_team_id.as_deref() {
2196
- Some(team) if !team.is_empty() => {
2197
- match project_state_for_owner_team(
2198
- workspace,
2199
- team,
2200
- state,
2201
- Some(&store),
2202
- Some(&message_id),
2203
- Some(event_log),
2204
- )? {
2205
- OwnerTeamProjection::Projected { state, .. } => {
2206
- scoped_state = state;
2207
- &scoped_state
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.is_dir() {
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))