@team-agent/installer 0.3.5 → 0.3.7
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/crates/team-agent/src/cli/diagnose.rs +9 -0
- package/crates/team-agent/src/cli/emit.rs +63 -0
- package/crates/team-agent/src/cli/mod.rs +334 -35
- package/crates/team-agent/src/cli/status_port.rs +62 -0
- package/crates/team-agent/src/cli/tests/base.rs +9 -4
- package/crates/team-agent/src/cli/tests/run_delegation.rs +10 -2
- package/crates/team-agent/src/cli/types.rs +3 -2
- package/crates/team-agent/src/compiler.rs +73 -50
- package/crates/team-agent/src/coordinator/tick.rs +108 -20
- package/crates/team-agent/src/db/migration.rs +17 -1
- package/crates/team-agent/src/lifecycle/launch.rs +182 -47
- package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +75 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +46 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +221 -7
- package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
- package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
- package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
- package/crates/team-agent/src/mcp_server/tools.rs +25 -1
- package/crates/team-agent/src/mcp_server/wire.rs +11 -1
- package/crates/team-agent/src/model/paths.rs +7 -0
- package/crates/team-agent/src/model/spec.rs +23 -1
- package/crates/team-agent/src/packaging/install.rs +42 -4
- package/crates/team-agent/src/packaging/tests.rs +91 -14
- package/crates/team-agent/src/packaging/types.rs +13 -1
- package/crates/team-agent/src/provider/adapter.rs +204 -0
- package/crates/team-agent/src/state/selector.rs +48 -14
- package/crates/team-agent/src/tmux_backend.rs +14 -2
- package/npm/install.mjs +21 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +82 -5
|
@@ -18,6 +18,45 @@ pub(super) fn quick_start_team_dir(role_doc: &str) -> PathBuf {
|
|
|
18
18
|
team
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/// E5 spec 迁移:spec 不再落 team_dir,而在 <team_workspace>/.team/runtime/<team_key>/。
|
|
22
|
+
/// team_key = team_dir.name(quick_start_team_dir → "teamdir");workspace = team_workspace(team)。
|
|
23
|
+
pub(super) fn quick_start_runtime_spec_path(team: &std::path::Path) -> PathBuf {
|
|
24
|
+
let workspace = crate::model::paths::team_workspace(team).unwrap();
|
|
25
|
+
let team_key = team.file_name().unwrap().to_string_lossy().to_string();
|
|
26
|
+
crate::model::paths::runtime_spec_path(&workspace, &team_key)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// 在 team 自身或其 team_workspace 父下找 .team/runtime/*/team.spec.yaml。run_workspace 解析
|
|
30
|
+
/// 可能是 team 或父、team_key 可能因 state 缺省而非 team 名,故扫整个 runtime 目录任一子目录。
|
|
31
|
+
/// `team_key` 给定时优先精确命中,否则取扫到的第一个。
|
|
32
|
+
pub(super) fn find_runtime_spec(team: &std::path::Path, team_key: &str) -> Option<PathBuf> {
|
|
33
|
+
let mut workspaces = vec![team.to_path_buf()];
|
|
34
|
+
if let Ok(ws) = crate::model::paths::team_workspace(team) {
|
|
35
|
+
workspaces.push(ws);
|
|
36
|
+
}
|
|
37
|
+
// 精确命中优先。
|
|
38
|
+
for ws in &workspaces {
|
|
39
|
+
let exact = crate::model::paths::runtime_spec_path(ws, team_key);
|
|
40
|
+
if exact.exists() {
|
|
41
|
+
return Some(exact);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// 回退:扫 .team/runtime/*/team.spec.yaml。
|
|
45
|
+
for ws in &workspaces {
|
|
46
|
+
let runtime = crate::model::paths::runtime_dir(ws);
|
|
47
|
+
let Ok(entries) = std::fs::read_dir(&runtime) else {
|
|
48
|
+
continue;
|
|
49
|
+
};
|
|
50
|
+
for entry in entries.flatten() {
|
|
51
|
+
let spec = entry.path().join("team.spec.yaml");
|
|
52
|
+
if spec.exists() {
|
|
53
|
+
return Some(spec);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
None
|
|
58
|
+
}
|
|
59
|
+
|
|
21
60
|
/// A no-owner running workspace: state.json carries session_name + agents but NO team_owner →
|
|
22
61
|
/// check_team_owner returns None = allowed (owner_gate.rs:48), so the owner-gated entry points can
|
|
23
62
|
/// proceed PAST the owner gate. Also inits the real team.db.
|
|
@@ -46,11 +85,16 @@ fn quick_start_compiles_real_spec_to_team_spec_yaml() {
|
|
|
46
85
|
let result = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
47
86
|
|
|
48
87
|
// OBSERVABLE 1 (real compiler ran; spawn-independent): the compiled spec is written.
|
|
49
|
-
|
|
88
|
+
// E5: spec lands in .team/runtime/<team_key>/ (NOT the user team dir).
|
|
89
|
+
let spec_path = quick_start_runtime_spec_path(&team);
|
|
50
90
|
assert!(
|
|
51
91
|
spec_path.exists(),
|
|
52
|
-
"quick_start must compile the team dir and write team.spec.yaml
|
|
53
|
-
before launch); the stub returns before compiling. result={result:?}"
|
|
92
|
+
"quick_start must compile the team dir and write team.spec.yaml under .team/runtime/<team_key>/ \
|
|
93
|
+
(the real compiler runs before launch); the stub returns before compiling. result={result:?}"
|
|
94
|
+
);
|
|
95
|
+
assert!(
|
|
96
|
+
!team.join("team.spec.yaml").exists(),
|
|
97
|
+
"E5: spec must NOT be written into the user team dir"
|
|
54
98
|
);
|
|
55
99
|
let spec_text = std::fs::read_to_string(&spec_path).unwrap_or_default();
|
|
56
100
|
assert!(
|
|
@@ -94,9 +138,11 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
|
|
|
94
138
|
)
|
|
95
139
|
.expect("status/collect selector should resolve project root");
|
|
96
140
|
assert_eq!(selected.run_workspace, workspace, "input={}", input.display());
|
|
141
|
+
// E5: selector resolves spec_path to .team/runtime/<team_key>/ (team_key="current").
|
|
142
|
+
let expected_spec = crate::model::paths::runtime_spec_path(&workspace, "current");
|
|
97
143
|
assert_eq!(
|
|
98
144
|
selected.spec_path.as_deref().map(std::fs::canonicalize).transpose().unwrap(),
|
|
99
|
-
Some(std::fs::canonicalize(
|
|
145
|
+
Some(std::fs::canonicalize(&expected_spec).unwrap()),
|
|
100
146
|
"input={}",
|
|
101
147
|
input.display()
|
|
102
148
|
);
|
|
@@ -433,6 +479,164 @@ fn spine_add_agent_rejects_duplicate_agent_id() {
|
|
|
433
479
|
);
|
|
434
480
|
}
|
|
435
481
|
|
|
482
|
+
// E5 Bug1 — add-agent must NOT copy the external role file into <team_dir>/agents (the
|
|
483
|
+
// self-copy O_TRUNC truncation reproduced on real machine), and must still inject the new
|
|
484
|
+
// agent into the compiled spec by reading the role file in place.
|
|
485
|
+
#[test]
|
|
486
|
+
fn e5_add_agent_does_not_copy_role_into_platform_dir_and_injects_into_spec() {
|
|
487
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
488
|
+
// External role file OUTSIDE team_dir/agents, with recognizable body content.
|
|
489
|
+
let role = team.parent().unwrap().join("w2-external-role.md");
|
|
490
|
+
let role_body = "---\nname: w2\nrole: Second Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nUNIQUE-E5-BUG1-MARKER body for w2.\n";
|
|
491
|
+
std::fs::write(&role, role_body).unwrap();
|
|
492
|
+
let transport = OfflineTransport::new();
|
|
493
|
+
// The spawn may not complete under OfflineTransport; we only assert the no-copy + spec-inject
|
|
494
|
+
// observables, which happen BEFORE spawn. Ignore the spawn-stage Result.
|
|
495
|
+
let _ = add_agent_with_transport(&team, &AgentId::new("w2"), &role, false, None, &transport);
|
|
496
|
+
|
|
497
|
+
// (1) No copy landed in the platform agents dir.
|
|
498
|
+
let copied = team.join("agents").join("w2.md");
|
|
499
|
+
assert!(
|
|
500
|
+
!copied.exists(),
|
|
501
|
+
"add-agent must NOT copy the role file into <team_dir>/agents (anti-pattern + O_TRUNC bug)"
|
|
502
|
+
);
|
|
503
|
+
// (2) The external role file is untouched (not truncated by a self-copy).
|
|
504
|
+
assert_eq!(
|
|
505
|
+
std::fs::read_to_string(&role).unwrap(),
|
|
506
|
+
role_body,
|
|
507
|
+
"external role file must be left intact (no truncating self-copy)"
|
|
508
|
+
);
|
|
509
|
+
// (3) The compiled spec (under .team/runtime/<team_key>/, NOT the user team dir) carries w2.
|
|
510
|
+
let runtime_spec = find_runtime_spec(&team, "teamdir").expect("runtime spec written");
|
|
511
|
+
let spec_text = std::fs::read_to_string(&runtime_spec).unwrap();
|
|
512
|
+
assert!(
|
|
513
|
+
spec_text.contains("w2"),
|
|
514
|
+
"compiled spec must inject the new agent w2 (read in place, not copied); spec=\n{spec_text}"
|
|
515
|
+
);
|
|
516
|
+
assert!(
|
|
517
|
+
!team.join("team.spec.yaml").exists(),
|
|
518
|
+
"E5: add_agent must NOT write spec into the user team dir"
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// E5 task#3 / E4 — restart rebuilds the runtime spec from role docs EVERY time: editing a role
|
|
523
|
+
// doc (here: rename a role) is reflected in the freshly-written runtime spec after restart.
|
|
524
|
+
#[test]
|
|
525
|
+
fn e5_restart_rebuilds_runtime_spec_from_role_docs() {
|
|
526
|
+
let ws = restart_ws_two_resumable_workers(); // has TEAM.md + agents/{alpha,bravo}.md + state
|
|
527
|
+
// Edit a role doc AFTER initial compile: change alpha's role text.
|
|
528
|
+
std::fs::write(
|
|
529
|
+
ws.join("agents").join("alpha.md"),
|
|
530
|
+
"---\nname: alpha\nrole: RENAMED Alpha Role\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nAlpha edited.\n",
|
|
531
|
+
)
|
|
532
|
+
.unwrap();
|
|
533
|
+
let transport = OfflineTransport::new();
|
|
534
|
+
let _ = restart_with_transport(&ws, false, None, &transport);
|
|
535
|
+
// The runtime spec (rebuilt from role docs) must carry the edited role text.
|
|
536
|
+
let runtime_spec = find_runtime_spec(&ws, "restartteam").expect("runtime spec rebuilt");
|
|
537
|
+
let spec_text = std::fs::read_to_string(&runtime_spec).unwrap();
|
|
538
|
+
assert!(
|
|
539
|
+
spec_text.contains("RENAMED Alpha Role"),
|
|
540
|
+
"restart must rebuild runtime spec from role docs (E4: TEAM.md/role edits take effect); spec=\n{spec_text}"
|
|
541
|
+
);
|
|
542
|
+
assert!(
|
|
543
|
+
!ws.join("team.spec.yaml").exists(),
|
|
544
|
+
"E5: rebuilt spec must land in .team/runtime, not the user team dir"
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// E5 task#3 / RC-A6b — restart with role definitions MISSING explicitly refuses (lists what's
|
|
549
|
+
// missing) and leaves the previous runtime spec in place (no silent path, no data destruction).
|
|
550
|
+
#[test]
|
|
551
|
+
fn e5_restart_missing_role_docs_refuses_and_preserves_old_spec() {
|
|
552
|
+
let ws = restart_ws_two_resumable_workers();
|
|
553
|
+
// Pre-seed a previous runtime spec so we can assert it survives the refusal.
|
|
554
|
+
let prev_spec = crate::model::paths::runtime_spec_path(&ws, "restartteam");
|
|
555
|
+
std::fs::create_dir_all(prev_spec.parent().unwrap()).unwrap();
|
|
556
|
+
std::fs::write(&prev_spec, "PREVIOUS-RUNTIME-SPEC-MARKER\n").unwrap();
|
|
557
|
+
// Remove the role definitions (TEAM.md) → role source gone.
|
|
558
|
+
std::fs::remove_file(ws.join("TEAM.md")).unwrap();
|
|
559
|
+
let transport = OfflineTransport::new();
|
|
560
|
+
let result = restart_with_transport(&ws, false, None, &transport);
|
|
561
|
+
let text = format!("{result:?}");
|
|
562
|
+
assert!(
|
|
563
|
+
text.contains("role definitions missing") || text.to_lowercase().contains("missing"),
|
|
564
|
+
"restart with missing role docs must explicitly refuse listing what's missing; got {text}"
|
|
565
|
+
);
|
|
566
|
+
// Old runtime spec preserved untouched (T2: no data destruction on refusal).
|
|
567
|
+
assert_eq!(
|
|
568
|
+
std::fs::read_to_string(&prev_spec).unwrap(),
|
|
569
|
+
"PREVIOUS-RUNTIME-SPEC-MARKER\n",
|
|
570
|
+
"previous runtime spec must be left in place on refusal"
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// E5 §3 解耦(tester 场景2)— add-agent on a team whose runtime spec already exists must still
|
|
575
|
+
// resolve team_dir to the USER role dir for compile_team (find TEAM.md/agents), not the runtime
|
|
576
|
+
// spec dir. Regression: SelectedTeam.spec_workspace=runtime was used as team_dir → compile_team
|
|
577
|
+
// looked for TEAM.md under .team/runtime/<key>/ → "missing TEAM.md".
|
|
578
|
+
#[test]
|
|
579
|
+
fn e5_add_agent_resolves_team_dir_to_role_dir_when_runtime_spec_exists() {
|
|
580
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
581
|
+
// Pre-create the runtime spec so the selector's runtime-first branch is taken (migrated team).
|
|
582
|
+
let runtime_spec = quick_start_runtime_spec_path(&team);
|
|
583
|
+
std::fs::create_dir_all(runtime_spec.parent().unwrap()).unwrap();
|
|
584
|
+
let base = crate::compiler::compile_team(&team).unwrap();
|
|
585
|
+
std::fs::write(&runtime_spec, crate::model::yaml::dumps(&base)).unwrap();
|
|
586
|
+
// External role file (relative-ish, outside agents/).
|
|
587
|
+
let role = team.join("w2-role.md");
|
|
588
|
+
std::fs::write(
|
|
589
|
+
&role,
|
|
590
|
+
"---\nname: w2\nrole: Second Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nw2.\n",
|
|
591
|
+
)
|
|
592
|
+
.unwrap();
|
|
593
|
+
let transport = OfflineTransport::new();
|
|
594
|
+
let result = add_agent_with_transport(&team, &AgentId::new("w2"), &role, false, None, &transport);
|
|
595
|
+
// Core decoupling signature: must NOT fail because compile_team looked for TEAM.md/agents under
|
|
596
|
+
// the runtime spec dir. Before the fix, team_dir=spec_workspace(runtime) → "missing TEAM.md".
|
|
597
|
+
let text = format!("{result:?}");
|
|
598
|
+
assert!(
|
|
599
|
+
!text.contains("missing TEAM.md") && !text.to_lowercase().contains("missing agents"),
|
|
600
|
+
"add-agent must resolve team_dir to the role dir for compile_team (not the runtime spec dir); got {text}"
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// E5 grep guard G1 — writers must land spec via runtime_spec_path (.team/runtime/<team_key>/),
|
|
605
|
+
// never write team.spec.yaml into the user team_dir/agents_dir. Pins the spec-demote invariant.
|
|
606
|
+
#[test]
|
|
607
|
+
fn e5_guard_g1_writers_use_runtime_spec_path_not_user_dir() {
|
|
608
|
+
let source = include_str!("../launch.rs");
|
|
609
|
+
let runtime_writes = source.matches("runtime_spec_path(").count();
|
|
610
|
+
assert!(
|
|
611
|
+
runtime_writes >= 2,
|
|
612
|
+
"G1: quick_start + add_agent must write spec via runtime_spec_path (found {runtime_writes})"
|
|
613
|
+
);
|
|
614
|
+
for forbidden in [
|
|
615
|
+
"let spec_path = agents_dir.join(\"team.spec.yaml\");\n std::fs::write",
|
|
616
|
+
"let spec_path = team_dir.join(\"team.spec.yaml\");\n std::fs::write",
|
|
617
|
+
] {
|
|
618
|
+
assert!(
|
|
619
|
+
!source.contains(forbidden),
|
|
620
|
+
"G1: user-dir spec write idiom must be gone: {forbidden:?}"
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// E5 grep guard G2 — launch.rs must NOT copy a role file into the platform team dir.
|
|
626
|
+
// Pins the Bug1 fix: no `fs::copy` of a role file + no `materialize_added_role_file` reborn.
|
|
627
|
+
#[test]
|
|
628
|
+
fn e5_guard_g2_no_copy_role_into_platform_dir() {
|
|
629
|
+
let source = include_str!("../launch.rs");
|
|
630
|
+
assert!(
|
|
631
|
+
!source.contains("materialize_added_role_file"),
|
|
632
|
+
"G2: materialize_added_role_file (role copy anti-pattern) must stay deleted"
|
|
633
|
+
);
|
|
634
|
+
assert!(
|
|
635
|
+
!source.contains("copy role file"),
|
|
636
|
+
"G2: no 'copy role file' into the platform agents dir (O_TRUNC self-copy bug)"
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
436
640
|
// ═════════════════════════════════════════════════════════════════════════
|
|
437
641
|
// SPAWN sub-phase RED — launch(dry_run=false) must REALLY spawn (unlocks the acceptance
|
|
438
642
|
// framework's cheap real-machine Tier-1). Golden launch/core.py: create the session + one worker
|
|
@@ -754,8 +958,17 @@ fn add_agent_with_transport_spawns_new_worker_not_stub() {
|
|
|
754
958
|
|
|
755
959
|
let _result = add_agent_with_transport(&team, &AgentId::new("worker2"), &role_file, false, None, &transport);
|
|
756
960
|
|
|
757
|
-
// (a) the recompiled spec was written (real subsystem step
|
|
758
|
-
|
|
961
|
+
// (a) the recompiled spec was written (real subsystem step). E5: under .team/runtime/<team_key>/,
|
|
962
|
+
// NOT the user team dir.
|
|
963
|
+
let runtime_spec = find_runtime_spec(&team, "addteam");
|
|
964
|
+
assert!(
|
|
965
|
+
runtime_spec.is_some(),
|
|
966
|
+
"add_agent must recompile + write team.spec.yaml under .team/runtime/<team_key>/"
|
|
967
|
+
);
|
|
968
|
+
assert!(
|
|
969
|
+
!team.join("team.spec.yaml").exists(),
|
|
970
|
+
"E5: add_agent must NOT write spec into the user team dir"
|
|
971
|
+
);
|
|
759
972
|
// (b) the new worker window was spawned (RED: stub recompiles then RequirementUnmet -> ZERO spawns).
|
|
760
973
|
let recorded = transport.spawn_records();
|
|
761
974
|
assert!(
|
|
@@ -839,7 +1052,8 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
839
1052
|
let transport = OfflineTransport::new();
|
|
840
1053
|
let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
841
1054
|
let workspace = team.parent().expect("team_workspace(<base>/teamdir) = <base>");
|
|
842
|
-
|
|
1055
|
+
// E5: spec_path in state now points to .team/runtime/<team_key>/, team_dir stays the user role dir.
|
|
1056
|
+
let spec_path = quick_start_runtime_spec_path(&team);
|
|
843
1057
|
let (raw, state) = raw_runtime_state(workspace);
|
|
844
1058
|
let keys = state.as_object().expect("state root object").keys().cloned().collect::<Vec<_>>();
|
|
845
1059
|
assert_eq!(
|
|
@@ -16,14 +16,36 @@ use super::types::{
|
|
|
16
16
|
// These are contract-callable: RED tests pass alias strings and assert the enum.
|
|
17
17
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
18
18
|
|
|
19
|
-
/// `_normalize_result_status` (`normalize.py:106-123`)
|
|
20
|
-
///
|
|
19
|
+
/// `_normalize_result_status` (`normalize.py:106-123`) — cr verdict (refined,
|
|
20
|
+
/// 2026-06-10) three-way split:
|
|
21
|
+
/// * status missing/null/empty → `Success` — **parity lock** (Python :107
|
|
22
|
+
/// `or "success"`: the implicit-success convention, exit-code-0 equivalent).
|
|
23
|
+
/// * recognized aliases → mapped (Python alias table verbatim).
|
|
24
|
+
/// * a truly UNKNOWN non-empty literal → `Partial` (uncertain ≠ silent success;
|
|
25
|
+
/// MUST-NOT-13 — deliberate divergence from Python :123's fallback "success",
|
|
26
|
+
/// P7-type RS-first fix; the raw literal is observable via the `_observed` variant).
|
|
21
27
|
pub fn normalize_result_status(value: Option<&str>) -> ResultStatus {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
normalize_result_status_observed(value).0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The observed variant: `.1` carries the raw unrecognized literal so ingestion
|
|
32
|
+
/// boundaries (wire report_result) can emit `provider.result.unknown_status_normalized`.
|
|
33
|
+
pub fn normalize_result_status_observed(value: Option<&str>) -> (ResultStatus, Option<String>) {
|
|
34
|
+
let token = normalize_token(value);
|
|
35
|
+
if token.is_empty() {
|
|
36
|
+
return (ResultStatus::Success, None);
|
|
37
|
+
}
|
|
38
|
+
match token.as_str() {
|
|
39
|
+
"success" | "ok" | "done" | "complete" | "completed" | "passed" | "pass" => {
|
|
40
|
+
(ResultStatus::Success, None)
|
|
41
|
+
}
|
|
42
|
+
"blocked" | "block" => (ResultStatus::Blocked, None),
|
|
43
|
+
"failed" | "fail" | "error" => (ResultStatus::Failed, None),
|
|
44
|
+
"partial" | "partially_done" => (ResultStatus::Partial, None),
|
|
45
|
+
_ => (
|
|
46
|
+
ResultStatus::Partial,
|
|
47
|
+
Some(value.unwrap_or_default().to_string()),
|
|
48
|
+
),
|
|
27
49
|
}
|
|
28
50
|
}
|
|
29
51
|
|
|
@@ -83,12 +83,14 @@
|
|
|
83
83
|
assert_eq!(normalize_change_kind(None, "inspected it"), ChangeKind::Observed);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
// ── #40 normalize_result_status: 'partiallydone' (no underscore) →
|
|
87
|
-
//
|
|
86
|
+
// ── #40 normalize_result_status: 'partiallydone' (no underscore) → Partial ──
|
|
87
|
+
// Re-anchored per cr verdict (refined, 2026-06-10): the old Python golden mapped
|
|
88
|
+
// an unmatched literal to success (probe_mcp_red.py STATUS); RS deliberately
|
|
89
|
+
// normalizes a NON-EMPTY unknown literal to Partial (MUST-NOT-13, P7-type fix).
|
|
88
90
|
#[test]
|
|
89
|
-
fn
|
|
90
|
-
assert_eq!(normalize_result_status(Some("partiallydone")), ResultStatus::
|
|
91
|
-
assert_eq!(normalize_result_status(Some("PartiallyDone")), ResultStatus::
|
|
91
|
+
fn result_status_partiallydone_no_underscore_is_partial() {
|
|
92
|
+
assert_eq!(normalize_result_status(Some("partiallydone")), ResultStatus::Partial);
|
|
93
|
+
assert_eq!(normalize_result_status(Some("PartiallyDone")), ResultStatus::Partial);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
// ── #35/#46/#53 normalize_changes: full alias set + empty-path SKIP ─────────
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#[test]
|
|
2
2
|
fn result_status_alias_chain() {
|
|
3
|
-
//
|
|
3
|
+
// cr verdict (refined, 2026-06-10) three-way split: missing/null/empty stays
|
|
4
|
+
// the parity-locked implicit success (Python :107); a NON-EMPTY unknown
|
|
5
|
+
// literal is Partial, never silent success (MUST-NOT-13, diverges from :123).
|
|
4
6
|
assert_eq!(normalize_result_status(None), ResultStatus::Success);
|
|
5
|
-
assert_eq!(normalize_result_status(Some("
|
|
7
|
+
assert_eq!(normalize_result_status(Some("")), ResultStatus::Success);
|
|
8
|
+
assert_eq!(normalize_result_status(Some("weird")), ResultStatus::Partial);
|
|
6
9
|
// ok/done/complete/completed/passed/pass → success
|
|
7
10
|
for a in ["ok", "DONE", "complete", "completed", "passed", "pass"] {
|
|
8
11
|
assert_eq!(normalize_result_status(Some(a)), ResultStatus::Success, "alias {a}");
|
|
@@ -22,7 +22,9 @@ use super::helpers::{
|
|
|
22
22
|
json_dumps_default, latest_task_for_assignee, non_empty_string, normalized_envelope_value, object_fields,
|
|
23
23
|
requires_ack_for_target, tool_runtime_error,
|
|
24
24
|
};
|
|
25
|
-
use super::normalize::{
|
|
25
|
+
use super::normalize::{
|
|
26
|
+
compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
|
|
27
|
+
};
|
|
26
28
|
use super::types::{Scope, SendOutcome, ToolError, ToolErrorReason, ToolOk, ToolResult, VisiblePeers};
|
|
27
29
|
|
|
28
30
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -321,6 +323,14 @@ impl TeamOrchestratorTools {
|
|
|
321
323
|
insert_array(obj, "next_actions", next_actions);
|
|
322
324
|
}
|
|
323
325
|
}
|
|
326
|
+
// T3-1 cr verdict (refined): an unknown non-empty status literal normalizes to
|
|
327
|
+
// Partial and must be OBSERVABLE at this ingestion boundary (the envelope-borne
|
|
328
|
+
// path; the wire `status` arg path emits at dispatch). Never a silent swallow.
|
|
329
|
+
if let Some(raw) =
|
|
330
|
+
normalize_result_status_observed(base.get("status").and_then(Value::as_str)).1
|
|
331
|
+
{
|
|
332
|
+
self.note_unknown_result_status(&raw);
|
|
333
|
+
}
|
|
324
334
|
let normalized = normalize_report_envelope(&base);
|
|
325
335
|
let env_value = normalized_envelope_value(&normalized);
|
|
326
336
|
let owner_team = self.canonical_owner_team_key()?;
|
|
@@ -333,6 +343,20 @@ impl TeamOrchestratorTools {
|
|
|
333
343
|
.and_then(|value| compact_tool_result(&value))
|
|
334
344
|
}
|
|
335
345
|
|
|
346
|
+
/// T3-1 cr verdict: the observable record of an unknown→Partial status
|
|
347
|
+
/// normalization (`provider.result.unknown_status_normalized`, raw literal
|
|
348
|
+
/// included) — MUST-NOT-13: the swallow is never silent.
|
|
349
|
+
pub(crate) fn note_unknown_result_status(&self, raw: &str) {
|
|
350
|
+
let _ = EventLog::new(&self.workspace).write(
|
|
351
|
+
"provider.result.unknown_status_normalized",
|
|
352
|
+
serde_json::json!({
|
|
353
|
+
"agent_id": self.agent_id.as_ref().map(AgentId::as_str),
|
|
354
|
+
"raw_status": raw,
|
|
355
|
+
"normalized": "partial",
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
336
360
|
/// `update_state` (`tools.py:316-325`): delegated through the lifecycle tools
|
|
337
361
|
/// facade. S0 preserves the old placeholder behavior.
|
|
338
362
|
pub fn update_state(&self, note: &str) -> ToolResult {
|
|
@@ -411,7 +411,17 @@ pub(crate) fn dispatch_tool(tools: &TeamOrchestratorTools, tool: McpTool, args:
|
|
|
411
411
|
McpTool::ReportResult => tools.report_result(
|
|
412
412
|
args.get("envelope"),
|
|
413
413
|
args.get("summary").and_then(Value::as_str),
|
|
414
|
-
|
|
414
|
+
// cr verdict (T3-1 refined): an unknown status literal normalizes to
|
|
415
|
+
// Partial and is OBSERVABLE at this ingestion boundary, never silent.
|
|
416
|
+
{
|
|
417
|
+
let (status, unknown) = crate::mcp_server::normalize::normalize_result_status_observed(
|
|
418
|
+
args.get("status").and_then(Value::as_str),
|
|
419
|
+
);
|
|
420
|
+
if let Some(raw) = unknown {
|
|
421
|
+
tools.note_unknown_result_status(&raw);
|
|
422
|
+
}
|
|
423
|
+
status
|
|
424
|
+
},
|
|
415
425
|
args.get("changes").and_then(Value::as_array).map(Vec::as_slice),
|
|
416
426
|
args.get("tests").and_then(Value::as_array).map(Vec::as_slice),
|
|
417
427
|
args.get("risks").and_then(Value::as_array).map(Vec::as_slice),
|
|
@@ -17,6 +17,13 @@ pub fn runtime_dir(workspace: &Path) -> PathBuf {
|
|
|
17
17
|
team_subdir(workspace, "runtime")
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/// E5:**spec.yaml 的唯一落地点** = `<workspace>/.team/runtime/<team_key>/team.spec.yaml`。
|
|
21
|
+
/// 用户终裁:角色定义(TEAM.md/agents/*.md)=第一真相源;spec=中间产物,**绝不落用户目录**。
|
|
22
|
+
/// 所有 spec 读写经此单点,杜绝 `<user_dir>/team.spec.yaml`。
|
|
23
|
+
pub fn runtime_spec_path(workspace: &Path, team_key: &str) -> PathBuf {
|
|
24
|
+
runtime_dir(workspace).join(team_key).join("team.spec.yaml")
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
/// `<workspace>/.team/logs`。
|
|
21
28
|
pub fn logs_dir(workspace: &Path) -> PathBuf {
|
|
22
29
|
team_subdir(workspace, "logs")
|
|
@@ -409,9 +409,31 @@ fn semantic_errors(spec: &Yaml, base_dir: &Path) -> Vec<String> {
|
|
|
409
409
|
let map_agents: Vec<&Yaml> = agents.iter().filter(|a| a.is_map()).collect();
|
|
410
410
|
|
|
411
411
|
// duplicate agent id(集合含 None 复刻 Python `{a.get("id") ...}`)。
|
|
412
|
+
// SMOKE-1 N38 失败可解释性:不仅报"有重复",还点出**哪个 id 重复**(可能多个),
|
|
413
|
+
// 给 operator 直接定位线索;locate.md §"Smallest likely code touch" item 3。
|
|
412
414
|
let id_set: HashSet<Option<&str>> = map_agents.iter().map(|a| a.get("id").and_then(Yaml::as_str)).collect();
|
|
413
415
|
if id_set.len() != map_agents.len() {
|
|
414
|
-
|
|
416
|
+
let mut seen: HashSet<&str> = HashSet::new();
|
|
417
|
+
let mut duplicates: Vec<&str> = Vec::new();
|
|
418
|
+
for agent in &map_agents {
|
|
419
|
+
if let Some(id) = agent.get("id").and_then(Yaml::as_str) {
|
|
420
|
+
if !seen.insert(id) && !duplicates.contains(&id) {
|
|
421
|
+
duplicates.push(id);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if duplicates.is_empty() {
|
|
426
|
+
e.push("/agents: duplicate agent id".to_string());
|
|
427
|
+
} else {
|
|
428
|
+
e.push(format!(
|
|
429
|
+
"/agents: duplicate agent id: {}",
|
|
430
|
+
duplicates
|
|
431
|
+
.iter()
|
|
432
|
+
.map(|id| format!("`{id}`"))
|
|
433
|
+
.collect::<Vec<_>>()
|
|
434
|
+
.join(", ")
|
|
435
|
+
));
|
|
436
|
+
}
|
|
415
437
|
}
|
|
416
438
|
// all_ids:present 的 agent id + leader id(若 truthy)。
|
|
417
439
|
let mut all_ids: HashSet<&str> = map_agents.iter().filter_map(|a| a.get("id").and_then(Yaml::as_str)).collect();
|
|
@@ -21,12 +21,22 @@ pub fn install(opts: &InstallOptions) -> Result<InstallReport, PackagingError> {
|
|
|
21
21
|
dry_run: true,
|
|
22
22
|
source: default_skill_source(),
|
|
23
23
|
})?;
|
|
24
|
+
// T3-6 (harvest §1): never a hardcoded DoctorStatus::Ok with no check behind it —
|
|
25
|
+
// run the real packaging doctor (schema diagnosis + gates) for the invoking
|
|
26
|
+
// workspace so the report reflects an actual result.
|
|
27
|
+
let doctor = super::migrate::doctor(&super::types::DoctorOptions {
|
|
28
|
+
workspace: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
|
29
|
+
gate: None,
|
|
30
|
+
fix: false,
|
|
31
|
+
cleanup_orphans: false,
|
|
32
|
+
confirm: false,
|
|
33
|
+
})?;
|
|
24
34
|
Ok(InstallReport {
|
|
25
35
|
installed_bin,
|
|
26
36
|
version: Version::current(),
|
|
27
37
|
replace: None,
|
|
28
38
|
skills,
|
|
29
|
-
doctor
|
|
39
|
+
doctor,
|
|
30
40
|
path_hint: diagnose_path(&bin_dir)?,
|
|
31
41
|
})
|
|
32
42
|
}
|
|
@@ -58,7 +68,7 @@ pub fn uninstall(opts: &UninstallOptions) -> Result<UninstallOutcome, PackagingE
|
|
|
58
68
|
|
|
59
69
|
let home = home_dir();
|
|
60
70
|
let mut removed_skill_dirs = Vec::new();
|
|
61
|
-
for target in
|
|
71
|
+
for target in SkillTarget::SINGLE_TARGETS {
|
|
62
72
|
if let Some(dest) = target.dest_dir(&home) {
|
|
63
73
|
if dest.0.exists() {
|
|
64
74
|
std::fs::remove_dir_all(&dest.0)?;
|
|
@@ -102,7 +112,7 @@ pub fn install_skill(opts: &SkillInstallOptions) -> Result<Vec<SkillInstallOutco
|
|
|
102
112
|
));
|
|
103
113
|
}
|
|
104
114
|
let targets: Vec<SkillTarget> = match opts.target {
|
|
105
|
-
SkillTarget::All =>
|
|
115
|
+
SkillTarget::All => SkillTarget::SINGLE_TARGETS.to_vec(),
|
|
106
116
|
target => vec![target],
|
|
107
117
|
};
|
|
108
118
|
let home = home_dir();
|
|
@@ -116,11 +126,27 @@ pub fn install_skill(opts: &SkillInstallOptions) -> Result<Vec<SkillInstallOutco
|
|
|
116
126
|
};
|
|
117
127
|
let mut removed_stale = Vec::new();
|
|
118
128
|
if !opts.dry_run {
|
|
129
|
+
// T2-1 (harvest §1): NEVER pre-wipe the user's existing skill dir — stage
|
|
130
|
+
// the copy into a sibling temp dir and only swap after the copy succeeded
|
|
131
|
+
// (write_worker_mcp_config tmp+rename 范式). A failed copy leaves the
|
|
132
|
+
// user's dir byte-identical.
|
|
133
|
+
let staging = staging_dir_for(&dest.0)?;
|
|
134
|
+
let _ = std::fs::remove_dir_all(&staging);
|
|
135
|
+
if let Err(error) = copy_tree(&opts.source, &staging) {
|
|
136
|
+
let _ = std::fs::remove_dir_all(&staging);
|
|
137
|
+
return Err(error);
|
|
138
|
+
}
|
|
119
139
|
if dest.0.exists() {
|
|
120
140
|
removed_stale = collect_files(&dest.0)?;
|
|
121
141
|
std::fs::remove_dir_all(&dest.0)?;
|
|
122
142
|
}
|
|
123
|
-
|
|
143
|
+
if let Err(error) = std::fs::rename(&staging, &dest.0) {
|
|
144
|
+
let _ = std::fs::remove_dir_all(&staging);
|
|
145
|
+
return Err(PackagingError::Io(std::io::Error::other(format!(
|
|
146
|
+
"swap staged skill dir into {}: {error}",
|
|
147
|
+
dest.0.display()
|
|
148
|
+
))));
|
|
149
|
+
}
|
|
124
150
|
}
|
|
125
151
|
out.push(SkillInstallOutcome {
|
|
126
152
|
target,
|
|
@@ -260,6 +286,18 @@ fn collect_files_inner(path: &Path, out: &mut Vec<PathBuf>) -> Result<(), Packag
|
|
|
260
286
|
Ok(())
|
|
261
287
|
}
|
|
262
288
|
|
|
289
|
+
/// T2-1: a sibling staging path for the atomic skill-dir swap (same parent so the
|
|
290
|
+
/// final rename never crosses filesystems).
|
|
291
|
+
fn staging_dir_for(dest: &Path) -> Result<PathBuf, PackagingError> {
|
|
292
|
+
let name = dest
|
|
293
|
+
.file_name()
|
|
294
|
+
.and_then(|name| name.to_str())
|
|
295
|
+
.unwrap_or("skill");
|
|
296
|
+
let parent = dest.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."));
|
|
297
|
+
std::fs::create_dir_all(&parent)?;
|
|
298
|
+
Ok(parent.join(format!(".{name}.ta-staging-{}", std::process::id())))
|
|
299
|
+
}
|
|
300
|
+
|
|
263
301
|
fn copy_tree(source: &Path, dest: &Path) -> Result<(), PackagingError> {
|
|
264
302
|
if !source.exists() {
|
|
265
303
|
return Err(PackagingError::Io(std::io::Error::new(
|