@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.
Files changed (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/models.rs +31 -0
  4. package/crates/team-agent/src/cli/status_port/brief.rs +40 -16
  5. package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
  6. package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
  7. package/crates/team-agent/src/cli/tests/status_brief.rs +199 -0
  8. package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
  9. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  10. package/crates/team-agent/src/coordinator/tests/s3_bounded.rs +239 -0
  11. package/crates/team-agent/src/coordinator/tick.rs +21 -13
  12. package/crates/team-agent/src/leader/start.rs +46 -74
  13. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +21 -5
  14. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
  15. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
  16. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +2 -11
  17. package/crates/team-agent/src/mcp_server/tools.rs +1 -3
  18. package/crates/team-agent/src/messaging/delivery.rs +154 -72
  19. package/crates/team-agent/src/messaging/results.rs +1 -2
  20. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
  21. package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
  22. package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
  23. package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
  24. package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
  25. package/crates/team-agent/src/state/mod.rs +1 -0
  26. package/crates/team-agent/src/state/persist.rs +47 -5
  27. package/crates/team-agent/src/state/projection.rs +31 -1
  28. package/crates/team-agent/src/state/repository/tests.rs +81 -0
  29. package/crates/team-agent/src/state/repository.rs +169 -1
  30. package/crates/team-agent/src/tmux_backend/tests.rs +28 -0
  31. package/crates/team-agent/src/tmux_backend.rs +31 -9
  32. package/crates/team-agent/src/transport.rs +6 -0
  33. 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 mut state = selected.state;
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 mut state = selected.state;
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
- .save_reapplying(
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
- 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")
@@ -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).save_reapplying(
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
+ }