@team-agent/installer 0.3.13 → 0.3.14
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/mod.rs +168 -75
- package/crates/team-agent/src/leader/start.rs +279 -4
- package/crates/team-agent/src/lifecycle/launch.rs +132 -23
- package/crates/team-agent/src/lifecycle/restart/common.rs +50 -40
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +125 -32
- package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
- package/crates/team-agent/src/lifecycle/tests/core.rs +181 -45
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +267 -65
- package/crates/team-agent/src/tmux_backend.rs +5 -0
- package/crates/team-agent/src/transport.rs +4 -0
- package/package.json +4 -4
|
@@ -121,12 +121,23 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
|
|
|
121
121
|
|
|
122
122
|
let transport = OfflineTransport::new();
|
|
123
123
|
let result = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
124
|
-
assert!(
|
|
124
|
+
assert!(
|
|
125
|
+
matches!(result, Ok(QuickStartReport::Ready { .. })),
|
|
126
|
+
"quick-start failed: {result:?}"
|
|
127
|
+
);
|
|
125
128
|
|
|
126
129
|
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
127
|
-
assert!(state_path.exists(), "quick-start .team/current must persist runtime state under project root");
|
|
128
130
|
assert!(
|
|
129
|
-
|
|
131
|
+
state_path.exists(),
|
|
132
|
+
"quick-start .team/current must persist runtime state under project root"
|
|
133
|
+
);
|
|
134
|
+
assert!(
|
|
135
|
+
!workspace
|
|
136
|
+
.join(".team")
|
|
137
|
+
.join(".team")
|
|
138
|
+
.join("runtime")
|
|
139
|
+
.join("state.json")
|
|
140
|
+
.exists(),
|
|
130
141
|
"quick-start .team/current must not create nested .team/.team runtime state"
|
|
131
142
|
);
|
|
132
143
|
|
|
@@ -137,11 +148,21 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
|
|
|
137
148
|
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
138
149
|
)
|
|
139
150
|
.expect("status/collect selector should resolve project root");
|
|
140
|
-
assert_eq!(
|
|
151
|
+
assert_eq!(
|
|
152
|
+
selected.run_workspace,
|
|
153
|
+
workspace,
|
|
154
|
+
"input={}",
|
|
155
|
+
input.display()
|
|
156
|
+
);
|
|
141
157
|
// E5: selector resolves spec_path to .team/runtime/<team_key>/ (team_key="current").
|
|
142
158
|
let expected_spec = crate::model::paths::runtime_spec_path(&workspace, "current");
|
|
143
159
|
assert_eq!(
|
|
144
|
-
selected
|
|
160
|
+
selected
|
|
161
|
+
.spec_path
|
|
162
|
+
.as_deref()
|
|
163
|
+
.map(std::fs::canonicalize)
|
|
164
|
+
.transpose()
|
|
165
|
+
.unwrap(),
|
|
145
166
|
Some(std::fs::canonicalize(&expected_spec).unwrap()),
|
|
146
167
|
"input={}",
|
|
147
168
|
input.display()
|
|
@@ -155,14 +176,20 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
|
|
|
155
176
|
summary: false,
|
|
156
177
|
json: true,
|
|
157
178
|
});
|
|
158
|
-
assert!(
|
|
179
|
+
assert!(
|
|
180
|
+
status.is_ok(),
|
|
181
|
+
"status should normalize teamdir to project-root runtime state: {status:?}"
|
|
182
|
+
);
|
|
159
183
|
|
|
160
184
|
let collect = crate::cli::cmd_collect(&crate::cli::CollectArgs {
|
|
161
185
|
result_file: None,
|
|
162
186
|
workspace: team.clone(),
|
|
163
187
|
json: true,
|
|
164
188
|
});
|
|
165
|
-
assert!(
|
|
189
|
+
assert!(
|
|
190
|
+
collect.is_ok(),
|
|
191
|
+
"collect should normalize teamdir to the same project-root state/spec: {collect:?}"
|
|
192
|
+
);
|
|
166
193
|
}
|
|
167
194
|
|
|
168
195
|
// P0 — quick_start over an INVALID role doc (missing `provider`) must surface the REAL compile
|
|
@@ -222,7 +249,15 @@ fn launch_dry_run_resolves_real_plan_not_stub_error() {
|
|
|
222
249
|
fn start_agent_proceeds_past_owner_gate_for_unowned_workspace() {
|
|
223
250
|
let ws = unowned_running_ws();
|
|
224
251
|
let transport = OfflineTransport::new();
|
|
225
|
-
let result = start_agent_with_transport(
|
|
252
|
+
let result = start_agent_with_transport(
|
|
253
|
+
&ws,
|
|
254
|
+
&AgentId::new("w1"),
|
|
255
|
+
false,
|
|
256
|
+
false,
|
|
257
|
+
true,
|
|
258
|
+
None,
|
|
259
|
+
&transport,
|
|
260
|
+
);
|
|
226
261
|
assert!(
|
|
227
262
|
!matches!(result, Err(LifecycleError::OwnerRefused(_))),
|
|
228
263
|
"start_agent must reach the real chain for a no-owner workspace, not hard-refuse owner; got {result:?}"
|
|
@@ -292,7 +327,10 @@ fn fork_agent_proceeds_past_owner_gate_for_unowned_workspace() {
|
|
|
292
327
|
fn quick_start_full_ready_real_spawn() {
|
|
293
328
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
294
329
|
let report = quick_start(&team, None, true, true, None).expect("quick_start launches the team");
|
|
295
|
-
assert!(
|
|
330
|
+
assert!(
|
|
331
|
+
matches!(report, QuickStartReport::Ready { .. }),
|
|
332
|
+
"got {report:?}"
|
|
333
|
+
);
|
|
296
334
|
}
|
|
297
335
|
|
|
298
336
|
// ═════════════════════════════════════════════════════════════════════════
|
|
@@ -375,7 +413,9 @@ fn compiled_spec_path_with_paused_agent(team: &std::path::Path) -> PathBuf {
|
|
|
375
413
|
_ => None,
|
|
376
414
|
})
|
|
377
415
|
.expect("compiled spec must contain agents");
|
|
378
|
-
let first = agents
|
|
416
|
+
let first = agents
|
|
417
|
+
.first_mut()
|
|
418
|
+
.expect("compiled spec must contain an agent");
|
|
379
419
|
let crate::model::yaml::Value::Map(agent) = first else {
|
|
380
420
|
panic!("compiled agent must be a map");
|
|
381
421
|
};
|
|
@@ -422,7 +462,10 @@ fn spine_launch_dry_run_safety_reflects_spec_dangerous() {
|
|
|
422
462
|
let team = quick_start_team_dir_custom(QS_TEAM_MD_DANGEROUS, QS_VALID_ROLE);
|
|
423
463
|
let spec_path = compiled_spec_path(&team);
|
|
424
464
|
let report = launch(&spec_path, true, false, true).expect("dry_run launch");
|
|
425
|
-
assert!(
|
|
465
|
+
assert!(
|
|
466
|
+
report.safety.enabled,
|
|
467
|
+
"safety must reflect spec.runtime.dangerous_auto_approve=true"
|
|
468
|
+
);
|
|
426
469
|
assert_eq!(
|
|
427
470
|
report.safety.source,
|
|
428
471
|
DangerousApprovalSource::RuntimeConfig,
|
|
@@ -471,7 +514,14 @@ fn spine_add_agent_rejects_duplicate_agent_id() {
|
|
|
471
514
|
let role = team.join("dup-role.md");
|
|
472
515
|
std::fs::write(&role, QS_VALID_ROLE).unwrap(); // a role doc named 'implementer' = duplicate
|
|
473
516
|
let transport = OfflineTransport::new();
|
|
474
|
-
let result = add_agent_with_transport(
|
|
517
|
+
let result = add_agent_with_transport(
|
|
518
|
+
&team,
|
|
519
|
+
&AgentId::new("implementer"),
|
|
520
|
+
&role,
|
|
521
|
+
false,
|
|
522
|
+
None,
|
|
523
|
+
&transport,
|
|
524
|
+
);
|
|
475
525
|
let text = format!("{result:?}");
|
|
476
526
|
assert!(
|
|
477
527
|
text.contains("already exists"),
|
|
@@ -524,7 +574,7 @@ fn e5_add_agent_does_not_copy_role_into_platform_dir_and_injects_into_spec() {
|
|
|
524
574
|
#[test]
|
|
525
575
|
fn e5_restart_rebuilds_runtime_spec_from_role_docs() {
|
|
526
576
|
let ws = restart_ws_two_resumable_workers(); // has TEAM.md + agents/{alpha,bravo}.md + state
|
|
527
|
-
|
|
577
|
+
// Edit a role doc AFTER initial compile: change alpha's role text.
|
|
528
578
|
std::fs::write(
|
|
529
579
|
ws.join("agents").join("alpha.md"),
|
|
530
580
|
"---\nname: alpha\nrole: RENAMED Alpha Role\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nAlpha edited.\n",
|
|
@@ -551,10 +601,13 @@ fn e5_restart_rebuilds_runtime_spec_from_role_docs() {
|
|
|
551
601
|
#[test]
|
|
552
602
|
fn red2_restart_finds_runtime_spec_not_missing_when_user_dir_has_no_spec() {
|
|
553
603
|
let ws = restart_ws_two_resumable_workers(); // has TEAM.md + agents + state
|
|
554
|
-
|
|
555
|
-
|
|
604
|
+
// Simulate spec-demote: remove any user-dir spec the fixture wrote; restart must still find
|
|
605
|
+
// the runtime spec (rebuilt from role docs / read-order B), NOT report "missing spec".
|
|
556
606
|
let _ = std::fs::remove_file(ws.join("team.spec.yaml"));
|
|
557
|
-
assert!(
|
|
607
|
+
assert!(
|
|
608
|
+
!ws.join("team.spec.yaml").exists(),
|
|
609
|
+
"user-dir spec removed (spec-demote condition)"
|
|
610
|
+
);
|
|
558
611
|
let transport = OfflineTransport::new();
|
|
559
612
|
let result = restart_with_transport(&ws, false, None, &transport);
|
|
560
613
|
// The gate must NOT short-circuit with "missing spec for restart" — restart must proceed
|
|
@@ -575,7 +628,10 @@ fn red2_restart_empty_workspace_still_reports_missing() {
|
|
|
575
628
|
let result = restart_with_transport(&ws, false, None, &transport);
|
|
576
629
|
let text = format!("{result:?}");
|
|
577
630
|
assert!(
|
|
578
|
-
text.contains("missing spec")
|
|
631
|
+
text.contains("missing spec")
|
|
632
|
+
|| text.to_lowercase().contains("not found")
|
|
633
|
+
|| text.contains("no_local_team_context")
|
|
634
|
+
|| text.to_lowercase().contains("invalid workspace"),
|
|
579
635
|
"RED-2 negative: empty workspace restart must still error (missing/not-found); got {text}"
|
|
580
636
|
);
|
|
581
637
|
let _ = std::fs::remove_dir_all(&ws);
|
|
@@ -595,7 +651,11 @@ fn red2still_entry_gate_resolves_parent_dot_team_form_a() {
|
|
|
595
651
|
let base = temp_ws();
|
|
596
652
|
let team_dir = base.join("teamdir");
|
|
597
653
|
std::fs::create_dir_all(team_dir.join("agents")).unwrap();
|
|
598
|
-
std::fs::write(
|
|
654
|
+
std::fs::write(
|
|
655
|
+
team_dir.join("TEAM.md"),
|
|
656
|
+
"---\nname: teamdir\nobjective: o\nprovider: codex\n---\nt.\n",
|
|
657
|
+
)
|
|
658
|
+
.unwrap();
|
|
599
659
|
std::fs::write(team_dir.join("agents").join("w1.md"), QS_VALID_ROLE).unwrap();
|
|
600
660
|
// .team lives in the PARENT (base), with a runtime spec — mirrors quick-start <dir> from base cwd.
|
|
601
661
|
let spec = crate::compiler::compile_team(&team_dir).unwrap();
|
|
@@ -622,7 +682,10 @@ fn red2still_entry_gate_resolves_parent_dot_team_form_a() {
|
|
|
622
682
|
);
|
|
623
683
|
// Full restart on team_dir must NOT early-exit with missing spec.
|
|
624
684
|
let transport = OfflineTransport::new();
|
|
625
|
-
let text = format!(
|
|
685
|
+
let text = format!(
|
|
686
|
+
"{:?}",
|
|
687
|
+
restart_with_transport(&team_dir, false, None, &transport)
|
|
688
|
+
);
|
|
626
689
|
assert!(
|
|
627
690
|
!text.contains("missing spec for restart") && !text.contains("active team spec not found"),
|
|
628
691
|
"RED-2-STILL Form A: restart <team_dir> (.team in parent) must not report missing; got {text}"
|
|
@@ -635,7 +698,11 @@ fn red2still_entry_gate_same_dir_dot_team_form_b() {
|
|
|
635
698
|
// Form B: .team in team_dir itself (cwd == team_dir at quick-start time).
|
|
636
699
|
let team_dir = temp_ws();
|
|
637
700
|
std::fs::create_dir_all(team_dir.join("agents")).unwrap();
|
|
638
|
-
std::fs::write(
|
|
701
|
+
std::fs::write(
|
|
702
|
+
team_dir.join("TEAM.md"),
|
|
703
|
+
"---\nname: tb\nobjective: o\nprovider: codex\n---\nt.\n",
|
|
704
|
+
)
|
|
705
|
+
.unwrap();
|
|
639
706
|
std::fs::write(team_dir.join("agents").join("w1.md"), QS_VALID_ROLE).unwrap();
|
|
640
707
|
let spec = crate::compiler::compile_team(&team_dir).unwrap();
|
|
641
708
|
let runtime_spec = crate::model::paths::runtime_spec_path(&team_dir, "tb");
|
|
@@ -649,7 +716,10 @@ fn red2still_entry_gate_same_dir_dot_team_form_b() {
|
|
|
649
716
|
)
|
|
650
717
|
.unwrap();
|
|
651
718
|
let transport = OfflineTransport::new();
|
|
652
|
-
let text = format!(
|
|
719
|
+
let text = format!(
|
|
720
|
+
"{:?}",
|
|
721
|
+
restart_with_transport(&team_dir, false, None, &transport)
|
|
722
|
+
);
|
|
653
723
|
assert!(
|
|
654
724
|
!text.contains("missing spec for restart") && !text.contains("active team spec not found"),
|
|
655
725
|
"RED-2-STILL Form B: restart <team_dir> (.team in team_dir) must not report missing; got {text}"
|
|
@@ -703,7 +773,8 @@ fn e5_add_agent_resolves_team_dir_to_role_dir_when_runtime_spec_exists() {
|
|
|
703
773
|
)
|
|
704
774
|
.unwrap();
|
|
705
775
|
let transport = OfflineTransport::new();
|
|
706
|
-
let result =
|
|
776
|
+
let result =
|
|
777
|
+
add_agent_with_transport(&team, &AgentId::new("w2"), &role, false, None, &transport);
|
|
707
778
|
// Core decoupling signature: must NOT fail because compile_team looked for TEAM.md/agents under
|
|
708
779
|
// the runtime spec dir. Before the fix, team_dir=spec_workspace(runtime) → "missing TEAM.md".
|
|
709
780
|
let text = format!("{result:?}");
|
|
@@ -825,15 +896,29 @@ fn launch_with_transport_records_one_spawn_per_agent_carrying_build_command() {
|
|
|
825
896
|
"exactly one spawn per started agent; recorded={recorded:?} started={:?}",
|
|
826
897
|
report.started
|
|
827
898
|
);
|
|
828
|
-
assert!(!report.started.is_empty(), "a real (non-dry-run) launch must spawn >=1 worker");
|
|
829
|
-
assert!(!report.dry_run, "launch_with_transport(dry_run=false) must report dry_run=false");
|
|
830
|
-
assert_eq!(recorded[0].0, "spawn_first", "the first worker uses new-session (spawn_first)");
|
|
831
899
|
assert!(
|
|
832
|
-
|
|
900
|
+
!report.started.is_empty(),
|
|
901
|
+
"a real (non-dry-run) launch must spawn >=1 worker"
|
|
902
|
+
);
|
|
903
|
+
assert!(
|
|
904
|
+
!report.dry_run,
|
|
905
|
+
"launch_with_transport(dry_run=false) must report dry_run=false"
|
|
906
|
+
);
|
|
907
|
+
assert_eq!(
|
|
908
|
+
recorded[0].0, "spawn_first",
|
|
909
|
+
"the first worker uses new-session (spawn_first)"
|
|
910
|
+
);
|
|
911
|
+
assert!(
|
|
912
|
+
recorded
|
|
913
|
+
.iter()
|
|
914
|
+
.any(|(_, argv)| argv.iter().any(|a| a == "codex")),
|
|
833
915
|
"each spawn argv must carry the agent's provider build_command (codex); got {recorded:?}"
|
|
834
916
|
);
|
|
835
917
|
assert!(
|
|
836
|
-
report
|
|
918
|
+
report
|
|
919
|
+
.started
|
|
920
|
+
.iter()
|
|
921
|
+
.any(|s| s.agent_id.as_str() == "implementer"),
|
|
837
922
|
"LaunchReport.started must list the compiled agent; got {:?}",
|
|
838
923
|
report.started
|
|
839
924
|
);
|
|
@@ -853,8 +938,17 @@ pub(super) fn seed_healthy_coordinator(workspace: &std::path::Path) {
|
|
|
853
938
|
std::fs::create_dir_all(crate::model::paths::runtime_dir(workspace)).unwrap();
|
|
854
939
|
let _ = crate::message_store::MessageStore::open(workspace).unwrap(); // create db schema (schema.ok)
|
|
855
940
|
let me = crate::coordinator::Pid::new(std::process::id());
|
|
856
|
-
crate::coordinator::write_coordinator_metadata(
|
|
857
|
-
|
|
941
|
+
crate::coordinator::write_coordinator_metadata(
|
|
942
|
+
&wp,
|
|
943
|
+
me,
|
|
944
|
+
crate::coordinator::MetadataSource::Boot,
|
|
945
|
+
)
|
|
946
|
+
.unwrap();
|
|
947
|
+
std::fs::write(
|
|
948
|
+
crate::coordinator::coordinator_pid_path(&wp),
|
|
949
|
+
me.to_string(),
|
|
950
|
+
)
|
|
951
|
+
.unwrap();
|
|
858
952
|
}
|
|
859
953
|
|
|
860
954
|
// RED — quick_start_with_transport must drive the REAL spawn path: launch.dry_run==false, started
|
|
@@ -889,7 +983,10 @@ fn quick_start_with_transport_spawns_workers_not_dry_run() {
|
|
|
889
983
|
launch.started
|
|
890
984
|
);
|
|
891
985
|
assert!(
|
|
892
|
-
launch
|
|
986
|
+
launch
|
|
987
|
+
.started
|
|
988
|
+
.iter()
|
|
989
|
+
.any(|s| s.agent_id.as_str() == "implementer"),
|
|
893
990
|
"launch.started must list the compiled agent 'implementer'; got {:?}",
|
|
894
991
|
launch.started
|
|
895
992
|
);
|
|
@@ -899,9 +996,14 @@ fn quick_start_with_transport_spawns_workers_not_dry_run() {
|
|
|
899
996
|
!recorded.is_empty(),
|
|
900
997
|
"quick_start must drive the transport spawn path (>=1 spawn recorded); the dry-run bug records ZERO spawns"
|
|
901
998
|
);
|
|
902
|
-
assert_eq!(
|
|
999
|
+
assert_eq!(
|
|
1000
|
+
recorded[0].0, "spawn_first",
|
|
1001
|
+
"the first worker uses new-session (spawn_first); got {recorded:?}"
|
|
1002
|
+
);
|
|
903
1003
|
assert!(
|
|
904
|
-
recorded
|
|
1004
|
+
recorded
|
|
1005
|
+
.iter()
|
|
1006
|
+
.any(|(_, argv)| argv.iter().any(|a| a == "codex")),
|
|
905
1007
|
"the spawn argv must carry the agent's provider build_command (codex); got {recorded:?}"
|
|
906
1008
|
);
|
|
907
1009
|
}
|
|
@@ -917,7 +1019,10 @@ fn quick_start_fresh_ws_spawns_resident_tmux_and_coordinator() {
|
|
|
917
1019
|
let report = quick_start(&team, None, true, true, None).expect("quick_start");
|
|
918
1020
|
match report {
|
|
919
1021
|
QuickStartReport::Ready { launch, .. } => {
|
|
920
|
-
assert!(
|
|
1022
|
+
assert!(
|
|
1023
|
+
!launch.dry_run,
|
|
1024
|
+
"a real quick_start must spawn (not dry-run)"
|
|
1025
|
+
);
|
|
921
1026
|
assert!(
|
|
922
1027
|
!launch.started.is_empty(),
|
|
923
1028
|
"a real quick_start must spawn >=1 worker into a live tmux session; got {:?}",
|
|
@@ -949,7 +1054,11 @@ pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
|
|
|
949
1054
|
let bravo_rollout = ws.join("bravo-rollout.jsonl");
|
|
950
1055
|
std::fs::write(&alpha_rollout, "{}\n").unwrap();
|
|
951
1056
|
std::fs::write(&bravo_rollout, "{}\n").unwrap();
|
|
952
|
-
std::fs::write(
|
|
1057
|
+
std::fs::write(
|
|
1058
|
+
ws.join("TEAM.md"),
|
|
1059
|
+
"---\nname: restartteam\nobjective: Restart probe.\nprovider: codex\n---\n\nteam.\n",
|
|
1060
|
+
)
|
|
1061
|
+
.unwrap();
|
|
953
1062
|
std::fs::write(ws.join("agents").join("alpha.md"), DELEG_ROLE_ALPHA).unwrap();
|
|
954
1063
|
std::fs::write(ws.join("agents").join("bravo.md"), DELEG_ROLE_BRAVO).unwrap();
|
|
955
1064
|
let spec = crate::compiler::compile_team(&ws).expect("compile 2-agent team");
|
|
@@ -1039,18 +1148,31 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
|
1039
1148
|
|
|
1040
1149
|
let ws = restart_ws_two_resumable_workers();
|
|
1041
1150
|
let dead_after_first = OfflineTransport::new().with_session_absent_after_spawn_first();
|
|
1042
|
-
let result =
|
|
1043
|
-
|
|
1151
|
+
let result = restart_with_transport_with_readiness_deadline(
|
|
1152
|
+
&ws,
|
|
1153
|
+
false,
|
|
1154
|
+
None,
|
|
1155
|
+
&dead_after_first,
|
|
1156
|
+
Some(0),
|
|
1157
|
+
);
|
|
1044
1158
|
let recorded = dead_after_first.spawn_records();
|
|
1045
1159
|
assert_eq!(
|
|
1046
1160
|
recorded.len(),
|
|
1047
|
-
|
|
1048
|
-
"if
|
|
1161
|
+
1,
|
|
1162
|
+
"if a resumed session disappears after alpha, restart must fail fast before bravo; got result={result:?} recorded={recorded:?}"
|
|
1049
1163
|
);
|
|
1050
1164
|
assert!(
|
|
1051
1165
|
recorded.iter().all(|(kind, _)| kind == "spawn_first"),
|
|
1052
1166
|
"restart must not call spawn_into/new-window against a dead server; result={result:?} recorded={recorded:?}"
|
|
1053
1167
|
);
|
|
1168
|
+
let result_debug = format!("{result:?}");
|
|
1169
|
+
let report = result.expect("resume integrity failure should return a typed failed report");
|
|
1170
|
+
let RestartReport::Failed { failed_agents, .. } = report else {
|
|
1171
|
+
panic!("resume integrity failure must not be Partial/Restarted; got {report:?}");
|
|
1172
|
+
};
|
|
1173
|
+
assert_eq!(failed_agents.len(), 1);
|
|
1174
|
+
assert_eq!(failed_agents[0].agent_id.as_str(), "alpha");
|
|
1175
|
+
assert_eq!(failed_agents[0].phase, "resume");
|
|
1054
1176
|
assert!(
|
|
1055
1177
|
dead_after_first.calls().iter().all(|call| *call != "kill_session"),
|
|
1056
1178
|
"a single worker/session disappearance must not trigger another shared-session kill; calls={:?}",
|
|
@@ -1061,13 +1183,16 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
|
1061
1183
|
.iter()
|
|
1062
1184
|
.find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.agent_failed"))
|
|
1063
1185
|
.expect("restart.agent_failed event for isolated alpha");
|
|
1064
|
-
assert_eq!(
|
|
1186
|
+
assert_eq!(
|
|
1187
|
+
failed.get("agent_id").and_then(|v| v.as_str()),
|
|
1188
|
+
Some("alpha")
|
|
1189
|
+
);
|
|
1065
1190
|
assert!(
|
|
1066
1191
|
failed
|
|
1067
1192
|
.get("error")
|
|
1068
1193
|
.and_then(|v| v.as_str())
|
|
1069
1194
|
.is_some_and(|error| error.contains("session_disappeared_after_spawn")),
|
|
1070
|
-
"restart must report the first resumed agent/session disappearance explicitly; event={failed} result={
|
|
1195
|
+
"restart must report the first resumed agent/session disappearance explicitly; event={failed} result={result_debug}"
|
|
1071
1196
|
);
|
|
1072
1197
|
}
|
|
1073
1198
|
|
|
@@ -1088,9 +1213,15 @@ fn restart_spawn_failure_isolated_to_partial_report() {
|
|
|
1088
1213
|
else {
|
|
1089
1214
|
panic!("single worker failure must return RestartReport::Partial; got {report:?}");
|
|
1090
1215
|
};
|
|
1091
|
-
assert!(
|
|
1216
|
+
assert!(
|
|
1217
|
+
coordinator_started,
|
|
1218
|
+
"partial restart still starts coordinator for successful agents"
|
|
1219
|
+
);
|
|
1092
1220
|
assert_eq!(
|
|
1093
|
-
agents
|
|
1221
|
+
agents
|
|
1222
|
+
.iter()
|
|
1223
|
+
.map(|agent| agent.agent_id.as_str())
|
|
1224
|
+
.collect::<Vec<_>>(),
|
|
1094
1225
|
vec!["alpha"]
|
|
1095
1226
|
);
|
|
1096
1227
|
assert_eq!(failed_agents.len(), 1);
|
|
@@ -1098,8 +1229,18 @@ fn restart_spawn_failure_isolated_to_partial_report() {
|
|
|
1098
1229
|
assert_eq!(failed_agents[0].phase, "spawn");
|
|
1099
1230
|
|
|
1100
1231
|
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1101
|
-
assert_eq!(
|
|
1102
|
-
|
|
1232
|
+
assert_eq!(
|
|
1233
|
+
state
|
|
1234
|
+
.pointer("/agents/alpha/status")
|
|
1235
|
+
.and_then(|v| v.as_str()),
|
|
1236
|
+
Some("running")
|
|
1237
|
+
);
|
|
1238
|
+
assert_eq!(
|
|
1239
|
+
state
|
|
1240
|
+
.pointer("/agents/bravo/status")
|
|
1241
|
+
.and_then(|v| v.as_str()),
|
|
1242
|
+
Some("failed")
|
|
1243
|
+
);
|
|
1103
1244
|
assert!(
|
|
1104
1245
|
state
|
|
1105
1246
|
.pointer("/agents/bravo/restart_error")
|
|
@@ -1113,7 +1254,10 @@ fn restart_spawn_failure_isolated_to_partial_report() {
|
|
|
1113
1254
|
.iter()
|
|
1114
1255
|
.find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.completed"))
|
|
1115
1256
|
.expect("restart.completed event for partial restart");
|
|
1116
|
-
assert_eq!(
|
|
1257
|
+
assert_eq!(
|
|
1258
|
+
completed.get("rc").and_then(|v| v.as_str()),
|
|
1259
|
+
Some("partial")
|
|
1260
|
+
);
|
|
1117
1261
|
assert!(
|
|
1118
1262
|
completed
|
|
1119
1263
|
.get("successful_agents")
|
|
@@ -1201,10 +1345,14 @@ fn restart_times_out_when_spawned_worker_pane_is_not_addressable() {
|
|
|
1201
1345
|
let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
|
|
1202
1346
|
let timeout = events
|
|
1203
1347
|
.iter()
|
|
1204
|
-
.find(|event|
|
|
1348
|
+
.find(|event| {
|
|
1349
|
+
event.get("event").and_then(|v| v.as_str()) == Some("restart.readiness_timeout")
|
|
1350
|
+
})
|
|
1205
1351
|
.expect("restart.readiness_timeout event");
|
|
1206
1352
|
assert_eq!(
|
|
1207
|
-
timeout
|
|
1353
|
+
timeout
|
|
1354
|
+
.get("worker_pane_addressable")
|
|
1355
|
+
.and_then(|v| v.as_bool()),
|
|
1208
1356
|
Some(false),
|
|
1209
1357
|
"timeout event must carry the failed readiness condition: {timeout}"
|
|
1210
1358
|
);
|
|
@@ -1230,7 +1378,15 @@ fn start_agent_with_transport_spawns_resume_not_stub() {
|
|
|
1230
1378
|
seed_healthy_coordinator(&ws);
|
|
1231
1379
|
let transport = OfflineTransport::new();
|
|
1232
1380
|
|
|
1233
|
-
let _result = start_agent_with_transport(
|
|
1381
|
+
let _result = start_agent_with_transport(
|
|
1382
|
+
&ws,
|
|
1383
|
+
&AgentId::new("alpha"),
|
|
1384
|
+
false,
|
|
1385
|
+
false,
|
|
1386
|
+
false,
|
|
1387
|
+
None,
|
|
1388
|
+
&transport,
|
|
1389
|
+
);
|
|
1234
1390
|
|
|
1235
1391
|
let recorded = transport.spawn_records();
|
|
1236
1392
|
assert_eq!(
|
|
@@ -1253,14 +1409,25 @@ fn start_agent_with_transport_spawns_resume_not_stub() {
|
|
|
1253
1409
|
fn add_agent_with_transport_spawns_new_worker_not_stub() {
|
|
1254
1410
|
let team = temp_ws().join("addteam");
|
|
1255
1411
|
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
1256
|
-
std::fs::write(
|
|
1412
|
+
std::fs::write(
|
|
1413
|
+
team.join("TEAM.md"),
|
|
1414
|
+
"---\nname: addteam\nobjective: Add probe.\nprovider: codex\n---\n\nteam.\n",
|
|
1415
|
+
)
|
|
1416
|
+
.unwrap();
|
|
1257
1417
|
std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap(); // existing agent
|
|
1258
1418
|
let role_file = team.join("worker2-role.md"); // OUTSIDE agents/ -> not a duplicate of an existing agent
|
|
1259
1419
|
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
1260
1420
|
seed_healthy_coordinator(&team);
|
|
1261
1421
|
let transport = OfflineTransport::new();
|
|
1262
1422
|
|
|
1263
|
-
let _result = add_agent_with_transport(
|
|
1423
|
+
let _result = add_agent_with_transport(
|
|
1424
|
+
&team,
|
|
1425
|
+
&AgentId::new("worker2"),
|
|
1426
|
+
&role_file,
|
|
1427
|
+
false,
|
|
1428
|
+
None,
|
|
1429
|
+
&transport,
|
|
1430
|
+
);
|
|
1264
1431
|
|
|
1265
1432
|
// (a) the recompiled spec was written (real subsystem step). E5: under .team/runtime/<team_key>/,
|
|
1266
1433
|
// NOT the user team dir.
|
|
@@ -1306,7 +1473,9 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
|
|
|
1306
1473
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
1307
1474
|
let transport = OfflineTransport::new();
|
|
1308
1475
|
let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
1309
|
-
let workspace = team
|
|
1476
|
+
let workspace = team
|
|
1477
|
+
.parent()
|
|
1478
|
+
.expect("team_workspace(<base>/teamdir) = <base>");
|
|
1310
1479
|
let state = crate::state::persist::load_runtime_state(workspace)
|
|
1311
1480
|
.expect("runtime state.json must exist after quick_start");
|
|
1312
1481
|
// (b) the tasks KEY must always be present and a JSON array (golden launch/core.py:69 default []).
|
|
@@ -1318,10 +1487,14 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
|
|
|
1318
1487
|
state.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>())
|
|
1319
1488
|
),
|
|
1320
1489
|
};
|
|
1321
|
-
let tasks = tasks
|
|
1490
|
+
let tasks = tasks
|
|
1491
|
+
.as_array()
|
|
1492
|
+
.expect("`tasks` must be a JSON array (golden default [])");
|
|
1322
1493
|
// (a) spec.tasks must be carried into runtime state — the doc compiler's default task id.
|
|
1323
1494
|
assert!(
|
|
1324
|
-
tasks
|
|
1495
|
+
tasks
|
|
1496
|
+
.iter()
|
|
1497
|
+
.any(|t| t.get("id").and_then(|v| v.as_str()) == Some("task_initial")),
|
|
1325
1498
|
"state.tasks must carry the compiled spec's task (id=task_initial); got {tasks:?}"
|
|
1326
1499
|
);
|
|
1327
1500
|
}
|
|
@@ -1355,11 +1528,18 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
1355
1528
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
1356
1529
|
let transport = OfflineTransport::new();
|
|
1357
1530
|
let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
1358
|
-
let workspace = team
|
|
1531
|
+
let workspace = team
|
|
1532
|
+
.parent()
|
|
1533
|
+
.expect("team_workspace(<base>/teamdir) = <base>");
|
|
1359
1534
|
// E5: spec_path in state now points to .team/runtime/<team_key>/, team_dir stays the user role dir.
|
|
1360
1535
|
let spec_path = quick_start_runtime_spec_path(&team);
|
|
1361
1536
|
let (raw, state) = raw_runtime_state(workspace);
|
|
1362
|
-
let keys = state
|
|
1537
|
+
let keys = state
|
|
1538
|
+
.as_object()
|
|
1539
|
+
.expect("state root object")
|
|
1540
|
+
.keys()
|
|
1541
|
+
.cloned()
|
|
1542
|
+
.collect::<Vec<_>>();
|
|
1363
1543
|
assert_eq!(
|
|
1364
1544
|
keys,
|
|
1365
1545
|
vec![
|
|
@@ -1382,7 +1562,10 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
1382
1562
|
truth (no shadow copy on the root that callers could read across teams); \
|
|
1383
1563
|
raw={raw}"
|
|
1384
1564
|
);
|
|
1385
|
-
assert_eq!(
|
|
1565
|
+
assert_eq!(
|
|
1566
|
+
state["spec_path"],
|
|
1567
|
+
json!(spec_path.canonicalize().unwrap().to_string_lossy())
|
|
1568
|
+
);
|
|
1386
1569
|
assert_eq!(state["workspace"], json!(workspace.to_string_lossy()));
|
|
1387
1570
|
assert_eq!(state["team_dir"], json!(team.to_string_lossy()));
|
|
1388
1571
|
assert_eq!(state["session_name"], json!("team-quickteam"));
|
|
@@ -1392,13 +1575,17 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
1392
1575
|
"leader must be copied from compiled spec.leader"
|
|
1393
1576
|
);
|
|
1394
1577
|
assert!(
|
|
1395
|
-
state["agents"]
|
|
1578
|
+
state["agents"]
|
|
1579
|
+
.as_object()
|
|
1580
|
+
.is_some_and(|agents| agents.contains_key("implementer")),
|
|
1396
1581
|
"existing agents value must remain seeded from compiled spec; got {:?}",
|
|
1397
1582
|
state["agents"]
|
|
1398
1583
|
);
|
|
1399
1584
|
assert!(
|
|
1400
1585
|
state["tasks"].as_array().is_some_and(|tasks| {
|
|
1401
|
-
tasks
|
|
1586
|
+
tasks
|
|
1587
|
+
.iter()
|
|
1588
|
+
.any(|task| task.get("id").and_then(|id| id.as_str()) == Some("task_initial"))
|
|
1402
1589
|
}),
|
|
1403
1590
|
"existing tasks value must remain seeded from compiled spec; got {:?}",
|
|
1404
1591
|
state["tasks"]
|
|
@@ -1418,17 +1605,23 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
1418
1605
|
#[serial(env)]
|
|
1419
1606
|
fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
1420
1607
|
const FIXED_SPAWNED_AT: &str = "2026-06-04T00:00:00+00:00";
|
|
1421
|
-
let _clock_guard =
|
|
1422
|
-
EnvVarGuard::set("TEAM_AGENT_TEST_FIXED_SPAWNED_AT", FIXED_SPAWNED_AT);
|
|
1608
|
+
let _clock_guard = EnvVarGuard::set("TEAM_AGENT_TEST_FIXED_SPAWNED_AT", FIXED_SPAWNED_AT);
|
|
1423
1609
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
1424
1610
|
let transport = OfflineTransport::new();
|
|
1425
1611
|
let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
1426
|
-
let workspace = team
|
|
1612
|
+
let workspace = team
|
|
1613
|
+
.parent()
|
|
1614
|
+
.expect("team_workspace(<base>/teamdir) = <base>");
|
|
1427
1615
|
let (raw, state) = raw_runtime_state(workspace);
|
|
1428
1616
|
let agent = state
|
|
1429
1617
|
.pointer("/agents/implementer")
|
|
1430
1618
|
.unwrap_or_else(|| panic!("implementer agent state missing; raw={raw}"));
|
|
1431
|
-
let keys = agent
|
|
1619
|
+
let keys = agent
|
|
1620
|
+
.as_object()
|
|
1621
|
+
.expect("agent state object")
|
|
1622
|
+
.keys()
|
|
1623
|
+
.cloned()
|
|
1624
|
+
.collect::<Vec<_>>();
|
|
1432
1625
|
assert_eq!(
|
|
1433
1626
|
keys,
|
|
1434
1627
|
vec![
|
|
@@ -1462,7 +1655,9 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
|
1462
1655
|
assert_eq!(agent["window"], json!("implementer"));
|
|
1463
1656
|
assert_eq!(
|
|
1464
1657
|
agent["mcp_config"],
|
|
1465
|
-
json!(workspace
|
|
1658
|
+
json!(workspace
|
|
1659
|
+
.join(".team/runtime/mcp/implementer.json")
|
|
1660
|
+
.to_string_lossy())
|
|
1466
1661
|
);
|
|
1467
1662
|
assert_eq!(
|
|
1468
1663
|
agent["permissions"],
|
|
@@ -1495,7 +1690,9 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
|
1495
1690
|
fn quick_start_paused_agent_state_is_paused_provider_only_and_not_spawned() {
|
|
1496
1691
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
1497
1692
|
let spec_path = compiled_spec_path_with_paused_agent(&team);
|
|
1498
|
-
let workspace = team
|
|
1693
|
+
let workspace = team
|
|
1694
|
+
.parent()
|
|
1695
|
+
.expect("team_workspace(<base>/teamdir) = <base>");
|
|
1499
1696
|
crate::state::persist::save_runtime_state(
|
|
1500
1697
|
workspace,
|
|
1501
1698
|
&json!({
|
|
@@ -1519,7 +1716,12 @@ fn quick_start_paused_agent_state_is_paused_provider_only_and_not_spawned() {
|
|
|
1519
1716
|
let agent = state
|
|
1520
1717
|
.pointer("/agents/implementer")
|
|
1521
1718
|
.unwrap_or_else(|| panic!("implementer agent state missing; raw={raw}"));
|
|
1522
|
-
let keys = agent
|
|
1719
|
+
let keys = agent
|
|
1720
|
+
.as_object()
|
|
1721
|
+
.expect("agent state object")
|
|
1722
|
+
.keys()
|
|
1723
|
+
.cloned()
|
|
1724
|
+
.collect::<Vec<_>>();
|
|
1523
1725
|
assert_eq!(
|
|
1524
1726
|
keys,
|
|
1525
1727
|
vec!["status", "provider"],
|
|
@@ -1041,6 +1041,11 @@ impl Transport for TmuxBackend {
|
|
|
1041
1041
|
Ok(SetEnvOutcome::Applied)
|
|
1042
1042
|
}
|
|
1043
1043
|
|
|
1044
|
+
fn kill_server(&self) -> Result<(), TransportError> {
|
|
1045
|
+
TmuxBackend::kill_server(self);
|
|
1046
|
+
Ok(())
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1044
1049
|
fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
|
|
1045
1050
|
let argv = self.tmux_argv(&[
|
|
1046
1051
|
"tmux".to_string(),
|