@team-agent/installer 0.5.106 → 0.5.107

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 (29) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/tests/base.rs +112 -225
  4. package/crates/team-agent/src/compiler/tests.rs +46 -59
  5. package/crates/team-agent/src/leader/tests/byte_findings.rs +1 -14
  6. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +57 -173
  7. package/crates/team-agent/src/lifecycle/launch/pi_mcp_extension.js +226 -0
  8. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +0 -37
  9. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +0 -9
  10. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_native_mcp_host.mjs +71 -0
  11. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -66
  12. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +0 -392
  13. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +0 -5
  14. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +2 -361
  15. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +1 -16
  16. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +20 -112
  17. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1 -12
  18. package/crates/team-agent/src/lifecycle/tests/pi_native_mcp_capability_red.rs +546 -0
  19. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  20. package/crates/team-agent/src/lifecycle/worker_command_context.rs +11 -54
  21. package/crates/team-agent/src/messaging/tests/wave2.rs +2 -302
  22. package/crates/team-agent/src/model/name_similarity.rs +52 -67
  23. package/crates/team-agent/src/model/task_graph.rs +37 -124
  24. package/crates/team-agent/src/model/yaml/tests.rs +46 -165
  25. package/crates/team-agent/src/provider/tests/adapter.rs +0 -20
  26. package/crates/team-agent/src/provider/tests/classify.rs +106 -198
  27. package/crates/team-agent/src/tmux_backend/tests.rs +178 -501
  28. package/crates/team-agent/src/transport/tests/wire.rs +0 -55
  29. package/package.json +4 -4
@@ -1,15 +1,8 @@
1
1
  //! U1 波2 contracts (RED-first → GREEN after fix).
2
2
  //!
3
- //! Wave-2 closes three hardening gaps the wave-1 7-acute audit identified but
4
- //! left untouched:
3
+ //! Wave-2 covers the U1-B and U1-C hardening gaps the wave-1 7-acute audit
4
+ //! identified, while retaining U1-A fail-closed regression guards:
5
5
  //!
6
- //! * U1-A — `resolve_inject_target` LEADER branch had no liveness/fallback
7
- //! gate: a stale `leader_receiver.pane_id` would be returned verbatim, and
8
- //! the upstream `leader_receiver_pane_is_usable` would also fail (pane_id
9
- //! no longer in `list_targets`, liveness Dead) → message goes terminal
10
- //! `failed:leader_not_attached` on a mere pane-id drift. The wave-2 fix
11
- //! widens "usable" to include "leader session+window exists" and lets the
12
- //! resolver fall back to that live pane.
13
6
  //! * U1-B — `transport.list_targets().unwrap_or_default()` SWALLOWED server
14
7
  //! jitter (`Err` ≡ subprocess fork failed) as an empty vec. With the cache
15
8
  //! codepath at :513-516 (`if live_targets.is_empty() && !known_dead { reuse }`)
@@ -56,71 +49,6 @@ fn pane_info_w(pane_id: &str, session: &str, window: &str) -> PaneInfo {
56
49
  // U1-A — LEADER pane_id drift fallback chain
57
50
  // ═════════════════════════════════════════════════════════════════════════════
58
51
 
59
- /// **Scenario**: state binds leader to `%old`, but tmux replaced it with `%new`
60
- /// in the same `leader-session`/`leader` window (pane drift — leader is ALIVE,
61
- /// just at a new id; this happens on tmux respawn-pane, `kill-pane` of an
62
- /// inner shell, panel resize, etc.).
63
- ///
64
- /// **Pre-fix behaviour**: `leader_receiver_pane_is_usable` returns false (old
65
- /// pane gone from list, liveness Dead) → mark failed leader_not_attached.
66
- /// We never even reach `resolve_inject_target`.
67
- ///
68
- /// **Post-fix expectation**: usable widens to "leader session+window exists";
69
- /// resolver falls back to the new live pane; delivery either delivers or fails
70
- /// for an unrelated reason — but NOT `leader_not_attached`.
71
- #[test]
72
- #[ignore = "U1-A drift fallback excised for 0.3.24 (macmini RED v2, res_e4b40473d36f). \
73
- Mock contract preserved verbatim for v0.3.25 fix-forward — the real-machine \
74
- fix requires writer-shape + projection + rediscover-writer triad, NOT the \
75
- wave-2 single-fn fallback. See .team/artifacts/u1-a-realmachine-v2-fix-or-excise.md."]
76
- fn u1_a_leader_pane_drift_falls_back_to_live_session_window_not_failed() {
77
- let ws = tmp_ws("u1a-drift");
78
- let store = store_for(&ws);
79
- let log = EventLog::new(&ws);
80
- let state = serde_json::json!({
81
- "session_name": "leader-session",
82
- "leader_receiver": {
83
- "pane_id": "%old",
84
- "session_name": "leader-session",
85
- "window_name": "leader",
86
- },
87
- });
88
- crate::state::persist::save_runtime_state(&ws, &state).unwrap();
89
- let message_id = store
90
- .create_message(None, "coordinator", "leader", "hi", None, false, None)
91
- .unwrap();
92
-
93
- // Drift: %old is GONE; %new took its place under the same session/window.
94
- let transport = OfflineTransport::new()
95
- .with_targets(vec![pane_info_w("%new", "leader-session", "leader")])
96
- .with_session_present(true)
97
- .with_liveness("%old", PaneLiveness::Dead)
98
- .with_liveness("%new", PaneLiveness::Live);
99
-
100
- let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state).unwrap();
101
-
102
- assert!(
103
- out.reason != Some(DeliveryRefusal::LeaderNotAttached),
104
- "U1-A: pane DRIFT (not leader death) must not terminal-fail as \
105
- leader_not_attached; got reason={:?} status={:?}",
106
- out.reason,
107
- out.message_status.0
108
- );
109
- assert_ne!(
110
- out.message_status.0, "failed",
111
- "U1-A: drift must not be terminal; got status={}",
112
- out.message_status.0
113
- );
114
- let inject_targets = transport.inject_targets();
115
- assert!(
116
- inject_targets
117
- .iter()
118
- .any(|t| matches!(t, Target::Pane(p) if p.as_str() == "%new")),
119
- "U1-A: fallback chain must inject into the LIVE pane (%new), not the stale \
120
- %old; injected targets={inject_targets:?}"
121
- );
122
- }
123
-
124
52
  /// **Scenario**: state has no `leader_receiver.pane_id` AND no leader session
125
53
  /// metadata. Leader is genuinely not attached.
126
54
  ///
@@ -427,215 +355,6 @@ Do you trust the contents of this directory?
427
355
  );
428
356
  }
429
357
 
430
- // ═════════════════════════════════════════════════════════════════════════════
431
- // U1-A wave-2 follow-on — REAL-MACHINE-FAITHFUL contracts for the projection-null
432
- // regression that escaped the original wave-2 fix (macmini fixture
433
- // `res_dc8f8c8e20f6`, see `.team/artifacts/u1-a-realmachine-fail-fix-spec.md`).
434
- //
435
- // The original U1-A test at line 72 of this file uses a SYNTHETIC state shape
436
- // that pre-populates BOTH `state.session_name` AND `leader_receiver.session_name`
437
- // AND omits `owner_team_id` on the message. All three are simultaneously WRONG
438
- // for the real-machine isolated-team scenario, which is exactly why the wave-2
439
- // fix shipped GREEN here but the bug reproduced on the device.
440
- //
441
- // The real-machine writer
442
- // (`lifecycle/launch.rs::seed_launched_owner_from_caller_with_provider_lookup`,
443
- // lines 4440-4468) builds the `leader_receiver` JSON WITHOUT `session_name`
444
- // and `window_name`. Real-machine messages from worker_b → leader DO carry
445
- // `owner_team_id` on the row at send time, so the delivery path at
446
- // `messaging/delivery.rs:140-162` re-projects the runtime state through
447
- // `project_state_for_owner_team` → `project_top_level_view`
448
- // (`state/projection.rs:301-306`). The projection's `setdefault(null)` contract
449
- // converts a missing `state.session_name` on the team entry to a JSON null at
450
- // the projected top level. From there the fallback chain at `delivery.rs:604-606`
451
- // (`leader_receiver_session_name`) hits null on both the receiver AND the
452
- // projected root, returns None, the `!session.is_empty()` gate at
453
- // `delivery.rs:542` is skipped, and the U1-A drift fallback in
454
- // `leader_receiver_pane_is_usable` ALSO short-circuits with empty session —
455
- // back to `LeaderNotAttached` on a live %new.
456
- // ═════════════════════════════════════════════════════════════════════════════
457
-
458
- /// **Real-machine scenario** (macmini fixture `res_dc8f8c8e20f6`):
459
- /// * Isolated team `t1` launched via quick-start; tmux session = `team-iso`.
460
- /// * `leader_receiver` written by the isolated-team seed path carries
461
- /// `pane_id` / `mode` / `status` / `discovery` / `tmux_socket` but
462
- /// **NO** `session_name` and **NO** `window_name` (writer never sets them
463
- /// — see `lifecycle/launch.rs:4440-4468`).
464
- /// * worker_b → leader message carries `owner_team_id = "t1"` on the SQLite
465
- /// row (real-machine sender always populates this for isolated teams).
466
- /// * On delivery tick, `tmux respawn-pane -k -t %old` had already drifted
467
- /// the leader pane: `%old` is Dead, `%new` lives at session `team-iso`,
468
- /// window `leader`.
469
- ///
470
- /// **Pre-fix behaviour (the bug that shipped in dd85ba7)**:
471
- /// delivery.rs:140 projects state via `project_top_level_view` → projection
472
- /// inserts `session_name: null` at projected top level
473
- /// (`state/projection.rs:301-302` `unwrap_or(Value::Null)` branch).
474
- /// Receiver lacks session_name → fallback at `delivery.rs:606` reads
475
- /// projected `state.session_name` → null → `.as_str()` → None.
476
- /// `leader_receiver_pane_is_usable` sees session=`""` → skips drift probe →
477
- /// returns false → message terminal-fails `LeaderNotAttached`, even though
478
- /// %new is alive at the right session+window.
479
- ///
480
- /// **Post-fix expectation**: projection.rs:300-306 falls back to the ROOT
481
- /// `state.session_name` before inserting null. The drift probe then succeeds
482
- /// and %new is selected as inject target.
483
- ///
484
- /// **0.3.24 status**: IGNORED. The wave-2 single-fn fallback (whose mock this
485
- /// asserted) was excised after macmini RED v2 (res_e4b40473d36f) proved that
486
- /// projection+drift-fallback alone do not close the real-machine gap. The
487
- /// v0.3.25 fix-forward will re-enable this contract with a real-tmux macmini
488
- /// counterpart — see `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
489
- #[test]
490
- #[ignore = "U1-A deferred to v0.3.25 — wave-2 single-fn fallback excised for 0.3.24 ship"]
491
- fn u1_a_projected_team_state_with_null_session_still_finds_drifted_leader_pane() {
492
- let ws = tmp_ws("u1a-proj-null-session");
493
- let store = store_for(&ws);
494
- let log = EventLog::new(&ws);
495
-
496
- // Real-machine state shape: nested `teams.t1` entry, receiver as the writer
497
- // at lifecycle/launch.rs:4440-4468 produces it (NO session_name, NO window_name).
498
- // Top-level `session_name` is the canonical isolated-team session; the team
499
- // entry does NOT carry it (matches real-machine `state.json` snapshots).
500
- let state = serde_json::json!({
501
- "active_team_key": "t1",
502
- "session_name": "team-iso",
503
- "teams": {
504
- "t1": {
505
- // NOTE: no session_name on the team entry — this is exactly the
506
- // projection-trigger condition. Real machine relies on the root
507
- // session_name; the projection setdefault(null) destroys it.
508
- "leader_receiver": {
509
- "mode": "direct_tmux",
510
- "status": "attached",
511
- "pane_id": "%old",
512
- "pane": "%old",
513
- "leader_session_uuid": "00000000-0000-0000-0000-000000000001",
514
- "owner_epoch": 1,
515
- "discovery": "quick_start",
516
- // NOTE: no tmux_socket (the isolated-team seed path at
517
- // lifecycle/launch.rs:4440-4468 marks it OPTIONAL; setting a
518
- // foreign socket here would route us through
519
- // `delivery_transport_for_recipient`'s real-tmux endpoint
520
- // backend, which is correct production behaviour but bypasses
521
- // OfflineTransport and is asserted on by the real-machine
522
- // gated stub at the end of this section).
523
- // NOTE: no session_name. No window_name. Faithful to
524
- // seed_launched_owner_from_caller_with_provider_lookup
525
- // (lifecycle/launch.rs:4440-4468) which never writes them.
526
- },
527
- "team_owner": {
528
- "pane_id": "%old",
529
- "leader_session_uuid": "00000000-0000-0000-0000-000000000001",
530
- "owner_epoch": 1,
531
- "claimed_via": "quick-start",
532
- },
533
- "owner_epoch": 1,
534
- "agents": {},
535
- }
536
- }
537
- });
538
- crate::state::persist::save_runtime_state(&ws, &state).unwrap();
539
-
540
- // Critical faithfulness knob: owner_team_id IS set on the row, exactly as
541
- // the real-machine sender (worker_b → leader on isolated team) writes it.
542
- // This is what triggers the projection path at delivery.rs:140-162 that
543
- // the pre-existing u1_a test above silently skipped by passing None.
544
- let message_id = store
545
- .create_message(None, "worker_b", "leader", "hi", None, false, Some("t1"))
546
- .unwrap();
547
-
548
- // Drift: %old is Dead; %new took its place under session "team-iso", window "leader".
549
- let transport = OfflineTransport::new()
550
- .with_targets(vec![pane_info_w("%new", "team-iso", "leader")])
551
- .with_session_present(true)
552
- .with_liveness("%old", PaneLiveness::Dead)
553
- .with_liveness("%new", PaneLiveness::Live);
554
-
555
- let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state).unwrap();
556
-
557
- assert!(
558
- out.reason != Some(DeliveryRefusal::LeaderNotAttached),
559
- "U1-A projection-null escape (real-machine fixture res_dc8f8c8e20f6): \
560
- worker_b → leader on isolated team with owner_team_id projects state \
561
- through project_top_level_view, which setdefault(null)s session_name \
562
- when the team entry lacks it; the U1-A fallback chain then sees empty \
563
- session and terminal-fails. Got reason={:?} status={:?} — must NOT be \
564
- LeaderNotAttached when %new is live at session+window.",
565
- out.reason,
566
- out.message_status.0
567
- );
568
- assert_ne!(
569
- out.message_status.0, "failed",
570
- "U1-A projection-null escape: drift via projected state must not be \
571
- terminal; got status={}",
572
- out.message_status.0
573
- );
574
- let inject_targets = transport.inject_targets();
575
- assert!(
576
- inject_targets
577
- .iter()
578
- .any(|t| matches!(t, Target::Pane(p) if p.as_str() == "%new")),
579
- "U1-A projection-null escape: fallback chain must inject into the LIVE \
580
- pane (%new) reachable via session+window probe; injected={inject_targets:?}"
581
- );
582
- }
583
-
584
- /// **Projection-primitive contract**: direct test on
585
- /// `state::projection::project_top_level_view` that exposes the
586
- /// `unwrap_or(Value::Null)` bug at `state/projection.rs:301-306` in isolation.
587
- ///
588
- /// **Pre-fix behaviour**: when the team entry lacks `session_name`, the
589
- /// projection inserts JSON null at the projected top level, regardless of
590
- /// whether the ROOT state has a valid `session_name`. Every downstream consumer
591
- /// uses `.and_then(Value::as_str)` which treats null and missing identically,
592
- /// so the bug is silent at projection time and only manifests at delivery time.
593
- ///
594
- /// **Post-fix expectation**: when the team entry lacks `session_name`, fall
595
- /// back to the ROOT `state.session_name` before inserting null. This is the
596
- /// minimal-blast-radius fix — when the team entry HAS session_name the
597
- /// behaviour is bit-identical (entry branch wins), and when both lack it the
598
- /// projected value is still null (unchanged → `project_top_level_view_golden`
599
- /// + `project_top_level_view_does_not_fall_to_toplevel_owner_when_teams_exist`
600
- /// in `state/projection.rs` stay green).
601
- ///
602
- /// **0.3.24 status**: IGNORED. The projection or_else fallback was reverted
603
- /// along with the wave-2 U1-A drift fallback because it had no other consumer.
604
- /// v0.3.25 will re-introduce it as part of the writer-shape + projection +
605
- /// rediscover-writer triad.
606
- #[test]
607
- #[ignore = "U1-A deferred to v0.3.25 — projection or_else fallback reverted with the wave-2 excision"]
608
- fn u1_a_project_top_level_view_falls_back_to_root_session_name_when_entry_lacks_it() {
609
- let state = serde_json::json!({
610
- "session_name": "root-sess",
611
- "teams": {
612
- "t1": {
613
- "agents": {},
614
- // NO session_name on the entry — projection must fall back to root.
615
- }
616
- }
617
- });
618
-
619
- let projected = crate::state::projection::project_top_level_view(&state, "t1");
620
-
621
- let session = projected.get("session_name");
622
- assert!(
623
- session.is_some(),
624
- "projection must produce a session_name key (matches Python setdefault \
625
- contract); got {projected:?}"
626
- );
627
- assert_eq!(
628
- session.and_then(serde_json::Value::as_str),
629
- Some("root-sess"),
630
- "U1-A projection contract: when team entry lacks session_name, the \
631
- projection MUST fall back to ROOT state.session_name; instead it \
632
- inserted {:?}. This is the root cause of the U1-A real-machine FAIL — \
633
- every delivery.rs fallback that calls .and_then(Value::as_str) treats \
634
- null as missing.",
635
- session
636
- );
637
- }
638
-
639
358
  /// **Regression guard**: when NEITHER the team entry NOR the root state has
640
359
  /// `session_name`, projection must still produce a `session_name` key with
641
360
  /// JSON null (Python `setdefault(null)` contract — keeps the existing
@@ -667,22 +386,3 @@ fn u1_a_project_top_level_view_inserts_null_session_name_when_neither_root_nor_e
667
386
  projected.get("session_name")
668
387
  );
669
388
  }
670
-
671
- // Real-machine integration stub (#[ignore]) — macmini scenario res_dc8f8c8e20f6.
672
- // The deterministic source of truth is the three OfflineTransport contracts above.
673
- // This stub claims the test name so the macmini harness can link it.
674
- #[test]
675
- #[ignore = "real-machine: macmini fixture res_dc8f8c8e20f6 — requires real tmux + isolated team launch + respawn-pane drift"]
676
- fn u1_a_macmini_res_dc8f8c8e20f6_isolated_team_respawn_pane_drift_delivers() {
677
- if std::env::var("TEAMAGENT_MACMINI_E2E").as_deref() != Ok("1") {
678
- eprintln!("skipping: TEAMAGENT_MACMINI_E2E != 1");
679
- return;
680
- }
681
- panic!(
682
- "stub: implement against tools/macmini-ssh/run_macmini_gap32_u1a_drift_e2e.sh \
683
- (follow-up). The OfflineTransport unit contracts above are the deterministic \
684
- source of truth; a real-machine replay was observed manually on the macmini \
685
- host (worker_b → leader, %old dead, %new alive, projection nulls \
686
- session_name, terminal-fails leader_not_attached pre-fix)."
687
- );
688
- }
@@ -174,62 +174,58 @@ mod tests {
174
174
  }
175
175
 
176
176
  #[test]
177
- fn prefix_hit_beats_lower_distance_non_prefix() {
178
- // "stat" is a prefix of "status" (distance 2) but "slat" is
179
- // distance 1 (mid-word substitution). Prefix wins.
180
- let out = rank("stat", &[c("slat"), c("status")]);
181
- assert_eq!(out, vec!["status".to_string(), "slat".to_string()]);
182
- }
183
-
184
- #[test]
185
- fn distance_orders_non_prefix() {
186
- let out = rank("btea", &[c("bota"), c("beta")]);
187
- assert_eq!(out[0], "beta"); // 1 edit
188
- assert_eq!(out[1], "bota"); // 2 edits
189
- }
190
-
191
- #[test]
192
- fn stable_key_decides_tie() {
193
- // Both "beta" and "bota" are distance 2 from "bata".
194
- let out = rank("bata", &[c("bota"), c("beta")]);
195
- assert_eq!(out, vec!["beta".to_string(), "bota".to_string()]);
196
- }
197
-
198
- #[test]
199
- fn caps_at_three() {
200
- let out = rank("aaaa", &[c("aaab"), c("aaac"), c("aaad"), c("aaae")]);
201
- assert_eq!(out.len(), 3);
202
- assert_eq!(out, vec!["aaab", "aaac", "aaad"]);
203
- }
204
-
205
- #[test]
206
- fn case_preserved_in_payload() {
207
- // "BTEA" normalized matches "Beta" (distance 1); the
208
- // canonical payload preserves original casing.
209
- let out = rank("BTEA", &[c("Beta")]);
210
- assert_eq!(out, vec!["Beta".to_string()]);
211
- }
212
-
213
- #[test]
214
- fn far_request_returns_empty() {
215
- let out = rank("zzzzzz", &[c("beta")]);
216
- assert!(out.is_empty());
217
- }
218
-
219
- #[test]
220
- fn threshold_widens_with_length() {
221
- assert_eq!(threshold_for_len(0), 1);
222
- assert_eq!(threshold_for_len(3), 1);
223
- assert_eq!(threshold_for_len(4), 2);
224
- assert_eq!(threshold_for_len(6), 2);
225
- assert_eq!(threshold_for_len(7), 3);
226
- assert_eq!(threshold_for_len(20), 3);
227
- }
228
-
229
- #[test]
230
- fn empty_request_returns_empty() {
231
- let out = rank("", &[c("beta")]);
232
- assert!(out.is_empty());
177
+ fn name_similarity_cases() {
178
+ enum Expected {
179
+ Exact(&'static [&'static str]),
180
+ Prefix(&'static [&'static str]),
181
+ Capped(usize, &'static [&'static str]),
182
+ }
183
+ enum Check {
184
+ Rank(&'static str, &'static [&'static str], Expected),
185
+ Thresholds(&'static [(usize, usize)]),
186
+ }
187
+ // (old case-id, check); Prefix deliberately does not assert the full output.
188
+ const CASES: &[(&str, Check)] = &[
189
+ // "stat" prefixes "status" (distance 2), ahead of "slat" (distance 1).
190
+ ("prefix_hit_beats_lower_distance_non_prefix", Check::Rank("stat", &["slat", "status"], Expected::Exact(&["status", "slat"]))),
191
+ // "beta" is 1 edit away; "bota" is 2 edits away. Only these first two were asserted.
192
+ ("distance_orders_non_prefix", Check::Rank("btea", &["bota", "beta"], Expected::Prefix(&["beta", "bota"]))),
193
+ // Both "beta" and "bota" are distance 2 from "bata"; stable_key breaks the tie.
194
+ ("stable_key_decides_tie", Check::Rank("bata", &["bota", "beta"], Expected::Exact(&["beta", "bota"]))),
195
+ ("caps_at_three", Check::Rank("aaaa", &["aaab", "aaac", "aaad", "aaae"], Expected::Capped(3, &["aaab", "aaac", "aaad"]))),
196
+ // Normalized "BTEA" matches "Beta" (distance 1); payload preserves canonical casing.
197
+ ("case_preserved_in_payload", Check::Rank("BTEA", &["Beta"], Expected::Exact(&["Beta"]))),
198
+ ("far_request_returns_empty", Check::Rank("zzzzzz", &["beta"], Expected::Exact(&[]))),
199
+ ("threshold_widens_with_length", Check::Thresholds(&[(0, 1), (3, 1), (4, 2), (6, 2), (7, 3), (20, 3)])),
200
+ ("empty_request_returns_empty", Check::Rank("", &["beta"], Expected::Exact(&[]))),
201
+ // Byte-lock existing emit.rs behavior: `statu` → `status`, checking only the first element.
202
+ ("statu_still_maps_to_status", Check::Rank("statu", &["status", "send", "start", "restart"], Expected::Prefix(&["status"]))),
203
+ ];
204
+ for (id, check) in CASES {
205
+ match check {
206
+ Check::Rank(request, candidates, expected) => {
207
+ let candidates: Vec<_> = candidates.iter().map(|name| c(name)).collect();
208
+ let out = rank(request, &candidates);
209
+ let values = match expected {
210
+ Expected::Exact(values) | Expected::Prefix(values) | Expected::Capped(_, values) => values,
211
+ };
212
+ let values: Vec<String> = values.iter().map(|value| (*value).to_string()).collect();
213
+ match expected {
214
+ Expected::Exact(_) => assert_eq!(out, values, "{id}"),
215
+ Expected::Prefix(_) => assert_eq!(out.get(..values.len()), Some(values.as_slice()), "{id}: prefix"),
216
+ Expected::Capped(limit, _) => {
217
+ assert_eq!(out.len(), *limit, "{id}: cap");
218
+ assert_eq!(out, values, "{id}");
219
+ }
220
+ }
221
+ }
222
+ Check::Thresholds(cases) => {
223
+ for &(len, expected) in cases.iter() {
224
+ assert_eq!(threshold_for_len(len), expected, "{id}: len={len}");
225
+ }
226
+ }
227
+ }
228
+ }
233
229
  }
234
230
 
235
231
  #[test]
@@ -253,15 +249,4 @@ mod tests {
253
249
  assert_eq!(best_name("btea", &["beta", "bota"]), Some("beta"));
254
250
  assert_eq!(best_name("zzzzzz", &["beta"]), None);
255
251
  }
256
-
257
- #[test]
258
- fn statu_still_maps_to_status() {
259
- // Byte-lock the existing emit.rs behavior: `statu` → `status`
260
- // with the shared helper.
261
- let out = rank(
262
- "statu",
263
- &[c("status"), c("send"), c("start"), c("restart")],
264
- );
265
- assert_eq!(out[0], "status");
266
- }
267
252
  }
@@ -191,137 +191,50 @@ mod tests {
191
191
  // ---- find_dependency_cycle:golden 来自 Python 真相源(§4.2 双跑) ----
192
192
 
193
193
  #[test]
194
- fn cycle_linear_chain_has_no_cycle() {
195
- // Python C1 -> []
196
- let tasks = [
197
- t("a", &[], TaskStatus::Pending),
198
- t("b", &["a"], TaskStatus::Pending),
199
- t("c", &["b"], TaskStatus::Pending),
200
- ];
201
- assert!(find_dependency_cycle(&tasks).is_empty());
202
- }
203
-
204
- #[test]
205
- fn cycle_two_node() {
206
- // Python C2 -> ['a','b','a']
207
- let tasks = [
208
- t("a", &["b"], TaskStatus::Pending),
209
- t("b", &["a"], TaskStatus::Pending),
210
- ];
211
- assert_eq!(
212
- cycle_ids(&find_dependency_cycle(&tasks)),
213
- vec!["a", "b", "a"]
214
- );
215
- }
216
-
217
- #[test]
218
- fn cycle_three_node() {
219
- // Python C3 -> ['a','b','c','a']
220
- let tasks = [
221
- t("a", &["b"], TaskStatus::Pending),
222
- t("b", &["c"], TaskStatus::Pending),
223
- t("c", &["a"], TaskStatus::Pending),
224
- ];
225
- assert_eq!(
226
- cycle_ids(&find_dependency_cycle(&tasks)),
227
- vec!["a", "b", "c", "a"]
228
- );
229
- }
230
-
231
- #[test]
232
- fn cycle_self_loop() {
233
- // Python C4 -> ['a','a'](走 stack.index 分支,非 [node,node] 退化分支)
234
- let tasks = [t("a", &["a"], TaskStatus::Pending)];
235
- assert_eq!(cycle_ids(&find_dependency_cycle(&tasks)), vec!["a", "a"]);
236
- }
237
-
238
- #[test]
239
- fn cycle_reached_from_acyclic_prefix() {
240
- // Python C5:x->a->b->a -> ['a','b','a'](前缀 x 不在环里,被 stack.index 切掉)
241
- let tasks = [
242
- t("x", &["a"], TaskStatus::Pending),
243
- t("a", &["b"], TaskStatus::Pending),
244
- t("b", &["a"], TaskStatus::Pending),
194
+ fn dependency_cycle_python_golden_cases() {
195
+ // (Python id, 旧函数名, note, tasks, 完整环路径)。保持输入和发现顺序。
196
+ let cases = [
197
+ ("C1", "cycle_linear_chain_has_no_cycle", "Python C1 -> []",
198
+ vec![t("a", &[], TaskStatus::Pending), t("b", &["a"], TaskStatus::Pending), t("c", &["b"], TaskStatus::Pending)], vec![]),
199
+ ("C2", "cycle_two_node", "Python C2 -> ['a','b','a']",
200
+ vec![t("a", &["b"], TaskStatus::Pending), t("b", &["a"], TaskStatus::Pending)], vec!["a", "b", "a"]),
201
+ ("C3", "cycle_three_node", "Python C3 -> ['a','b','c','a']",
202
+ vec![t("a", &["b"], TaskStatus::Pending), t("b", &["c"], TaskStatus::Pending), t("c", &["a"], TaskStatus::Pending)], vec!["a", "b", "c", "a"]),
203
+ ("C4", "cycle_self_loop", "Python C4 -> ['a','a'](走 stack.index 分支,非 [node,node] 退化分支)",
204
+ vec![t("a", &["a"], TaskStatus::Pending)], vec!["a", "a"]),
205
+ ("C5", "cycle_reached_from_acyclic_prefix", "Python C5:x->a->b->a -> ['a','b','a'](前缀 x 不在环里,被 stack.index 切掉)",
206
+ vec![t("x", &["a"], TaskStatus::Pending), t("a", &["b"], TaskStatus::Pending), t("b", &["a"], TaskStatus::Pending)], vec!["a", "b", "a"]),
207
+ ("C6", "cycle_missing_dep_is_ignored", "Python C6:a 依赖图外 'zzz' -> 不入环 -> []",
208
+ vec![t("a", &["zzz"], TaskStatus::Pending), t("b", &["a"], TaskStatus::Pending)], vec![]),
209
+ ("C7", "cycle_empty_input", "Python C7 -> []", vec![], vec![]),
245
210
  ];
246
- assert_eq!(
247
- cycle_ids(&find_dependency_cycle(&tasks)),
248
- vec!["a", "b", "a"]
249
- );
250
- }
251
-
252
- #[test]
253
- fn cycle_missing_dep_is_ignored() {
254
- // Python C6:a 依赖图外 'zzz' -> 不入环 -> []
255
- let tasks = [
256
- t("a", &["zzz"], TaskStatus::Pending),
257
- t("b", &["a"], TaskStatus::Pending),
258
- ];
259
- assert!(find_dependency_cycle(&tasks).is_empty());
260
- }
261
-
262
- #[test]
263
- fn cycle_empty_input() {
264
- // Python C7 -> []
265
- assert!(find_dependency_cycle(&[]).is_empty());
211
+ for (id, name, note, tasks, expected) in cases {
212
+ assert_eq!(cycle_ids(&find_dependency_cycle(&tasks)), expected, "case={id}/{name}; {note}");
213
+ }
266
214
  }
267
215
 
268
216
  // ---- ready_tasks:golden 来自 Python 真相源 ----
269
217
 
270
218
  #[test]
271
- fn ready_pending_no_deps() {
272
- // Python R1 -> ['a'](running 不在白名单)
273
- let tasks = [
274
- t("a", &[], TaskStatus::Pending),
275
- t("b", &[], TaskStatus::Running),
219
+ fn ready_tasks_python_golden_cases() {
220
+ // (Python id, 旧函数名, note, tasks, 完整就绪列表)。不改变状态白名单或顺序。
221
+ let cases = [
222
+ ("R1", "ready_pending_no_deps", "Python R1 -> ['a'](running 不在白名单)",
223
+ vec![t("a", &[], TaskStatus::Pending), t("b", &[], TaskStatus::Running)], vec!["a"]),
224
+ ("R2", "ready_default_pending", "Python R2:无 status key 视作 pending -> ['a']。本类型用显式 Pending 表达缺省。",
225
+ vec![t("a", &[], TaskStatus::Pending)], vec!["a"]),
226
+ ("R3", "ready_requires_deps_done", "Python R3:a done -> b 就绪;c 依赖未完成的 b -> 不就绪 -> ['b']",
227
+ vec![t("a", &[], TaskStatus::Done), t("b", &["a"], TaskStatus::Pending), t("c", &["b"], TaskStatus::Pending)], vec!["b"]),
228
+ ("R4", "ready_status_whitelist", "Python R4:needs_retry / ready 在白名单,blocked 不在 -> ['a','b']",
229
+ vec![t("a", &[], TaskStatus::NeedsRetry), t("b", &[], TaskStatus::Ready), t("c", &[], TaskStatus::Blocked)], vec!["a", "b"]),
230
+ ("R5", "ready_missing_dep_not_done", "Python R5:依赖查无此 id -> 视作非 done -> 不就绪 -> []",
231
+ vec![t("b", &["missing"], TaskStatus::Pending)], vec![]),
232
+ ("R6", "ready_terminal_statuses_excluded", "Python R6:done/failed/cancelled 均不在白名单 -> []",
233
+ vec![t("a", &[], TaskStatus::Done), t("b", &[], TaskStatus::Failed), t("c", &[], TaskStatus::Cancelled)], vec![]),
276
234
  ];
277
- assert_eq!(ready_ids(&ready_tasks(&tasks)), vec!["a"]);
278
- }
279
-
280
- #[test]
281
- fn ready_default_pending() {
282
- // Python R2:无 status key 视作 pending -> ['a']。本类型用显式 Pending 表达缺省。
283
- let tasks = [t("a", &[], TaskStatus::Pending)];
284
- assert_eq!(ready_ids(&ready_tasks(&tasks)), vec!["a"]);
285
- }
286
-
287
- #[test]
288
- fn ready_requires_deps_done() {
289
- // Python R3:a done -> b 就绪;c 依赖未完成的 b -> 不就绪 -> ['b']
290
- let tasks = [
291
- t("a", &[], TaskStatus::Done),
292
- t("b", &["a"], TaskStatus::Pending),
293
- t("c", &["b"], TaskStatus::Pending),
294
- ];
295
- assert_eq!(ready_ids(&ready_tasks(&tasks)), vec!["b"]);
296
- }
297
-
298
- #[test]
299
- fn ready_status_whitelist() {
300
- // Python R4:needs_retry / ready 在白名单,blocked 不在 -> ['a','b']
301
- let tasks = [
302
- t("a", &[], TaskStatus::NeedsRetry),
303
- t("b", &[], TaskStatus::Ready),
304
- t("c", &[], TaskStatus::Blocked),
305
- ];
306
- assert_eq!(ready_ids(&ready_tasks(&tasks)), vec!["a", "b"]);
307
- }
308
-
309
- #[test]
310
- fn ready_missing_dep_not_done() {
311
- // Python R5:依赖查无此 id -> 视作非 done -> 不就绪 -> []
312
- let tasks = [t("b", &["missing"], TaskStatus::Pending)];
313
- assert!(ready_tasks(&tasks).is_empty());
314
- }
315
-
316
- #[test]
317
- fn ready_terminal_statuses_excluded() {
318
- // Python R6:done/failed/cancelled 均不在白名单 -> []
319
- let tasks = [
320
- t("a", &[], TaskStatus::Done),
321
- t("b", &[], TaskStatus::Failed),
322
- t("c", &[], TaskStatus::Cancelled),
323
- ];
324
- assert!(ready_tasks(&tasks).is_empty());
235
+ for (id, name, note, tasks, expected) in cases {
236
+ assert_eq!(ready_ids(&ready_tasks(&tasks)), expected, "case={id}/{name}; {note}");
237
+ }
325
238
  }
326
239
 
327
240
  // ---- update_task_status:golden 来自 Python 真相源 ----