@team-agent/installer 0.5.105 → 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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +5 -3
- package/README.zh.md +4 -3
- package/crates/team-agent/src/cli/adapters.rs +15 -1
- package/crates/team-agent/src/cli/emit.rs +33 -13
- package/crates/team-agent/src/cli/mod.rs +148 -21
- package/crates/team-agent/src/cli/status_port/brief.rs +62 -3
- package/crates/team-agent/src/cli/tests/base.rs +112 -225
- package/crates/team-agent/src/cli/tests/run_delegation.rs +4 -2
- package/crates/team-agent/src/cli/tests/status_send.rs +2 -2
- package/crates/team-agent/src/compiler/tests.rs +46 -59
- package/crates/team-agent/src/leader/tests/byte_findings.rs +1 -14
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +57 -173
- package/crates/team-agent/src/lifecycle/launch/pi_mcp_extension.js +226 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +0 -37
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -3
- package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +24 -17
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +22 -9
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_native_mcp_host.mjs +71 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +43 -66
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +71 -392
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +0 -5
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +2 -361
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +1 -16
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +20 -112
- package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1 -12
- package/crates/team-agent/src/lifecycle/tests/pi_native_mcp_capability_red.rs +546 -0
- package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +6 -2
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +11 -54
- package/crates/team-agent/src/messaging/tests/wave2.rs +2 -302
- package/crates/team-agent/src/model/name_similarity.rs +52 -67
- package/crates/team-agent/src/model/task_graph.rs +37 -124
- package/crates/team-agent/src/model/yaml/tests.rs +46 -165
- package/crates/team-agent/src/provider/tests/adapter.rs +0 -20
- package/crates/team-agent/src/provider/tests/classify.rs +106 -198
- package/crates/team-agent/src/state/projection.rs +15 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +315 -501
- package/crates/team-agent/src/tmux_backend.rs +110 -0
- package/crates/team-agent/src/transport/test_support.rs +35 -0
- package/crates/team-agent/src/transport/tests/wire.rs +32 -58
- package/crates/team-agent/src/transport.rs +26 -12
- package/package.json +4 -4
- package/skills/team-agent/references/recovery-runbook.md +2 -2
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
//! U1 波2 contracts (RED-first → GREEN after fix).
|
|
2
2
|
//!
|
|
3
|
-
//! Wave-2
|
|
4
|
-
//!
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
195
|
-
// Python
|
|
196
|
-
let
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
247
|
-
cycle_ids(&find_dependency_cycle(&tasks)),
|
|
248
|
-
|
|
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
|
|
272
|
-
// Python
|
|
273
|
-
let
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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 真相源 ----
|