@team-agent/installer 0.5.77 → 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.
Files changed (95) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +161 -29
  4. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  5. package/crates/team-agent/src/cli/emit.rs +24 -8
  6. package/crates/team-agent/src/cli/mod.rs +543 -98
  7. package/crates/team-agent/src/cli/models.rs +393 -32
  8. package/crates/team-agent/src/cli/named_address.rs +19 -5
  9. package/crates/team-agent/src/cli/profile.rs +35 -4
  10. package/crates/team-agent/src/cli/spec.rs +7 -2
  11. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  12. package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
  13. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  14. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  15. package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  17. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  18. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  19. package/crates/team-agent/src/cli/types.rs +1 -0
  20. package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
  21. package/crates/team-agent/src/leader/lease.rs +492 -33
  22. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  23. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  24. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  25. package/crates/team-agent/src/leader/start.rs +197 -88
  26. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  27. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  28. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  29. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  30. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  31. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +86 -23
  32. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  33. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  34. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  35. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  36. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  37. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  38. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
  42. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  43. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  44. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  45. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  46. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  47. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
  48. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  49. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  50. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  51. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  52. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  53. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  54. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
  55. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  56. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  57. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  58. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  59. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  60. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
  61. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  62. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  63. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  64. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
  65. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
  66. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  67. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  68. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  69. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  70. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  71. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  72. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  73. package/crates/team-agent/src/messaging/delivery.rs +221 -80
  74. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  75. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
  76. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  77. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  78. package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
  79. package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
  80. package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
  81. package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
  82. package/crates/team-agent/src/state/mod.rs +1 -0
  83. package/crates/team-agent/src/state/persist.rs +102 -4
  84. package/crates/team-agent/src/state/projection.rs +7 -1
  85. package/crates/team-agent/src/state/repository.rs +29 -0
  86. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
  87. package/crates/team-agent/src/tmux_backend/tests.rs +149 -4
  88. package/crates/team-agent/src/tmux_backend.rs +239 -127
  89. package/crates/team-agent/src/transport.rs +78 -0
  90. package/crates/team-agent/src/transport_factory.rs +53 -0
  91. package/package.json +4 -5
  92. package/skills/team-agent/SKILL.md +45 -17
  93. package/skills/team-agent/command-coverage.json +4 -29
  94. package/npm/check-skill-links.mjs +0 -88
  95. package/skills/team-agent/references/team-agent-operator.md +0 -672
@@ -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
+ }
@@ -3376,10 +3376,18 @@ fn gate054_status_surfaces_pending_leader_notifications() {
3376
3376
  entry.get("channel").and_then(serde_json::Value::as_str),
3377
3377
  Some("rebind_required")
3378
3378
  );
3379
- assert!(entry
3379
+ let action = entry
3380
3380
  .get("action")
3381
3381
  .and_then(serde_json::Value::as_str)
3382
- .is_some_and(|action| action.contains("attach-leader") || action.contains("takeover")));
3382
+ .unwrap_or("");
3383
+ assert!(
3384
+ action.contains("historical leader mailbox debt"),
3385
+ "historical debt action must stay debt-scoped; action={action}"
3386
+ );
3387
+ assert!(
3388
+ !action.contains("attach-leader") && !action.contains("takeover") && !action.contains("claim-leader"),
3389
+ "historical debt must not authorize bind commands; action={action}"
3390
+ );
3383
3391
  }
3384
3392
 
3385
3393
  #[test]
@@ -15,10 +15,8 @@
15
15
  //! `.team/scripts/cursor_seat.sh` 实测一致:
16
16
  //! `--trust --sandbox disabled --workspace <物理路径> [--force] [--model]`
17
17
  //! Role 不入 argv,写 `<workspace>/.cursor/rules/*.mdc` + `alwaysApply: true`。
18
- //! MCP 无 `--mcp-config`;身份必须写进 per-seat
19
- //! `provider-config/<id>/cursor/.cursor/mcp.json` 的 env 表
18
+ //! MCP 无 `--mcp-config`;身份必须写进 `.cursor/mcp.json` 的 env 表
20
19
  //! (cursor 不把父进程 TEAM_AGENT_* 传给 MCP 子进程)。
21
- //! spawn 把 `--workspace` 指到该工程根,并用 `--add-dir` 挂上真 workspace。
22
20
  //!
23
21
  //! 隐藏 flag `--system-prompt` / `--allowed-tools` 实测存在但 help 未列,
24
22
  //! 随时可能消失。可以在注释里记录,代码里不许调。
@@ -75,8 +73,7 @@ pub(crate) fn cursor_agent_base_command(
75
73
  let _ = effort;
76
74
  // system_prompt 不入 argv(help 无 --rules / --append-system-prompt)。
77
75
  // launch 写 `<workspace>/.cursor/rules/team-agent-role-<agent_id>.mdc`。
78
- // mcp_config 也不入 argv(无 --mcp-config)。launch 写
79
- // `.team/runtime/provider-config/<id>/cursor/.cursor/mcp.json`
76
+ // mcp_config 也不入 argv(无 --mcp-config)。launch 写 `.cursor/mcp.json`
80
77
  // 并 `agent mcp enable team_orchestrator`。这里收下以免静默丢弃。
81
78
  let _ = (adapter, auth_mode, mcp_config, managed_mcp_config, system_prompt);
82
79
  argv.push("--workspace".to_string());
@@ -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))
@@ -0,0 +1,248 @@
1
+ use super::*;
2
+ use crate::state::persist::{load_runtime_state, runtime_state_path, save_runtime_state};
3
+
4
+ fn workspace() -> std::path::PathBuf {
5
+ use std::sync::atomic::{AtomicUsize, Ordering};
6
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
7
+ let path = std::env::temp_dir().join(format!(
8
+ "watch-188-{}-{}",
9
+ std::process::id(),
10
+ NEXT.fetch_add(1, Ordering::Relaxed)
11
+ ));
12
+ std::fs::create_dir_all(path.join(".team/runtime")).unwrap();
13
+ path
14
+ }
15
+
16
+ #[test]
17
+ fn complete_empty_roster_prunes_but_missing_roster_and_paused_members_survive() {
18
+ let mut state = json!({
19
+ "agents": {},
20
+ "teams": {
21
+ "empty": {"agents": {}, "coordinator": {"abnormal_exit_watch": {"old": {}}}},
22
+ "unknown": {"coordinator": {"abnormal_exit_watch": {"old": {}}}},
23
+ "paused": {"agents": {"p": {"status": "paused"}}, "coordinator": {
24
+ "abnormal_exit_watch": {"p": {"last_notified_key": "keep"}, "gone": {}}
25
+ }}
26
+ },
27
+ "coordinator": {"abnormal_exit_watch": {"unknown": {}}}
28
+ });
29
+ compact_after_merge(&mut state, None, &[]);
30
+ assert_eq!(
31
+ state["teams"]["empty"]["coordinator"]["abnormal_exit_watch"],
32
+ json!({})
33
+ );
34
+ assert_eq!(
35
+ state["teams"]["unknown"]["coordinator"]["abnormal_exit_watch"],
36
+ json!({"old": {}})
37
+ );
38
+ assert_eq!(
39
+ state["teams"]["paused"]["coordinator"]["abnormal_exit_watch"],
40
+ json!({"p": {"last_notified_key": "keep"}})
41
+ );
42
+ assert_eq!(
43
+ state["coordinator"]["abnormal_exit_watch"],
44
+ json!({"unknown": {}})
45
+ );
46
+ assert!(!needs_compaction(&state));
47
+ }
48
+
49
+ #[test]
50
+ fn root_legacy_union_is_not_migrated_by_bare_agent_name() {
51
+ let mut state = json!({"agents": {}, "teams": {
52
+ "a": {"agents": {"w": {}}}, "b": {"agents": {"w": {}}}
53
+ }, "coordinator": {"abnormal_exit_watch": {"w": {"last_notified_key": "legacy"}, "gone": {}}}});
54
+ compact_after_merge(&mut state, None, &[]);
55
+ assert_eq!(
56
+ state["coordinator"]["abnormal_exit_watch"]
57
+ .as_object()
58
+ .unwrap()
59
+ .len(),
60
+ 1
61
+ );
62
+ for team in ["a", "b"] {
63
+ let projection = crate::state::projection::project_top_level_view(&state, team);
64
+ assert!(projection["coordinator"]
65
+ .get("abnormal_exit_watch")
66
+ .is_none());
67
+ }
68
+ }
69
+
70
+ #[test]
71
+ fn fallback_preserves_other_coordinator_fields_and_owned_watch() {
72
+ let state = json!({"coordinator": {"tick": 9, "abnormal_exit_watch": {"w": {}}}, "teams": {
73
+ "a": {"agents": {}},
74
+ "b": {"agents": {}, "coordinator": {"abnormal_exit_watch": {"owned": {}}, "tick": 2}}
75
+ }});
76
+ let a = crate::state::projection::project_top_level_view(&state, "a");
77
+ let b = crate::state::projection::project_top_level_view(&state, "b");
78
+ assert_eq!(a["coordinator"], json!({"tick": 9}));
79
+ assert_eq!(b["coordinator"], state["teams"]["b"]["coordinator"]);
80
+ }
81
+
82
+ fn observed() -> Value {
83
+ let mut state = json!({"active_team_key": "a", "agents": {"w": {
84
+ "provider": "codex", "rollout_path": "/fixture/w.jsonl", "spawn_epoch": 1
85
+ }}, "coordinator": {"abnormal_exit_watch": {"w": {"last_notified_key": "keep"}}}});
86
+ state["coordinator"]["abnormal_exit_watch"]["w"]["watch_identity"] =
87
+ observation_identity(&state, "w").unwrap();
88
+ state
89
+ }
90
+
91
+ #[test]
92
+ fn observation_identity_separates_same_name_team_path_and_spawn_generation() {
93
+ let initial = observed();
94
+ let mut unchanged = initial.clone();
95
+ begin_observation(&mut unchanged, "w");
96
+ begin_observation(&mut unchanged, "w");
97
+ assert_eq!(
98
+ unchanged, initial,
99
+ "same observation retains notification dedupe"
100
+ );
101
+ for (pointer, value) in [
102
+ ("/active_team_key", json!("b")),
103
+ ("/agents/w/rollout_path", json!("/fixture/other.jsonl")),
104
+ ("/agents/w/spawn_epoch", json!(2)),
105
+ ("/agents/w/provider", json!("claude")),
106
+ ("/agents/w/spawned_at", json!("new-incarnation")),
107
+ ] {
108
+ let mut changed = initial.clone();
109
+ if pointer.ends_with("spawned_at") {
110
+ changed["agents"]["w"]["spawned_at"] = value;
111
+ } else {
112
+ *changed.pointer_mut(pointer).unwrap() = value;
113
+ }
114
+ begin_observation(&mut changed, "w");
115
+ assert!(changed["coordinator"]["abnormal_exit_watch"]
116
+ .get("w")
117
+ .is_none());
118
+ }
119
+ }
120
+
121
+ #[test]
122
+ fn legacy_unknown_identity_is_retained_until_member_is_observed() {
123
+ let mut state = observed();
124
+ state["coordinator"]["abnormal_exit_watch"]["w"]
125
+ .as_object_mut()
126
+ .unwrap()
127
+ .remove("watch_identity");
128
+ let legacy = state.clone();
129
+ compact_after_merge(&mut state, None, &[]);
130
+ assert_eq!(
131
+ state, legacy,
132
+ "membership compaction cannot guess legacy identity"
133
+ );
134
+ begin_observation(&mut state, "w");
135
+ assert!(state["coordinator"]["abnormal_exit_watch"]
136
+ .get("w")
137
+ .is_none());
138
+ }
139
+
140
+ #[test]
141
+ fn unchanged_normal_save_compacts_large_old_snapshots_and_cannot_resurrect_them() {
142
+ let ws = workspace();
143
+ let watch: serde_json::Map<String, Value> = (0..100)
144
+ .map(|n| {
145
+ (
146
+ format!("old-{n}"),
147
+ json!({"last_notified_key": "x".repeat(100)}),
148
+ )
149
+ })
150
+ .collect();
151
+ let mut state =
152
+ json!({"agents": {}, "teams": {}, "coordinator": {"abnormal_exit_watch": watch}});
153
+ for n in 0..8 {
154
+ state["teams"][format!("team-{n}")] =
155
+ json!({"agents": {}, "coordinator": state["coordinator"].clone()});
156
+ }
157
+ let original = serde_json::to_vec(&state).unwrap().len();
158
+ // Warm the old cache without invoking the load-time migrations, which
159
+ // could otherwise compact before the no-change save under test.
160
+ crate::state::persist::save_runtime_state_without_migrations(&ws, &state).unwrap();
161
+ let stale = state;
162
+ assert_eq!(
163
+ serde_json::from_slice::<Value>(&std::fs::read(runtime_state_path(&ws)).unwrap())
164
+ .unwrap(),
165
+ stale
166
+ );
167
+ save_runtime_state(&ws, &stale).unwrap();
168
+ let first = std::fs::read(runtime_state_path(&ws)).unwrap();
169
+ assert!(
170
+ first.len() < original / 10,
171
+ "size must depend on actual members, not watch x teams"
172
+ );
173
+ save_runtime_state(&ws, &stale).unwrap();
174
+ assert_eq!(std::fs::read(runtime_state_path(&ws)).unwrap(), first);
175
+ std::fs::remove_dir_all(ws).unwrap();
176
+ }
177
+
178
+ #[test]
179
+ fn latest_roster_merge_precedes_pruning_and_explicit_deletion_removes_watch() {
180
+ let ws = workspace();
181
+ let latest = json!({"active_team_key": "a", "session_name": "team-a", "agents": {
182
+ "w": {"agent_id": "w", "provider": "codex", "status": "paused"}
183
+ }, "coordinator": {"abnormal_exit_watch": {"w": {"last_notified_key": "keep"}}}});
184
+ save_runtime_state(&ws, &latest).unwrap();
185
+ let mut stale = load_runtime_state(&ws).unwrap();
186
+ stale["agents"] = json!({});
187
+ save_runtime_state(&ws, &stale).unwrap();
188
+ let merged = load_runtime_state(&ws).unwrap();
189
+ assert!(merged["agents"].get("w").is_some());
190
+ assert_eq!(
191
+ merged["coordinator"]["abnormal_exit_watch"]["w"]["last_notified_key"],
192
+ "keep"
193
+ );
194
+ crate::state::persist::save_runtime_state_with_deleted_agents(&ws, &stale, &["w"]).unwrap();
195
+ let removed = load_runtime_state(&ws).unwrap();
196
+ assert!(removed["agents"].get("w").is_none());
197
+ assert_eq!(removed["coordinator"]["abnormal_exit_watch"], json!({}));
198
+ std::fs::remove_dir_all(ws).unwrap();
199
+ }
200
+
201
+ #[test]
202
+ fn unknown_team_retains_history_until_observation_but_never_authorizes_reuse() {
203
+ let mut state = observed();
204
+ state["active_team_key"] = Value::Null;
205
+ let before = state.clone();
206
+ assert!(observation_identity(&state, "w").is_none());
207
+ compact_after_merge(&mut state, None, &[]);
208
+ assert_eq!(state, before);
209
+ assert!(!matches_observation(
210
+ &state,
211
+ "w",
212
+ &state["coordinator"]["abnormal_exit_watch"]["w"]
213
+ ));
214
+ begin_observation(&mut state, "w");
215
+ assert!(state["coordinator"]["abnormal_exit_watch"]
216
+ .get("w")
217
+ .is_none());
218
+ }
219
+
220
+ #[test]
221
+ fn malformed_team_catalog_does_not_authorize_root_pruning() {
222
+ let mut state =
223
+ json!({"agents": {}, "teams": [], "coordinator": {"abnormal_exit_watch": {"w": {}}}});
224
+ let before = state.clone();
225
+ compact_after_merge(&mut state, None, &[]);
226
+ assert_eq!(state, before);
227
+ assert!(!needs_compaction(&state));
228
+ }
229
+
230
+ #[test]
231
+ fn lock_held_latest_membership_protects_rows_without_roster_stub_fields() {
232
+ let latest = json!({"agents": {"unknown-row": {}}, "teams": {
233
+ "new-sibling": {"agents": {"sibling": {}}}
234
+ }});
235
+ let mut incoming = json!({"agents": {}, "coordinator": {"abnormal_exit_watch": {
236
+ "unknown-row": {}, "sibling": {}, "gone": {}
237
+ }}});
238
+ compact_after_merge(&mut incoming, Some(&latest), &[]);
239
+ assert_eq!(
240
+ incoming["coordinator"]["abnormal_exit_watch"],
241
+ json!({"unknown-row": {}, "sibling": {}})
242
+ );
243
+ compact_after_merge(&mut incoming, Some(&latest), &["unknown-row"]);
244
+ assert_eq!(
245
+ incoming["coordinator"]["abnormal_exit_watch"],
246
+ json!({"sibling": {}})
247
+ );
248
+ }