@team-agent/installer 0.3.13 → 0.3.15
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/adapters.rs +1 -0
- package/crates/team-agent/src/cli/emit.rs +92 -11
- package/crates/team-agent/src/cli/mod.rs +228 -85
- package/crates/team-agent/src/cli/send.rs +252 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +159 -2
- package/crates/team-agent/src/cli/types.rs +30 -1
- package/crates/team-agent/src/compiler/tests.rs +2 -2
- package/crates/team-agent/src/compiler.rs +1 -1
- package/crates/team-agent/src/fake_worker.rs +32 -145
- package/crates/team-agent/src/leader/start.rs +279 -4
- package/crates/team-agent/src/lifecycle/display.rs +3 -3
- package/crates/team-agent/src/lifecycle/launch.rs +692 -92
- package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
- package/crates/team-agent/src/lifecycle/restart/common.rs +88 -42
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +197 -33
- package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
- package/crates/team-agent/src/lifecycle/tests/core.rs +195 -50
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +934 -72
- package/crates/team-agent/src/lifecycle/types.rs +5 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
- package/crates/team-agent/src/mcp_server/wire.rs +153 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
- package/crates/team-agent/src/messaging/mod.rs +6 -3
- package/crates/team-agent/src/messaging/results.rs +240 -158
- package/crates/team-agent/src/messaging/send.rs +3 -2
- package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
- package/crates/team-agent/src/os_probe.rs +11 -0
- package/crates/team-agent/src/state/persist.rs +6 -0
- package/crates/team-agent/src/tmux_backend.rs +90 -0
- package/crates/team-agent/src/transport/test_support.rs +46 -1
- package/crates/team-agent/src/transport.rs +31 -0
- package/package.json +4 -4
- package/skills/team-agent/references/recovery-runbook.md +277 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
use super::*;
|
|
2
2
|
use crate::transport::test_support::OfflineTransport;
|
|
3
3
|
use serial_test::serial;
|
|
4
|
+
use serde_json::json;
|
|
4
5
|
|
|
5
6
|
const QS_TEAM_MD: &str =
|
|
6
7
|
"---\nname: quickteam\nobjective: Quick start.\nprovider: codex\n---\n\nQuick-start team.\n";
|
|
@@ -18,6 +19,38 @@ pub(super) fn quick_start_team_dir(role_doc: &str) -> PathBuf {
|
|
|
18
19
|
team
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
fn quick_start_team_dir_with_roles(role_docs: &[(&str, &str)]) -> PathBuf {
|
|
23
|
+
let team = temp_ws().join("teamdir");
|
|
24
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
25
|
+
std::fs::write(team.join("TEAM.md"), QS_TEAM_MD).unwrap();
|
|
26
|
+
for (file, role_doc) in role_docs {
|
|
27
|
+
std::fs::write(team.join("agents").join(file), role_doc).unwrap();
|
|
28
|
+
}
|
|
29
|
+
team
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fn role_doc(id: &str) -> String {
|
|
33
|
+
format!(
|
|
34
|
+
"---\nname: {id}\nrole: {id} Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\n{id} worker.\n"
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fn layout_pane(session: &str, window: &str, pane: &str, pane_index: u32) -> crate::transport::PaneInfo {
|
|
39
|
+
crate::transport::PaneInfo {
|
|
40
|
+
pane_id: crate::transport::PaneId::new(pane),
|
|
41
|
+
session: crate::transport::SessionName::new(session),
|
|
42
|
+
window_index: None,
|
|
43
|
+
window_name: Some(crate::transport::WindowName::new(window)),
|
|
44
|
+
pane_index: Some(pane_index),
|
|
45
|
+
tty: None,
|
|
46
|
+
current_command: Some("codex".to_string()),
|
|
47
|
+
current_path: None,
|
|
48
|
+
active: pane_index == 0,
|
|
49
|
+
pane_pid: None,
|
|
50
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
21
54
|
/// E5 spec 迁移:spec 不再落 team_dir,而在 <team_workspace>/.team/runtime/<team_key>/。
|
|
22
55
|
/// team_key = team_dir.name(quick_start_team_dir → "teamdir");workspace = team_workspace(team)。
|
|
23
56
|
pub(super) fn quick_start_runtime_spec_path(team: &std::path::Path) -> PathBuf {
|
|
@@ -121,12 +154,23 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
|
|
|
121
154
|
|
|
122
155
|
let transport = OfflineTransport::new();
|
|
123
156
|
let result = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
124
|
-
assert!(
|
|
157
|
+
assert!(
|
|
158
|
+
matches!(result, Ok(QuickStartReport::Ready { .. })),
|
|
159
|
+
"quick-start failed: {result:?}"
|
|
160
|
+
);
|
|
125
161
|
|
|
126
162
|
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
163
|
assert!(
|
|
129
|
-
|
|
164
|
+
state_path.exists(),
|
|
165
|
+
"quick-start .team/current must persist runtime state under project root"
|
|
166
|
+
);
|
|
167
|
+
assert!(
|
|
168
|
+
!workspace
|
|
169
|
+
.join(".team")
|
|
170
|
+
.join(".team")
|
|
171
|
+
.join("runtime")
|
|
172
|
+
.join("state.json")
|
|
173
|
+
.exists(),
|
|
130
174
|
"quick-start .team/current must not create nested .team/.team runtime state"
|
|
131
175
|
);
|
|
132
176
|
|
|
@@ -137,11 +181,21 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
|
|
|
137
181
|
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
138
182
|
)
|
|
139
183
|
.expect("status/collect selector should resolve project root");
|
|
140
|
-
assert_eq!(
|
|
184
|
+
assert_eq!(
|
|
185
|
+
selected.run_workspace,
|
|
186
|
+
workspace,
|
|
187
|
+
"input={}",
|
|
188
|
+
input.display()
|
|
189
|
+
);
|
|
141
190
|
// E5: selector resolves spec_path to .team/runtime/<team_key>/ (team_key="current").
|
|
142
191
|
let expected_spec = crate::model::paths::runtime_spec_path(&workspace, "current");
|
|
143
192
|
assert_eq!(
|
|
144
|
-
selected
|
|
193
|
+
selected
|
|
194
|
+
.spec_path
|
|
195
|
+
.as_deref()
|
|
196
|
+
.map(std::fs::canonicalize)
|
|
197
|
+
.transpose()
|
|
198
|
+
.unwrap(),
|
|
145
199
|
Some(std::fs::canonicalize(&expected_spec).unwrap()),
|
|
146
200
|
"input={}",
|
|
147
201
|
input.display()
|
|
@@ -155,14 +209,20 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
|
|
|
155
209
|
summary: false,
|
|
156
210
|
json: true,
|
|
157
211
|
});
|
|
158
|
-
assert!(
|
|
212
|
+
assert!(
|
|
213
|
+
status.is_ok(),
|
|
214
|
+
"status should normalize teamdir to project-root runtime state: {status:?}"
|
|
215
|
+
);
|
|
159
216
|
|
|
160
217
|
let collect = crate::cli::cmd_collect(&crate::cli::CollectArgs {
|
|
161
218
|
result_file: None,
|
|
162
219
|
workspace: team.clone(),
|
|
163
220
|
json: true,
|
|
164
221
|
});
|
|
165
|
-
assert!(
|
|
222
|
+
assert!(
|
|
223
|
+
collect.is_ok(),
|
|
224
|
+
"collect should normalize teamdir to the same project-root state/spec: {collect:?}"
|
|
225
|
+
);
|
|
166
226
|
}
|
|
167
227
|
|
|
168
228
|
// P0 — quick_start over an INVALID role doc (missing `provider`) must surface the REAL compile
|
|
@@ -222,7 +282,15 @@ fn launch_dry_run_resolves_real_plan_not_stub_error() {
|
|
|
222
282
|
fn start_agent_proceeds_past_owner_gate_for_unowned_workspace() {
|
|
223
283
|
let ws = unowned_running_ws();
|
|
224
284
|
let transport = OfflineTransport::new();
|
|
225
|
-
let result = start_agent_with_transport(
|
|
285
|
+
let result = start_agent_with_transport(
|
|
286
|
+
&ws,
|
|
287
|
+
&AgentId::new("w1"),
|
|
288
|
+
false,
|
|
289
|
+
false,
|
|
290
|
+
true,
|
|
291
|
+
None,
|
|
292
|
+
&transport,
|
|
293
|
+
);
|
|
226
294
|
assert!(
|
|
227
295
|
!matches!(result, Err(LifecycleError::OwnerRefused(_))),
|
|
228
296
|
"start_agent must reach the real chain for a no-owner workspace, not hard-refuse owner; got {result:?}"
|
|
@@ -292,7 +360,10 @@ fn fork_agent_proceeds_past_owner_gate_for_unowned_workspace() {
|
|
|
292
360
|
fn quick_start_full_ready_real_spawn() {
|
|
293
361
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
294
362
|
let report = quick_start(&team, None, true, true, None).expect("quick_start launches the team");
|
|
295
|
-
assert!(
|
|
363
|
+
assert!(
|
|
364
|
+
matches!(report, QuickStartReport::Ready { .. }),
|
|
365
|
+
"got {report:?}"
|
|
366
|
+
);
|
|
296
367
|
}
|
|
297
368
|
|
|
298
369
|
// ═════════════════════════════════════════════════════════════════════════
|
|
@@ -375,7 +446,9 @@ fn compiled_spec_path_with_paused_agent(team: &std::path::Path) -> PathBuf {
|
|
|
375
446
|
_ => None,
|
|
376
447
|
})
|
|
377
448
|
.expect("compiled spec must contain agents");
|
|
378
|
-
let first = agents
|
|
449
|
+
let first = agents
|
|
450
|
+
.first_mut()
|
|
451
|
+
.expect("compiled spec must contain an agent");
|
|
379
452
|
let crate::model::yaml::Value::Map(agent) = first else {
|
|
380
453
|
panic!("compiled agent must be a map");
|
|
381
454
|
};
|
|
@@ -422,7 +495,10 @@ fn spine_launch_dry_run_safety_reflects_spec_dangerous() {
|
|
|
422
495
|
let team = quick_start_team_dir_custom(QS_TEAM_MD_DANGEROUS, QS_VALID_ROLE);
|
|
423
496
|
let spec_path = compiled_spec_path(&team);
|
|
424
497
|
let report = launch(&spec_path, true, false, true).expect("dry_run launch");
|
|
425
|
-
assert!(
|
|
498
|
+
assert!(
|
|
499
|
+
report.safety.enabled,
|
|
500
|
+
"safety must reflect spec.runtime.dangerous_auto_approve=true"
|
|
501
|
+
);
|
|
426
502
|
assert_eq!(
|
|
427
503
|
report.safety.source,
|
|
428
504
|
DangerousApprovalSource::RuntimeConfig,
|
|
@@ -463,19 +539,35 @@ fn spine_restart_empty_team_is_not_atomic_refusal() {
|
|
|
463
539
|
);
|
|
464
540
|
}
|
|
465
541
|
|
|
466
|
-
//
|
|
467
|
-
// BEFORE any full recompile. Current does a full compile_team with no duplicate-id guard.
|
|
542
|
+
// E25 — add_agent duplicate membership is runtime-state based, not role-doc/spec-file based.
|
|
468
543
|
#[test]
|
|
469
544
|
fn spine_add_agent_rejects_duplicate_agent_id() {
|
|
470
545
|
let team = quick_start_team_dir(QS_VALID_ROLE); // team already has agent 'implementer'
|
|
546
|
+
crate::state::persist::save_runtime_state(
|
|
547
|
+
&team,
|
|
548
|
+
&json!({
|
|
549
|
+
"session_name": "team-quickteam",
|
|
550
|
+
"agents": {
|
|
551
|
+
"implementer": {"status": "running", "provider": "codex", "window": "implementer"}
|
|
552
|
+
}
|
|
553
|
+
}),
|
|
554
|
+
)
|
|
555
|
+
.unwrap();
|
|
471
556
|
let role = team.join("dup-role.md");
|
|
472
557
|
std::fs::write(&role, QS_VALID_ROLE).unwrap(); // a role doc named 'implementer' = duplicate
|
|
473
558
|
let transport = OfflineTransport::new();
|
|
474
|
-
let result = add_agent_with_transport(
|
|
559
|
+
let result = add_agent_with_transport(
|
|
560
|
+
&team,
|
|
561
|
+
&AgentId::new("implementer"),
|
|
562
|
+
&role,
|
|
563
|
+
false,
|
|
564
|
+
None,
|
|
565
|
+
&transport,
|
|
566
|
+
);
|
|
475
567
|
let text = format!("{result:?}");
|
|
476
568
|
assert!(
|
|
477
569
|
text.contains("already exists"),
|
|
478
|
-
"add_agent must reject
|
|
570
|
+
"add_agent must reject an id already present in runtime state; got {text}"
|
|
479
571
|
);
|
|
480
572
|
}
|
|
481
573
|
|
|
@@ -524,7 +616,7 @@ fn e5_add_agent_does_not_copy_role_into_platform_dir_and_injects_into_spec() {
|
|
|
524
616
|
#[test]
|
|
525
617
|
fn e5_restart_rebuilds_runtime_spec_from_role_docs() {
|
|
526
618
|
let ws = restart_ws_two_resumable_workers(); // has TEAM.md + agents/{alpha,bravo}.md + state
|
|
527
|
-
|
|
619
|
+
// Edit a role doc AFTER initial compile: change alpha's role text.
|
|
528
620
|
std::fs::write(
|
|
529
621
|
ws.join("agents").join("alpha.md"),
|
|
530
622
|
"---\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 +643,13 @@ fn e5_restart_rebuilds_runtime_spec_from_role_docs() {
|
|
|
551
643
|
#[test]
|
|
552
644
|
fn red2_restart_finds_runtime_spec_not_missing_when_user_dir_has_no_spec() {
|
|
553
645
|
let ws = restart_ws_two_resumable_workers(); // has TEAM.md + agents + state
|
|
554
|
-
|
|
555
|
-
|
|
646
|
+
// Simulate spec-demote: remove any user-dir spec the fixture wrote; restart must still find
|
|
647
|
+
// the runtime spec (rebuilt from role docs / read-order B), NOT report "missing spec".
|
|
556
648
|
let _ = std::fs::remove_file(ws.join("team.spec.yaml"));
|
|
557
|
-
assert!(
|
|
649
|
+
assert!(
|
|
650
|
+
!ws.join("team.spec.yaml").exists(),
|
|
651
|
+
"user-dir spec removed (spec-demote condition)"
|
|
652
|
+
);
|
|
558
653
|
let transport = OfflineTransport::new();
|
|
559
654
|
let result = restart_with_transport(&ws, false, None, &transport);
|
|
560
655
|
// The gate must NOT short-circuit with "missing spec for restart" — restart must proceed
|
|
@@ -575,7 +670,11 @@ fn red2_restart_empty_workspace_still_reports_missing() {
|
|
|
575
670
|
let result = restart_with_transport(&ws, false, None, &transport);
|
|
576
671
|
let text = format!("{result:?}");
|
|
577
672
|
assert!(
|
|
578
|
-
text.contains("missing spec")
|
|
673
|
+
text.contains("missing spec")
|
|
674
|
+
|| text.to_lowercase().contains("not found")
|
|
675
|
+
|| text.contains("no_local_team_context")
|
|
676
|
+
|| text.to_lowercase().contains("invalid workspace")
|
|
677
|
+
|| text.contains("role definitions missing"),
|
|
579
678
|
"RED-2 negative: empty workspace restart must still error (missing/not-found); got {text}"
|
|
580
679
|
);
|
|
581
680
|
let _ = std::fs::remove_dir_all(&ws);
|
|
@@ -595,7 +694,11 @@ fn red2still_entry_gate_resolves_parent_dot_team_form_a() {
|
|
|
595
694
|
let base = temp_ws();
|
|
596
695
|
let team_dir = base.join("teamdir");
|
|
597
696
|
std::fs::create_dir_all(team_dir.join("agents")).unwrap();
|
|
598
|
-
std::fs::write(
|
|
697
|
+
std::fs::write(
|
|
698
|
+
team_dir.join("TEAM.md"),
|
|
699
|
+
"---\nname: teamdir\nobjective: o\nprovider: codex\n---\nt.\n",
|
|
700
|
+
)
|
|
701
|
+
.unwrap();
|
|
599
702
|
std::fs::write(team_dir.join("agents").join("w1.md"), QS_VALID_ROLE).unwrap();
|
|
600
703
|
// .team lives in the PARENT (base), with a runtime spec — mirrors quick-start <dir> from base cwd.
|
|
601
704
|
let spec = crate::compiler::compile_team(&team_dir).unwrap();
|
|
@@ -622,7 +725,10 @@ fn red2still_entry_gate_resolves_parent_dot_team_form_a() {
|
|
|
622
725
|
);
|
|
623
726
|
// Full restart on team_dir must NOT early-exit with missing spec.
|
|
624
727
|
let transport = OfflineTransport::new();
|
|
625
|
-
let text = format!(
|
|
728
|
+
let text = format!(
|
|
729
|
+
"{:?}",
|
|
730
|
+
restart_with_transport(&team_dir, false, None, &transport)
|
|
731
|
+
);
|
|
626
732
|
assert!(
|
|
627
733
|
!text.contains("missing spec for restart") && !text.contains("active team spec not found"),
|
|
628
734
|
"RED-2-STILL Form A: restart <team_dir> (.team in parent) must not report missing; got {text}"
|
|
@@ -635,7 +741,11 @@ fn red2still_entry_gate_same_dir_dot_team_form_b() {
|
|
|
635
741
|
// Form B: .team in team_dir itself (cwd == team_dir at quick-start time).
|
|
636
742
|
let team_dir = temp_ws();
|
|
637
743
|
std::fs::create_dir_all(team_dir.join("agents")).unwrap();
|
|
638
|
-
std::fs::write(
|
|
744
|
+
std::fs::write(
|
|
745
|
+
team_dir.join("TEAM.md"),
|
|
746
|
+
"---\nname: tb\nobjective: o\nprovider: codex\n---\nt.\n",
|
|
747
|
+
)
|
|
748
|
+
.unwrap();
|
|
639
749
|
std::fs::write(team_dir.join("agents").join("w1.md"), QS_VALID_ROLE).unwrap();
|
|
640
750
|
let spec = crate::compiler::compile_team(&team_dir).unwrap();
|
|
641
751
|
let runtime_spec = crate::model::paths::runtime_spec_path(&team_dir, "tb");
|
|
@@ -649,7 +759,10 @@ fn red2still_entry_gate_same_dir_dot_team_form_b() {
|
|
|
649
759
|
)
|
|
650
760
|
.unwrap();
|
|
651
761
|
let transport = OfflineTransport::new();
|
|
652
|
-
let text = format!(
|
|
762
|
+
let text = format!(
|
|
763
|
+
"{:?}",
|
|
764
|
+
restart_with_transport(&team_dir, false, None, &transport)
|
|
765
|
+
);
|
|
653
766
|
assert!(
|
|
654
767
|
!text.contains("missing spec for restart") && !text.contains("active team spec not found"),
|
|
655
768
|
"RED-2-STILL Form B: restart <team_dir> (.team in team_dir) must not report missing; got {text}"
|
|
@@ -703,7 +816,8 @@ fn e5_add_agent_resolves_team_dir_to_role_dir_when_runtime_spec_exists() {
|
|
|
703
816
|
)
|
|
704
817
|
.unwrap();
|
|
705
818
|
let transport = OfflineTransport::new();
|
|
706
|
-
let result =
|
|
819
|
+
let result =
|
|
820
|
+
add_agent_with_transport(&team, &AgentId::new("w2"), &role, false, None, &transport);
|
|
707
821
|
// Core decoupling signature: must NOT fail because compile_team looked for TEAM.md/agents under
|
|
708
822
|
// the runtime spec dir. Before the fix, team_dir=spec_workspace(runtime) → "missing TEAM.md".
|
|
709
823
|
let text = format!("{result:?}");
|
|
@@ -825,15 +939,29 @@ fn launch_with_transport_records_one_spawn_per_agent_carrying_build_command() {
|
|
|
825
939
|
"exactly one spawn per started agent; recorded={recorded:?} started={:?}",
|
|
826
940
|
report.started
|
|
827
941
|
);
|
|
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
942
|
assert!(
|
|
832
|
-
|
|
943
|
+
!report.started.is_empty(),
|
|
944
|
+
"a real (non-dry-run) launch must spawn >=1 worker"
|
|
945
|
+
);
|
|
946
|
+
assert!(
|
|
947
|
+
!report.dry_run,
|
|
948
|
+
"launch_with_transport(dry_run=false) must report dry_run=false"
|
|
949
|
+
);
|
|
950
|
+
assert_eq!(
|
|
951
|
+
recorded[0].0, "spawn_first",
|
|
952
|
+
"the first worker uses new-session (spawn_first)"
|
|
953
|
+
);
|
|
954
|
+
assert!(
|
|
955
|
+
recorded
|
|
956
|
+
.iter()
|
|
957
|
+
.any(|(_, argv)| argv.iter().any(|a| a == "codex")),
|
|
833
958
|
"each spawn argv must carry the agent's provider build_command (codex); got {recorded:?}"
|
|
834
959
|
);
|
|
835
960
|
assert!(
|
|
836
|
-
report
|
|
961
|
+
report
|
|
962
|
+
.started
|
|
963
|
+
.iter()
|
|
964
|
+
.any(|s| s.agent_id.as_str() == "implementer"),
|
|
837
965
|
"LaunchReport.started must list the compiled agent; got {:?}",
|
|
838
966
|
report.started
|
|
839
967
|
);
|
|
@@ -853,8 +981,17 @@ pub(super) fn seed_healthy_coordinator(workspace: &std::path::Path) {
|
|
|
853
981
|
std::fs::create_dir_all(crate::model::paths::runtime_dir(workspace)).unwrap();
|
|
854
982
|
let _ = crate::message_store::MessageStore::open(workspace).unwrap(); // create db schema (schema.ok)
|
|
855
983
|
let me = crate::coordinator::Pid::new(std::process::id());
|
|
856
|
-
crate::coordinator::write_coordinator_metadata(
|
|
857
|
-
|
|
984
|
+
crate::coordinator::write_coordinator_metadata(
|
|
985
|
+
&wp,
|
|
986
|
+
me,
|
|
987
|
+
crate::coordinator::MetadataSource::Boot,
|
|
988
|
+
)
|
|
989
|
+
.unwrap();
|
|
990
|
+
std::fs::write(
|
|
991
|
+
crate::coordinator::coordinator_pid_path(&wp),
|
|
992
|
+
me.to_string(),
|
|
993
|
+
)
|
|
994
|
+
.unwrap();
|
|
858
995
|
}
|
|
859
996
|
|
|
860
997
|
// RED — quick_start_with_transport must drive the REAL spawn path: launch.dry_run==false, started
|
|
@@ -889,7 +1026,10 @@ fn quick_start_with_transport_spawns_workers_not_dry_run() {
|
|
|
889
1026
|
launch.started
|
|
890
1027
|
);
|
|
891
1028
|
assert!(
|
|
892
|
-
launch
|
|
1029
|
+
launch
|
|
1030
|
+
.started
|
|
1031
|
+
.iter()
|
|
1032
|
+
.any(|s| s.agent_id.as_str() == "implementer"),
|
|
893
1033
|
"launch.started must list the compiled agent 'implementer'; got {:?}",
|
|
894
1034
|
launch.started
|
|
895
1035
|
);
|
|
@@ -899,13 +1039,212 @@ fn quick_start_with_transport_spawns_workers_not_dry_run() {
|
|
|
899
1039
|
!recorded.is_empty(),
|
|
900
1040
|
"quick_start must drive the transport spawn path (>=1 spawn recorded); the dry-run bug records ZERO spawns"
|
|
901
1041
|
);
|
|
902
|
-
assert_eq!(
|
|
1042
|
+
assert_eq!(
|
|
1043
|
+
recorded[0].0, "spawn_first",
|
|
1044
|
+
"the first worker uses new-session (spawn_first); got {recorded:?}"
|
|
1045
|
+
);
|
|
903
1046
|
assert!(
|
|
904
|
-
recorded
|
|
1047
|
+
recorded
|
|
1048
|
+
.iter()
|
|
1049
|
+
.any(|(_, argv)| argv.iter().any(|a| a == "codex")),
|
|
905
1050
|
"the spawn argv must carry the agent's provider build_command (codex); got {recorded:?}"
|
|
906
1051
|
);
|
|
907
1052
|
}
|
|
908
1053
|
|
|
1054
|
+
#[test]
|
|
1055
|
+
fn adaptive_layout_plan_8_workers_is_3_3_2() {
|
|
1056
|
+
let agents = (1..=8)
|
|
1057
|
+
.map(|i| AgentId::new(format!("w{i}")))
|
|
1058
|
+
.collect::<Vec<_>>();
|
|
1059
|
+
let plan = adaptive_layout_plan(&agents, 3);
|
|
1060
|
+
let windows = plan
|
|
1061
|
+
.iter()
|
|
1062
|
+
.map(|placement| placement.layout_window.as_str().to_string())
|
|
1063
|
+
.collect::<Vec<_>>();
|
|
1064
|
+
assert_eq!(
|
|
1065
|
+
windows,
|
|
1066
|
+
vec![
|
|
1067
|
+
"team-w1", "team-w1", "team-w1", "team-w2", "team-w2", "team-w2", "team-w3",
|
|
1068
|
+
"team-w3",
|
|
1069
|
+
]
|
|
1070
|
+
);
|
|
1071
|
+
let pane_counts = ["team-w1", "team-w2", "team-w3"]
|
|
1072
|
+
.into_iter()
|
|
1073
|
+
.map(|window| windows.iter().filter(|actual| actual.as_str() == window).count())
|
|
1074
|
+
.collect::<Vec<_>>();
|
|
1075
|
+
assert_eq!(pane_counts, vec![3, 3, 2]);
|
|
1076
|
+
assert_eq!(
|
|
1077
|
+
plan.iter()
|
|
1078
|
+
.filter(|placement| placement.starts_window)
|
|
1079
|
+
.map(|placement| placement.agent_id.as_str().to_string())
|
|
1080
|
+
.collect::<Vec<_>>(),
|
|
1081
|
+
vec!["w1", "w4", "w7"]
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
#[test]
|
|
1086
|
+
fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
|
|
1087
|
+
let roles = (1..=4)
|
|
1088
|
+
.map(|i| {
|
|
1089
|
+
let id = format!("w{i}");
|
|
1090
|
+
(format!("{id}.md"), role_doc(&id))
|
|
1091
|
+
})
|
|
1092
|
+
.collect::<Vec<_>>();
|
|
1093
|
+
let role_refs = roles
|
|
1094
|
+
.iter()
|
|
1095
|
+
.map(|(file, doc)| (file.as_str(), doc.as_str()))
|
|
1096
|
+
.collect::<Vec<_>>();
|
|
1097
|
+
let team = quick_start_team_dir_with_roles(&role_refs);
|
|
1098
|
+
let workspace = team.parent().expect("team_workspace(team_dir) = parent");
|
|
1099
|
+
seed_healthy_coordinator(workspace);
|
|
1100
|
+
let transport = OfflineTransport::new();
|
|
1101
|
+
|
|
1102
|
+
let report = quick_start_with_transport(&team, None, true, true, None, &transport)
|
|
1103
|
+
.expect("quick_start_with_transport must reach Ready");
|
|
1104
|
+
|
|
1105
|
+
let (launch, attach_commands, display_backend) = match report {
|
|
1106
|
+
QuickStartReport::Ready { launch, attach_commands, display_backend, .. } => {
|
|
1107
|
+
(*launch, attach_commands, display_backend)
|
|
1108
|
+
}
|
|
1109
|
+
other => panic!("quick_start must reach Ready; got {other:?}"),
|
|
1110
|
+
};
|
|
1111
|
+
assert_eq!(display_backend, "adaptive");
|
|
1112
|
+
assert_eq!(
|
|
1113
|
+
transport
|
|
1114
|
+
.spawn_records()
|
|
1115
|
+
.iter()
|
|
1116
|
+
.map(|(kind, _)| kind.as_str())
|
|
1117
|
+
.collect::<Vec<_>>(),
|
|
1118
|
+
vec!["spawn_first", "spawn_split", "spawn_split", "spawn_into"],
|
|
1119
|
+
"4 default-adaptive workers should create team-w1 with 3 panes, then team-w2"
|
|
1120
|
+
);
|
|
1121
|
+
assert_eq!(
|
|
1122
|
+
launch
|
|
1123
|
+
.started
|
|
1124
|
+
.iter()
|
|
1125
|
+
.map(|started| {
|
|
1126
|
+
(
|
|
1127
|
+
started.agent_id.as_str().to_string(),
|
|
1128
|
+
started.layout_window.as_ref().map(|w| w.as_str().to_string()),
|
|
1129
|
+
started.pane_index,
|
|
1130
|
+
)
|
|
1131
|
+
})
|
|
1132
|
+
.collect::<Vec<_>>(),
|
|
1133
|
+
vec![
|
|
1134
|
+
("w1".to_string(), Some("team-w1".to_string()), Some(0)),
|
|
1135
|
+
("w2".to_string(), Some("team-w1".to_string()), Some(1)),
|
|
1136
|
+
("w3".to_string(), Some("team-w1".to_string()), Some(2)),
|
|
1137
|
+
("w4".to_string(), Some("team-w2".to_string()), Some(0)),
|
|
1138
|
+
]
|
|
1139
|
+
);
|
|
1140
|
+
assert!(
|
|
1141
|
+
attach_commands.iter().any(|cmd| cmd.contains(":team-w1")),
|
|
1142
|
+
"attach commands must include the first layout window: {attach_commands:?}"
|
|
1143
|
+
);
|
|
1144
|
+
assert!(
|
|
1145
|
+
attach_commands.iter().any(|cmd| cmd.contains(":team-w2")),
|
|
1146
|
+
"attach commands must include the second layout window: {attach_commands:?}"
|
|
1147
|
+
);
|
|
1148
|
+
|
|
1149
|
+
let (_raw, state) = raw_runtime_state(workspace);
|
|
1150
|
+
assert_eq!(state["display_backend"], json!("adaptive"));
|
|
1151
|
+
assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("team-w1")));
|
|
1152
|
+
assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
|
|
1153
|
+
assert_eq!(state.pointer("/agents/w1/pane_index"), Some(&json!(0)));
|
|
1154
|
+
assert_eq!(
|
|
1155
|
+
state.pointer("/agents/w1/display/backend"),
|
|
1156
|
+
Some(&json!("adaptive"))
|
|
1157
|
+
);
|
|
1158
|
+
assert_eq!(
|
|
1159
|
+
state.pointer("/agents/w4/layout_window"),
|
|
1160
|
+
Some(&json!("team-w2"))
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
#[test]
|
|
1165
|
+
#[serial(env)]
|
|
1166
|
+
fn quick_start_tmux_backend_prefers_absolute_tmux_env_endpoint() {
|
|
1167
|
+
let leader_socket = "/tmp/team-agent-layout-leader-socket";
|
|
1168
|
+
let _tmux = EnvVarGuard::set("TMUX", &format!("{leader_socket},123,0"));
|
|
1169
|
+
let backend = quick_start_tmux_backend(std::path::Path::new("/tmp/layout-workspace"));
|
|
1170
|
+
|
|
1171
|
+
assert_eq!(
|
|
1172
|
+
crate::transport::Transport::tmux_endpoint(&backend).as_deref(),
|
|
1173
|
+
Some(leader_socket),
|
|
1174
|
+
"quick-start from inside tmux must use the caller's full $TMUX socket endpoint"
|
|
1175
|
+
);
|
|
1176
|
+
assert_eq!(
|
|
1177
|
+
selected_tmux_socket_source(&backend, std::path::Path::new("/tmp/layout-workspace")),
|
|
1178
|
+
Some("leader_env")
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
#[test]
|
|
1183
|
+
fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
|
|
1184
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
1185
|
+
let workspace = team.parent().expect("team_workspace(team_dir) = parent");
|
|
1186
|
+
seed_healthy_coordinator(workspace);
|
|
1187
|
+
let endpoint = "/tmp/team-agent-layout-selected-socket";
|
|
1188
|
+
let transport = OfflineTransport::new().with_tmux_endpoint(endpoint);
|
|
1189
|
+
|
|
1190
|
+
let report = quick_start_with_transport(&team, None, true, true, None, &transport)
|
|
1191
|
+
.expect("quick_start_with_transport must reach Ready");
|
|
1192
|
+
let attach_commands = match report {
|
|
1193
|
+
QuickStartReport::Ready { attach_commands, .. } => attach_commands,
|
|
1194
|
+
other => panic!("quick_start must reach Ready; got {other:?}"),
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
assert!(
|
|
1198
|
+
attach_commands
|
|
1199
|
+
.iter()
|
|
1200
|
+
.any(|cmd| cmd.contains(&format!("tmux -S {endpoint} attach -t "))),
|
|
1201
|
+
"attach commands must use the selected socket endpoint: {attach_commands:?}"
|
|
1202
|
+
);
|
|
1203
|
+
let (_raw, state) = raw_runtime_state(workspace);
|
|
1204
|
+
assert_eq!(state["tmux_endpoint"], json!(endpoint));
|
|
1205
|
+
assert_eq!(state["tmux_socket"], json!(endpoint));
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
#[test]
|
|
1209
|
+
fn quick_start_no_display_keeps_one_window_per_agent() {
|
|
1210
|
+
let roles = ["w1", "w2"]
|
|
1211
|
+
.into_iter()
|
|
1212
|
+
.map(|id| (format!("{id}.md"), role_doc(id)))
|
|
1213
|
+
.collect::<Vec<_>>();
|
|
1214
|
+
let role_refs = roles
|
|
1215
|
+
.iter()
|
|
1216
|
+
.map(|(file, doc)| (file.as_str(), doc.as_str()))
|
|
1217
|
+
.collect::<Vec<_>>();
|
|
1218
|
+
let team = quick_start_team_dir_with_roles(&role_refs);
|
|
1219
|
+
let workspace = team.parent().expect("team_workspace(team_dir) = parent");
|
|
1220
|
+
seed_healthy_coordinator(workspace);
|
|
1221
|
+
let transport = OfflineTransport::new();
|
|
1222
|
+
|
|
1223
|
+
let report = quick_start_with_transport_in_workspace_with_display(
|
|
1224
|
+
workspace, &team, None, true, true, None, &transport, false,
|
|
1225
|
+
)
|
|
1226
|
+
.expect("quick_start_with_transport must reach Ready");
|
|
1227
|
+
|
|
1228
|
+
let display_backend = match report {
|
|
1229
|
+
QuickStartReport::Ready { display_backend, .. } => display_backend,
|
|
1230
|
+
other => panic!("quick_start must reach Ready; got {other:?}"),
|
|
1231
|
+
};
|
|
1232
|
+
assert_eq!(display_backend, "none");
|
|
1233
|
+
assert_eq!(
|
|
1234
|
+
transport
|
|
1235
|
+
.spawn_records()
|
|
1236
|
+
.iter()
|
|
1237
|
+
.map(|(kind, _)| kind.as_str())
|
|
1238
|
+
.collect::<Vec<_>>(),
|
|
1239
|
+
vec!["spawn_first", "spawn_into"],
|
|
1240
|
+
"--no-display must use the legacy one-worker-window spawn path"
|
|
1241
|
+
);
|
|
1242
|
+
let (_raw, state) = raw_runtime_state(workspace);
|
|
1243
|
+
assert_eq!(state["display_backend"], json!("none"));
|
|
1244
|
+
assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("w1")));
|
|
1245
|
+
assert_eq!(state.pointer("/agents/w1/layout_window"), None);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
909
1248
|
// REAL-MACHINE residency boundary (acceptance framework): the PUBLIC quick_start (real TmuxBackend +
|
|
910
1249
|
// real start_coordinator) on a fresh ws must leave a LIVE tmux session AND a ps-verifiable resident
|
|
911
1250
|
// coordinator daemon (start_coordinator -> live pid). The framework verifies residency via ps; here we
|
|
@@ -917,7 +1256,10 @@ fn quick_start_fresh_ws_spawns_resident_tmux_and_coordinator() {
|
|
|
917
1256
|
let report = quick_start(&team, None, true, true, None).expect("quick_start");
|
|
918
1257
|
match report {
|
|
919
1258
|
QuickStartReport::Ready { launch, .. } => {
|
|
920
|
-
assert!(
|
|
1259
|
+
assert!(
|
|
1260
|
+
!launch.dry_run,
|
|
1261
|
+
"a real quick_start must spawn (not dry-run)"
|
|
1262
|
+
);
|
|
921
1263
|
assert!(
|
|
922
1264
|
!launch.started.is_empty(),
|
|
923
1265
|
"a real quick_start must spawn >=1 worker into a live tmux session; got {:?}",
|
|
@@ -949,7 +1291,11 @@ pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
|
|
|
949
1291
|
let bravo_rollout = ws.join("bravo-rollout.jsonl");
|
|
950
1292
|
std::fs::write(&alpha_rollout, "{}\n").unwrap();
|
|
951
1293
|
std::fs::write(&bravo_rollout, "{}\n").unwrap();
|
|
952
|
-
std::fs::write(
|
|
1294
|
+
std::fs::write(
|
|
1295
|
+
ws.join("TEAM.md"),
|
|
1296
|
+
"---\nname: restartteam\nobjective: Restart probe.\nprovider: codex\n---\n\nteam.\n",
|
|
1297
|
+
)
|
|
1298
|
+
.unwrap();
|
|
953
1299
|
std::fs::write(ws.join("agents").join("alpha.md"), DELEG_ROLE_ALPHA).unwrap();
|
|
954
1300
|
std::fs::write(ws.join("agents").join("bravo.md"), DELEG_ROLE_BRAVO).unwrap();
|
|
955
1301
|
let spec = crate::compiler::compile_team(&ws).expect("compile 2-agent team");
|
|
@@ -1039,18 +1385,31 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
|
1039
1385
|
|
|
1040
1386
|
let ws = restart_ws_two_resumable_workers();
|
|
1041
1387
|
let dead_after_first = OfflineTransport::new().with_session_absent_after_spawn_first();
|
|
1042
|
-
let result =
|
|
1043
|
-
|
|
1388
|
+
let result = restart_with_transport_with_readiness_deadline(
|
|
1389
|
+
&ws,
|
|
1390
|
+
false,
|
|
1391
|
+
None,
|
|
1392
|
+
&dead_after_first,
|
|
1393
|
+
Some(0),
|
|
1394
|
+
);
|
|
1044
1395
|
let recorded = dead_after_first.spawn_records();
|
|
1045
1396
|
assert_eq!(
|
|
1046
1397
|
recorded.len(),
|
|
1047
|
-
|
|
1048
|
-
"if
|
|
1398
|
+
1,
|
|
1399
|
+
"if a resumed session disappears after alpha, restart must fail fast before bravo; got result={result:?} recorded={recorded:?}"
|
|
1049
1400
|
);
|
|
1050
1401
|
assert!(
|
|
1051
1402
|
recorded.iter().all(|(kind, _)| kind == "spawn_first"),
|
|
1052
1403
|
"restart must not call spawn_into/new-window against a dead server; result={result:?} recorded={recorded:?}"
|
|
1053
1404
|
);
|
|
1405
|
+
let result_debug = format!("{result:?}");
|
|
1406
|
+
let report = result.expect("resume integrity failure should return a typed failed report");
|
|
1407
|
+
let RestartReport::Failed { failed_agents, .. } = report else {
|
|
1408
|
+
panic!("resume integrity failure must not be Partial/Restarted; got {report:?}");
|
|
1409
|
+
};
|
|
1410
|
+
assert_eq!(failed_agents.len(), 1);
|
|
1411
|
+
assert_eq!(failed_agents[0].agent_id.as_str(), "alpha");
|
|
1412
|
+
assert_eq!(failed_agents[0].phase, "resume");
|
|
1054
1413
|
assert!(
|
|
1055
1414
|
dead_after_first.calls().iter().all(|call| *call != "kill_session"),
|
|
1056
1415
|
"a single worker/session disappearance must not trigger another shared-session kill; calls={:?}",
|
|
@@ -1061,13 +1420,16 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
|
1061
1420
|
.iter()
|
|
1062
1421
|
.find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.agent_failed"))
|
|
1063
1422
|
.expect("restart.agent_failed event for isolated alpha");
|
|
1064
|
-
assert_eq!(
|
|
1423
|
+
assert_eq!(
|
|
1424
|
+
failed.get("agent_id").and_then(|v| v.as_str()),
|
|
1425
|
+
Some("alpha")
|
|
1426
|
+
);
|
|
1065
1427
|
assert!(
|
|
1066
1428
|
failed
|
|
1067
1429
|
.get("error")
|
|
1068
1430
|
.and_then(|v| v.as_str())
|
|
1069
1431
|
.is_some_and(|error| error.contains("session_disappeared_after_spawn")),
|
|
1070
|
-
"restart must report the first resumed agent/session disappearance explicitly; event={failed} result={
|
|
1432
|
+
"restart must report the first resumed agent/session disappearance explicitly; event={failed} result={result_debug}"
|
|
1071
1433
|
);
|
|
1072
1434
|
}
|
|
1073
1435
|
|
|
@@ -1088,9 +1450,15 @@ fn restart_spawn_failure_isolated_to_partial_report() {
|
|
|
1088
1450
|
else {
|
|
1089
1451
|
panic!("single worker failure must return RestartReport::Partial; got {report:?}");
|
|
1090
1452
|
};
|
|
1091
|
-
assert!(
|
|
1453
|
+
assert!(
|
|
1454
|
+
coordinator_started,
|
|
1455
|
+
"partial restart still starts coordinator for successful agents"
|
|
1456
|
+
);
|
|
1092
1457
|
assert_eq!(
|
|
1093
|
-
agents
|
|
1458
|
+
agents
|
|
1459
|
+
.iter()
|
|
1460
|
+
.map(|agent| agent.agent_id.as_str())
|
|
1461
|
+
.collect::<Vec<_>>(),
|
|
1094
1462
|
vec!["alpha"]
|
|
1095
1463
|
);
|
|
1096
1464
|
assert_eq!(failed_agents.len(), 1);
|
|
@@ -1098,8 +1466,18 @@ fn restart_spawn_failure_isolated_to_partial_report() {
|
|
|
1098
1466
|
assert_eq!(failed_agents[0].phase, "spawn");
|
|
1099
1467
|
|
|
1100
1468
|
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1101
|
-
assert_eq!(
|
|
1102
|
-
|
|
1469
|
+
assert_eq!(
|
|
1470
|
+
state
|
|
1471
|
+
.pointer("/agents/alpha/status")
|
|
1472
|
+
.and_then(|v| v.as_str()),
|
|
1473
|
+
Some("running")
|
|
1474
|
+
);
|
|
1475
|
+
assert_eq!(
|
|
1476
|
+
state
|
|
1477
|
+
.pointer("/agents/bravo/status")
|
|
1478
|
+
.and_then(|v| v.as_str()),
|
|
1479
|
+
Some("failed")
|
|
1480
|
+
);
|
|
1103
1481
|
assert!(
|
|
1104
1482
|
state
|
|
1105
1483
|
.pointer("/agents/bravo/restart_error")
|
|
@@ -1113,7 +1491,10 @@ fn restart_spawn_failure_isolated_to_partial_report() {
|
|
|
1113
1491
|
.iter()
|
|
1114
1492
|
.find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.completed"))
|
|
1115
1493
|
.expect("restart.completed event for partial restart");
|
|
1116
|
-
assert_eq!(
|
|
1494
|
+
assert_eq!(
|
|
1495
|
+
completed.get("rc").and_then(|v| v.as_str()),
|
|
1496
|
+
Some("partial")
|
|
1497
|
+
);
|
|
1117
1498
|
assert!(
|
|
1118
1499
|
completed
|
|
1119
1500
|
.get("successful_agents")
|
|
@@ -1201,10 +1582,14 @@ fn restart_times_out_when_spawned_worker_pane_is_not_addressable() {
|
|
|
1201
1582
|
let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
|
|
1202
1583
|
let timeout = events
|
|
1203
1584
|
.iter()
|
|
1204
|
-
.find(|event|
|
|
1585
|
+
.find(|event| {
|
|
1586
|
+
event.get("event").and_then(|v| v.as_str()) == Some("restart.readiness_timeout")
|
|
1587
|
+
})
|
|
1205
1588
|
.expect("restart.readiness_timeout event");
|
|
1206
1589
|
assert_eq!(
|
|
1207
|
-
timeout
|
|
1590
|
+
timeout
|
|
1591
|
+
.get("worker_pane_addressable")
|
|
1592
|
+
.and_then(|v| v.as_bool()),
|
|
1208
1593
|
Some(false),
|
|
1209
1594
|
"timeout event must carry the failed readiness condition: {timeout}"
|
|
1210
1595
|
);
|
|
@@ -1230,7 +1615,15 @@ fn start_agent_with_transport_spawns_resume_not_stub() {
|
|
|
1230
1615
|
seed_healthy_coordinator(&ws);
|
|
1231
1616
|
let transport = OfflineTransport::new();
|
|
1232
1617
|
|
|
1233
|
-
let _result = start_agent_with_transport(
|
|
1618
|
+
let _result = start_agent_with_transport(
|
|
1619
|
+
&ws,
|
|
1620
|
+
&AgentId::new("alpha"),
|
|
1621
|
+
false,
|
|
1622
|
+
false,
|
|
1623
|
+
false,
|
|
1624
|
+
None,
|
|
1625
|
+
&transport,
|
|
1626
|
+
);
|
|
1234
1627
|
|
|
1235
1628
|
let recorded = transport.spawn_records();
|
|
1236
1629
|
assert_eq!(
|
|
@@ -1253,14 +1646,25 @@ fn start_agent_with_transport_spawns_resume_not_stub() {
|
|
|
1253
1646
|
fn add_agent_with_transport_spawns_new_worker_not_stub() {
|
|
1254
1647
|
let team = temp_ws().join("addteam");
|
|
1255
1648
|
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
1256
|
-
std::fs::write(
|
|
1649
|
+
std::fs::write(
|
|
1650
|
+
team.join("TEAM.md"),
|
|
1651
|
+
"---\nname: addteam\nobjective: Add probe.\nprovider: codex\n---\n\nteam.\n",
|
|
1652
|
+
)
|
|
1653
|
+
.unwrap();
|
|
1257
1654
|
std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap(); // existing agent
|
|
1258
1655
|
let role_file = team.join("worker2-role.md"); // OUTSIDE agents/ -> not a duplicate of an existing agent
|
|
1259
1656
|
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
1260
1657
|
seed_healthy_coordinator(&team);
|
|
1261
1658
|
let transport = OfflineTransport::new();
|
|
1262
1659
|
|
|
1263
|
-
let _result = add_agent_with_transport(
|
|
1660
|
+
let _result = add_agent_with_transport(
|
|
1661
|
+
&team,
|
|
1662
|
+
&AgentId::new("worker2"),
|
|
1663
|
+
&role_file,
|
|
1664
|
+
false,
|
|
1665
|
+
None,
|
|
1666
|
+
&transport,
|
|
1667
|
+
);
|
|
1264
1668
|
|
|
1265
1669
|
// (a) the recompiled spec was written (real subsystem step). E5: under .team/runtime/<team_key>/,
|
|
1266
1670
|
// NOT the user team dir.
|
|
@@ -1286,6 +1690,419 @@ fn add_agent_with_transport_spawns_new_worker_not_stub() {
|
|
|
1286
1690
|
);
|
|
1287
1691
|
}
|
|
1288
1692
|
|
|
1693
|
+
#[test]
|
|
1694
|
+
fn add_agent_role_doc_exists_runtime_missing_succeeds() {
|
|
1695
|
+
let team = temp_ws().join("add_role_doc_team");
|
|
1696
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
1697
|
+
std::fs::write(
|
|
1698
|
+
team.join("TEAM.md"),
|
|
1699
|
+
"---\nname: addroledoc\nobjective: Add role-doc probe.\nprovider: codex\n---\n\nteam.\n",
|
|
1700
|
+
)
|
|
1701
|
+
.unwrap();
|
|
1702
|
+
std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap();
|
|
1703
|
+
let role_file = team.join("agents").join("worker2.md");
|
|
1704
|
+
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
1705
|
+
crate::state::persist::save_runtime_state(
|
|
1706
|
+
&team,
|
|
1707
|
+
&json!({
|
|
1708
|
+
"session_name": "team-addroledoc",
|
|
1709
|
+
"agents": {
|
|
1710
|
+
"implementer": {"status": "running", "provider": "codex", "window": "implementer"}
|
|
1711
|
+
}
|
|
1712
|
+
}),
|
|
1713
|
+
)
|
|
1714
|
+
.unwrap();
|
|
1715
|
+
seed_healthy_coordinator(&team);
|
|
1716
|
+
let transport = OfflineTransport::new();
|
|
1717
|
+
|
|
1718
|
+
let result = add_agent_with_transport(
|
|
1719
|
+
&team,
|
|
1720
|
+
&AgentId::new("worker2"),
|
|
1721
|
+
&role_file,
|
|
1722
|
+
false,
|
|
1723
|
+
None,
|
|
1724
|
+
&transport,
|
|
1725
|
+
);
|
|
1726
|
+
|
|
1727
|
+
assert!(
|
|
1728
|
+
result.is_ok(),
|
|
1729
|
+
"a prepared agents/<id>.md role doc is not membership; runtime state is authoritative: {result:?}"
|
|
1730
|
+
);
|
|
1731
|
+
let state = crate::state::persist::load_runtime_state(&team).expect("load state");
|
|
1732
|
+
assert!(
|
|
1733
|
+
state.pointer("/agents/worker2").is_some(),
|
|
1734
|
+
"add-agent must add worker2 to runtime state; state={state}"
|
|
1735
|
+
);
|
|
1736
|
+
assert_eq!(
|
|
1737
|
+
transport.spawn_records().len(),
|
|
1738
|
+
1,
|
|
1739
|
+
"add-agent must spawn exactly the new worker"
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
#[test]
|
|
1744
|
+
fn start_agent_runtime_missing_role_doc_exists_no_side_effect() {
|
|
1745
|
+
let team = temp_ws().join("start_missing_team");
|
|
1746
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
1747
|
+
std::fs::write(
|
|
1748
|
+
team.join("TEAM.md"),
|
|
1749
|
+
"---\nname: startmissing\nobjective: Start missing probe.\nprovider: codex\n---\n\nteam.\n",
|
|
1750
|
+
)
|
|
1751
|
+
.unwrap();
|
|
1752
|
+
std::fs::write(team.join("agents").join("reviewer.md"), DELEG_ROLE_WORKER2).unwrap();
|
|
1753
|
+
crate::state::persist::save_runtime_state(
|
|
1754
|
+
&team,
|
|
1755
|
+
&json!({
|
|
1756
|
+
"session_name": "team-startmissing",
|
|
1757
|
+
"agents": {
|
|
1758
|
+
"implementer": {"status": "running", "provider": "codex", "window": "implementer"}
|
|
1759
|
+
}
|
|
1760
|
+
}),
|
|
1761
|
+
)
|
|
1762
|
+
.unwrap();
|
|
1763
|
+
let transport = OfflineTransport::new();
|
|
1764
|
+
|
|
1765
|
+
let result = start_agent_with_transport(
|
|
1766
|
+
&team,
|
|
1767
|
+
&AgentId::new("reviewer"),
|
|
1768
|
+
false,
|
|
1769
|
+
false,
|
|
1770
|
+
true,
|
|
1771
|
+
None,
|
|
1772
|
+
&transport,
|
|
1773
|
+
);
|
|
1774
|
+
|
|
1775
|
+
let text = format!("{result:?}");
|
|
1776
|
+
assert!(
|
|
1777
|
+
text.contains("not found"),
|
|
1778
|
+
"start-agent must refuse runtime-missing agents before side effects; got {text}"
|
|
1779
|
+
);
|
|
1780
|
+
assert!(
|
|
1781
|
+
transport.spawn_records().is_empty(),
|
|
1782
|
+
"runtime-missing start-agent must not spawn a pane"
|
|
1783
|
+
);
|
|
1784
|
+
assert!(
|
|
1785
|
+
!team
|
|
1786
|
+
.join(".team")
|
|
1787
|
+
.join("runtime")
|
|
1788
|
+
.join("mcp")
|
|
1789
|
+
.join("reviewer.json")
|
|
1790
|
+
.exists(),
|
|
1791
|
+
"runtime-missing start-agent must not write worker MCP config"
|
|
1792
|
+
);
|
|
1793
|
+
let events = crate::event_log::EventLog::new(&team)
|
|
1794
|
+
.tail(20)
|
|
1795
|
+
.unwrap_or_default();
|
|
1796
|
+
assert!(
|
|
1797
|
+
!events.iter().any(|event| {
|
|
1798
|
+
event.get("event").and_then(|v| v.as_str()) == Some("start_agent.agent_start")
|
|
1799
|
+
}),
|
|
1800
|
+
"runtime-missing start-agent must not emit start_agent.agent_start; events={events:?}"
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
#[test]
|
|
1805
|
+
fn restart_allow_fresh_copilot_persists_expected_session_id() {
|
|
1806
|
+
let ws = temp_ws().join("restartcopilot");
|
|
1807
|
+
std::fs::create_dir_all(ws.join("agents")).unwrap();
|
|
1808
|
+
std::fs::write(
|
|
1809
|
+
ws.join("TEAM.md"),
|
|
1810
|
+
"---\nname: restartcopilot\nobjective: Restart copilot probe.\nprovider: copilot\n---\n\nteam.\n",
|
|
1811
|
+
)
|
|
1812
|
+
.unwrap();
|
|
1813
|
+
std::fs::write(
|
|
1814
|
+
ws.join("agents").join("alpha.md"),
|
|
1815
|
+
"---\nname: alpha\nrole: Alpha Worker\nprovider: copilot\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nAlpha.\n",
|
|
1816
|
+
)
|
|
1817
|
+
.unwrap();
|
|
1818
|
+
let spec = crate::compiler::compile_team(&ws).expect("compile copilot team");
|
|
1819
|
+
std::fs::write(ws.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
|
|
1820
|
+
crate::state::persist::save_runtime_state(
|
|
1821
|
+
&ws,
|
|
1822
|
+
&json!({
|
|
1823
|
+
"session_name": "team-restartcopilot",
|
|
1824
|
+
"agents": {
|
|
1825
|
+
"alpha": {
|
|
1826
|
+
"status": "running",
|
|
1827
|
+
"provider": "copilot",
|
|
1828
|
+
"window": "alpha",
|
|
1829
|
+
"first_send_at": "2026-05-27T10:00:00+00:00",
|
|
1830
|
+
"session_id": null,
|
|
1831
|
+
"rollout_path": "/tmp/stale-copilot-rollout.jsonl",
|
|
1832
|
+
"captured_at": "2026-05-27T10:00:00+00:00",
|
|
1833
|
+
"captured_via": "stale",
|
|
1834
|
+
"attribution_confidence": "stale"
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
}),
|
|
1838
|
+
)
|
|
1839
|
+
.unwrap();
|
|
1840
|
+
seed_healthy_coordinator(&ws);
|
|
1841
|
+
let transport = OfflineTransport::new();
|
|
1842
|
+
|
|
1843
|
+
let result = restart_with_transport(&ws, true, None, &transport);
|
|
1844
|
+
|
|
1845
|
+
assert!(
|
|
1846
|
+
matches!(result, Ok(RestartReport::Restarted { .. })),
|
|
1847
|
+
"allow-fresh restart should fresh-spawn the copilot worker; got {result:?}"
|
|
1848
|
+
);
|
|
1849
|
+
let recorded = transport.spawn_records();
|
|
1850
|
+
assert_eq!(
|
|
1851
|
+
recorded.len(),
|
|
1852
|
+
1,
|
|
1853
|
+
"expected one copilot spawn; got {recorded:?}"
|
|
1854
|
+
);
|
|
1855
|
+
let session_arg = recorded[0]
|
|
1856
|
+
.1
|
|
1857
|
+
.windows(2)
|
|
1858
|
+
.find_map(|pair| (pair[0] == "--session-id").then(|| pair[1].clone()))
|
|
1859
|
+
.expect("copilot fresh spawn argv must include --session-id");
|
|
1860
|
+
let state = crate::state::persist::load_runtime_state(&ws).expect("load state");
|
|
1861
|
+
let agent = state.pointer("/agents/alpha").expect("alpha state");
|
|
1862
|
+
assert_eq!(
|
|
1863
|
+
agent.get("session_id").and_then(serde_json::Value::as_str),
|
|
1864
|
+
Some(session_arg.as_str()),
|
|
1865
|
+
"fresh restart must promote expected_session_id to persisted session_id"
|
|
1866
|
+
);
|
|
1867
|
+
assert_eq!(
|
|
1868
|
+
agent
|
|
1869
|
+
.get("_pending_session_id")
|
|
1870
|
+
.and_then(serde_json::Value::as_str),
|
|
1871
|
+
Some(session_arg.as_str()),
|
|
1872
|
+
"fresh restart must retain pending session audit state"
|
|
1873
|
+
);
|
|
1874
|
+
for stale in [
|
|
1875
|
+
"rollout_path",
|
|
1876
|
+
"captured_at",
|
|
1877
|
+
"captured_via",
|
|
1878
|
+
"attribution_confidence",
|
|
1879
|
+
] {
|
|
1880
|
+
assert_eq!(
|
|
1881
|
+
agent.get(stale),
|
|
1882
|
+
Some(&serde_json::Value::Null),
|
|
1883
|
+
"fresh restart must clear stale capture field {stale}: {agent}"
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
let plan = crate::lifecycle::restart::classify_restart_plan(&state, false)
|
|
1887
|
+
.expect("classify second restart");
|
|
1888
|
+
assert!(
|
|
1889
|
+
plan.unresumable
|
|
1890
|
+
.iter()
|
|
1891
|
+
.all(|worker| worker.reason != "no_persisted_session_id"),
|
|
1892
|
+
"second restart must not loop as no_persisted_session_id; unresumable={:?}",
|
|
1893
|
+
plan.unresumable
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
#[test]
|
|
1898
|
+
fn add_agent_adaptive_splits_last_non_full_layout_window() {
|
|
1899
|
+
let roles = [("w1.md", role_doc("w1")), ("w2.md", role_doc("w2"))];
|
|
1900
|
+
let role_refs = roles
|
|
1901
|
+
.iter()
|
|
1902
|
+
.map(|(file, doc)| (*file, doc.as_str()))
|
|
1903
|
+
.collect::<Vec<_>>();
|
|
1904
|
+
let team = quick_start_team_dir_with_roles(&role_refs);
|
|
1905
|
+
let session = "team-layout-add";
|
|
1906
|
+
crate::state::persist::save_runtime_state(
|
|
1907
|
+
&team,
|
|
1908
|
+
&json!({
|
|
1909
|
+
"session_name": session,
|
|
1910
|
+
"display_backend": "adaptive",
|
|
1911
|
+
"active_team_key": "teamdir",
|
|
1912
|
+
"agents": {
|
|
1913
|
+
"w1": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 0, "pane_id": "%1"},
|
|
1914
|
+
"w2": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 1, "pane_id": "%2"}
|
|
1915
|
+
}
|
|
1916
|
+
}),
|
|
1917
|
+
)
|
|
1918
|
+
.unwrap();
|
|
1919
|
+
seed_healthy_coordinator(&team);
|
|
1920
|
+
let role_file = team.join("w3-role.md");
|
|
1921
|
+
std::fs::write(&role_file, role_doc("w3")).unwrap();
|
|
1922
|
+
let transport = OfflineTransport::new()
|
|
1923
|
+
.with_session_present(true)
|
|
1924
|
+
.with_targets(vec![
|
|
1925
|
+
layout_pane(session, "team-w1", "%1", 0),
|
|
1926
|
+
layout_pane(session, "team-w1", "%2", 1),
|
|
1927
|
+
])
|
|
1928
|
+
.with_pane_presence("%1", true)
|
|
1929
|
+
.with_pane_presence("%2", true);
|
|
1930
|
+
|
|
1931
|
+
add_agent_with_transport(
|
|
1932
|
+
&team,
|
|
1933
|
+
&AgentId::new("w3"),
|
|
1934
|
+
&role_file,
|
|
1935
|
+
true,
|
|
1936
|
+
None,
|
|
1937
|
+
&transport,
|
|
1938
|
+
)
|
|
1939
|
+
.expect("adaptive add-agent should start");
|
|
1940
|
+
|
|
1941
|
+
assert_eq!(
|
|
1942
|
+
transport.spawn_window_records().last(),
|
|
1943
|
+
Some(&(String::from("spawn_split"), String::from("team-w1"))),
|
|
1944
|
+
"last non-full layout window should be split"
|
|
1945
|
+
);
|
|
1946
|
+
let (_raw, state) = raw_runtime_state(&team);
|
|
1947
|
+
assert_eq!(state.pointer("/agents/w3/layout_window"), Some(&json!("team-w1")));
|
|
1948
|
+
assert_eq!(state.pointer("/agents/w3/pane_index"), Some(&json!(2)));
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
#[test]
|
|
1952
|
+
fn add_agent_adaptive_creates_suffix_window_when_last_layout_full_and_name_collides() {
|
|
1953
|
+
let roles = [
|
|
1954
|
+
("w1.md", role_doc("w1")),
|
|
1955
|
+
("w2.md", role_doc("w2")),
|
|
1956
|
+
("w3.md", role_doc("w3")),
|
|
1957
|
+
];
|
|
1958
|
+
let role_refs = roles
|
|
1959
|
+
.iter()
|
|
1960
|
+
.map(|(file, doc)| (*file, doc.as_str()))
|
|
1961
|
+
.collect::<Vec<_>>();
|
|
1962
|
+
let team = quick_start_team_dir_with_roles(&role_refs);
|
|
1963
|
+
let session = "team-layout-add-full";
|
|
1964
|
+
crate::state::persist::save_runtime_state(
|
|
1965
|
+
&team,
|
|
1966
|
+
&json!({
|
|
1967
|
+
"session_name": session,
|
|
1968
|
+
"display_backend": "adaptive",
|
|
1969
|
+
"active_team_key": "teamdir",
|
|
1970
|
+
"agents": {
|
|
1971
|
+
"w1": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 0, "pane_id": "%1"},
|
|
1972
|
+
"w2": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 1, "pane_id": "%2"},
|
|
1973
|
+
"w3": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 2, "pane_id": "%3"}
|
|
1974
|
+
}
|
|
1975
|
+
}),
|
|
1976
|
+
)
|
|
1977
|
+
.unwrap();
|
|
1978
|
+
seed_healthy_coordinator(&team);
|
|
1979
|
+
let role_file = team.join("w4-role.md");
|
|
1980
|
+
std::fs::write(&role_file, role_doc("w4")).unwrap();
|
|
1981
|
+
let transport = OfflineTransport::new()
|
|
1982
|
+
.with_session_present(true)
|
|
1983
|
+
.with_targets(vec![
|
|
1984
|
+
layout_pane(session, "team-w1", "%1", 0),
|
|
1985
|
+
layout_pane(session, "team-w1", "%2", 1),
|
|
1986
|
+
layout_pane(session, "team-w1", "%3", 2),
|
|
1987
|
+
layout_pane(session, "team-w2", "%9", 0),
|
|
1988
|
+
])
|
|
1989
|
+
.with_pane_presence("%1", true)
|
|
1990
|
+
.with_pane_presence("%2", true)
|
|
1991
|
+
.with_pane_presence("%3", true);
|
|
1992
|
+
|
|
1993
|
+
add_agent_with_transport(
|
|
1994
|
+
&team,
|
|
1995
|
+
&AgentId::new("w4"),
|
|
1996
|
+
&role_file,
|
|
1997
|
+
true,
|
|
1998
|
+
None,
|
|
1999
|
+
&transport,
|
|
2000
|
+
)
|
|
2001
|
+
.expect("adaptive add-agent should start");
|
|
2002
|
+
|
|
2003
|
+
assert_eq!(
|
|
2004
|
+
transport.spawn_window_records().last(),
|
|
2005
|
+
Some(&(String::from("spawn_into"), String::from("team-w2-2"))),
|
|
2006
|
+
"full last layout window should create next window with collision suffix"
|
|
2007
|
+
);
|
|
2008
|
+
let (_raw, state) = raw_runtime_state(&team);
|
|
2009
|
+
assert_eq!(state.pointer("/agents/w4/layout_window"), Some(&json!("team-w2-2")));
|
|
2010
|
+
assert_eq!(state.pointer("/agents/w4/pane_index"), Some(&json!(0)));
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
#[test]
|
|
2014
|
+
fn stop_agent_adaptive_kills_target_pane_not_shared_layout_window() {
|
|
2015
|
+
let roles = [("w1.md", role_doc("w1")), ("w2.md", role_doc("w2"))];
|
|
2016
|
+
let role_refs = roles
|
|
2017
|
+
.iter()
|
|
2018
|
+
.map(|(file, doc)| (*file, doc.as_str()))
|
|
2019
|
+
.collect::<Vec<_>>();
|
|
2020
|
+
let team = quick_start_team_dir_with_roles(&role_refs);
|
|
2021
|
+
let spec = crate::compiler::compile_team(&team).unwrap();
|
|
2022
|
+
std::fs::write(team.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
|
|
2023
|
+
let session = "team-layout-stop";
|
|
2024
|
+
crate::state::persist::save_runtime_state(
|
|
2025
|
+
&team,
|
|
2026
|
+
&json!({
|
|
2027
|
+
"session_name": session,
|
|
2028
|
+
"display_backend": "adaptive",
|
|
2029
|
+
"agents": {
|
|
2030
|
+
"w1": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 0, "pane_id": "%1"},
|
|
2031
|
+
"w2": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 1, "pane_id": "%2"}
|
|
2032
|
+
}
|
|
2033
|
+
}),
|
|
2034
|
+
)
|
|
2035
|
+
.unwrap();
|
|
2036
|
+
let transport = OfflineTransport::new()
|
|
2037
|
+
.with_targets(vec![
|
|
2038
|
+
layout_pane(session, "team-w1", "%1", 0),
|
|
2039
|
+
layout_pane(session, "team-w1", "%2", 1),
|
|
2040
|
+
])
|
|
2041
|
+
.with_pane_presence("%2", true);
|
|
2042
|
+
|
|
2043
|
+
let report =
|
|
2044
|
+
stop_agent_with_transport(&team, &AgentId::new("w2"), None, &transport).unwrap();
|
|
2045
|
+
|
|
2046
|
+
assert!(report.stopped);
|
|
2047
|
+
assert_eq!(report.target, "%2");
|
|
2048
|
+
assert!(
|
|
2049
|
+
transport.calls().iter().any(|call| *call == "kill_pane"),
|
|
2050
|
+
"adaptive stop must kill only the target pane"
|
|
2051
|
+
);
|
|
2052
|
+
assert!(
|
|
2053
|
+
!transport.calls().iter().any(|call| *call == "kill_window"),
|
|
2054
|
+
"adaptive stop must not kill the shared layout window"
|
|
2055
|
+
);
|
|
2056
|
+
let (_raw, state) = raw_runtime_state(&team);
|
|
2057
|
+
assert_eq!(state.pointer("/agents/w1/pane_id"), Some(&json!("%1")));
|
|
2058
|
+
assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
#[test]
|
|
2062
|
+
fn start_agent_adaptive_restarts_missing_pane_in_existing_layout_window() {
|
|
2063
|
+
let ws = temp_ws();
|
|
2064
|
+
crate::state::persist::save_runtime_state(
|
|
2065
|
+
&ws,
|
|
2066
|
+
&json!({
|
|
2067
|
+
"session_name": "team-layout-restart",
|
|
2068
|
+
"display_backend": "adaptive",
|
|
2069
|
+
"agents": {
|
|
2070
|
+
"w1": {"status": "running", "provider": "codex", "role": "w1", "model": "gpt-5.5", "auth_mode": "subscription", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 0, "pane_id": "%1"},
|
|
2071
|
+
"w2": {"status": "running", "provider": "codex", "role": "w2", "model": "gpt-5.5", "auth_mode": "subscription", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 1, "pane_id": "%2"}
|
|
2072
|
+
}
|
|
2073
|
+
}),
|
|
2074
|
+
)
|
|
2075
|
+
.unwrap();
|
|
2076
|
+
seed_healthy_coordinator(&ws);
|
|
2077
|
+
let transport = OfflineTransport::new()
|
|
2078
|
+
.with_session_present(true)
|
|
2079
|
+
.with_targets(vec![layout_pane("team-layout-restart", "team-w1", "%2", 1)])
|
|
2080
|
+
.with_pane_presence("%1", false)
|
|
2081
|
+
.with_pane_presence("%2", true);
|
|
2082
|
+
|
|
2083
|
+
let outcome = start_agent_with_transport(
|
|
2084
|
+
&ws,
|
|
2085
|
+
&AgentId::new("w1"),
|
|
2086
|
+
false,
|
|
2087
|
+
true,
|
|
2088
|
+
true,
|
|
2089
|
+
None,
|
|
2090
|
+
&transport,
|
|
2091
|
+
)
|
|
2092
|
+
.expect("adaptive restart should spawn");
|
|
2093
|
+
|
|
2094
|
+
assert!(matches!(outcome, StartAgentOutcome::Running { .. }));
|
|
2095
|
+
assert_eq!(
|
|
2096
|
+
transport.spawn_window_records().last(),
|
|
2097
|
+
Some(&(String::from("spawn_split"), String::from("team-w1"))),
|
|
2098
|
+
"missing worker pane should be rebuilt in its existing layout window"
|
|
2099
|
+
);
|
|
2100
|
+
let (_raw, state) = raw_runtime_state(&ws);
|
|
2101
|
+
assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
|
|
2102
|
+
assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("team-w1")));
|
|
2103
|
+
assert_eq!(state.pointer("/agents/w2/pane_id"), Some(&json!("%2")));
|
|
2104
|
+
}
|
|
2105
|
+
|
|
1289
2106
|
// ═════════════════════════════════════════════════════════════════════════
|
|
1290
2107
|
// WAVE 2 · LANE A — agent-lifecycle byte-parity contracts. stop_agent / reset_agent / remove_agent /
|
|
1291
2108
|
// fork_agent are stubs (OwnerRefused / RequirementUnmet / "session_id missing"). These RED contracts
|
|
@@ -1306,7 +2123,9 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
|
|
|
1306
2123
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
1307
2124
|
let transport = OfflineTransport::new();
|
|
1308
2125
|
let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
1309
|
-
let workspace = team
|
|
2126
|
+
let workspace = team
|
|
2127
|
+
.parent()
|
|
2128
|
+
.expect("team_workspace(<base>/teamdir) = <base>");
|
|
1310
2129
|
let state = crate::state::persist::load_runtime_state(workspace)
|
|
1311
2130
|
.expect("runtime state.json must exist after quick_start");
|
|
1312
2131
|
// (b) the tasks KEY must always be present and a JSON array (golden launch/core.py:69 default []).
|
|
@@ -1318,10 +2137,14 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
|
|
|
1318
2137
|
state.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>())
|
|
1319
2138
|
),
|
|
1320
2139
|
};
|
|
1321
|
-
let tasks = tasks
|
|
2140
|
+
let tasks = tasks
|
|
2141
|
+
.as_array()
|
|
2142
|
+
.expect("`tasks` must be a JSON array (golden default [])");
|
|
1322
2143
|
// (a) spec.tasks must be carried into runtime state — the doc compiler's default task id.
|
|
1323
2144
|
assert!(
|
|
1324
|
-
tasks
|
|
2145
|
+
tasks
|
|
2146
|
+
.iter()
|
|
2147
|
+
.any(|t| t.get("id").and_then(|v| v.as_str()) == Some("task_initial")),
|
|
1325
2148
|
"state.tasks must carry the compiled spec's task (id=task_initial); got {tasks:?}"
|
|
1326
2149
|
);
|
|
1327
2150
|
}
|
|
@@ -1355,11 +2178,18 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
1355
2178
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
1356
2179
|
let transport = OfflineTransport::new();
|
|
1357
2180
|
let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
1358
|
-
let workspace = team
|
|
2181
|
+
let workspace = team
|
|
2182
|
+
.parent()
|
|
2183
|
+
.expect("team_workspace(<base>/teamdir) = <base>");
|
|
1359
2184
|
// E5: spec_path in state now points to .team/runtime/<team_key>/, team_dir stays the user role dir.
|
|
1360
2185
|
let spec_path = quick_start_runtime_spec_path(&team);
|
|
1361
2186
|
let (raw, state) = raw_runtime_state(workspace);
|
|
1362
|
-
let keys = state
|
|
2187
|
+
let keys = state
|
|
2188
|
+
.as_object()
|
|
2189
|
+
.expect("state root object")
|
|
2190
|
+
.keys()
|
|
2191
|
+
.cloned()
|
|
2192
|
+
.collect::<Vec<_>>();
|
|
1363
2193
|
assert_eq!(
|
|
1364
2194
|
keys,
|
|
1365
2195
|
vec![
|
|
@@ -1382,7 +2212,10 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
1382
2212
|
truth (no shadow copy on the root that callers could read across teams); \
|
|
1383
2213
|
raw={raw}"
|
|
1384
2214
|
);
|
|
1385
|
-
assert_eq!(
|
|
2215
|
+
assert_eq!(
|
|
2216
|
+
state["spec_path"],
|
|
2217
|
+
json!(spec_path.canonicalize().unwrap().to_string_lossy())
|
|
2218
|
+
);
|
|
1386
2219
|
assert_eq!(state["workspace"], json!(workspace.to_string_lossy()));
|
|
1387
2220
|
assert_eq!(state["team_dir"], json!(team.to_string_lossy()));
|
|
1388
2221
|
assert_eq!(state["session_name"], json!("team-quickteam"));
|
|
@@ -1392,21 +2225,25 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
1392
2225
|
"leader must be copied from compiled spec.leader"
|
|
1393
2226
|
);
|
|
1394
2227
|
assert!(
|
|
1395
|
-
state["agents"]
|
|
2228
|
+
state["agents"]
|
|
2229
|
+
.as_object()
|
|
2230
|
+
.is_some_and(|agents| agents.contains_key("implementer")),
|
|
1396
2231
|
"existing agents value must remain seeded from compiled spec; got {:?}",
|
|
1397
2232
|
state["agents"]
|
|
1398
2233
|
);
|
|
1399
2234
|
assert!(
|
|
1400
2235
|
state["tasks"].as_array().is_some_and(|tasks| {
|
|
1401
|
-
tasks
|
|
2236
|
+
tasks
|
|
2237
|
+
.iter()
|
|
2238
|
+
.any(|task| task.get("id").and_then(|id| id.as_str()) == Some("task_initial"))
|
|
1402
2239
|
}),
|
|
1403
2240
|
"existing tasks value must remain seeded from compiled spec; got {:?}",
|
|
1404
2241
|
state["tasks"]
|
|
1405
2242
|
);
|
|
1406
2243
|
assert_eq!(
|
|
1407
2244
|
state["display_backend"],
|
|
1408
|
-
json!("
|
|
1409
|
-
"
|
|
2245
|
+
json!("adaptive"),
|
|
2246
|
+
"adaptive layout directive: resolve_display_backend(None, source='launch') defaults to adaptive"
|
|
1410
2247
|
);
|
|
1411
2248
|
}
|
|
1412
2249
|
|
|
@@ -1418,17 +2255,23 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
1418
2255
|
#[serial(env)]
|
|
1419
2256
|
fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
1420
2257
|
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);
|
|
2258
|
+
let _clock_guard = EnvVarGuard::set("TEAM_AGENT_TEST_FIXED_SPAWNED_AT", FIXED_SPAWNED_AT);
|
|
1423
2259
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
1424
2260
|
let transport = OfflineTransport::new();
|
|
1425
2261
|
let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
1426
|
-
let workspace = team
|
|
2262
|
+
let workspace = team
|
|
2263
|
+
.parent()
|
|
2264
|
+
.expect("team_workspace(<base>/teamdir) = <base>");
|
|
1427
2265
|
let (raw, state) = raw_runtime_state(workspace);
|
|
1428
2266
|
let agent = state
|
|
1429
2267
|
.pointer("/agents/implementer")
|
|
1430
2268
|
.unwrap_or_else(|| panic!("implementer agent state missing; raw={raw}"));
|
|
1431
|
-
let keys = agent
|
|
2269
|
+
let keys = agent
|
|
2270
|
+
.as_object()
|
|
2271
|
+
.expect("agent state object")
|
|
2272
|
+
.keys()
|
|
2273
|
+
.cloned()
|
|
2274
|
+
.collect::<Vec<_>>();
|
|
1432
2275
|
assert_eq!(
|
|
1433
2276
|
keys,
|
|
1434
2277
|
vec![
|
|
@@ -1447,11 +2290,15 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
|
1447
2290
|
"captured_at",
|
|
1448
2291
|
"captured_via",
|
|
1449
2292
|
"attribution_confidence",
|
|
2293
|
+
"layout_window",
|
|
2294
|
+
"layout_index",
|
|
2295
|
+
"pane_index",
|
|
2296
|
+
"display",
|
|
1450
2297
|
"spawn_cwd",
|
|
1451
2298
|
"spawned_at",
|
|
1452
2299
|
"pane_id",
|
|
1453
2300
|
],
|
|
1454
|
-
"running agent state key order must
|
|
2301
|
+
"running agent state key order must include adaptive layout fields; raw={raw}"
|
|
1455
2302
|
);
|
|
1456
2303
|
assert_eq!(agent["status"], json!("running"));
|
|
1457
2304
|
assert_eq!(agent["provider"], json!("codex"));
|
|
@@ -1459,10 +2306,12 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
|
1459
2306
|
assert_eq!(agent["model"], json!("gpt-5.5"));
|
|
1460
2307
|
assert_eq!(agent["auth_mode"], json!("subscription"));
|
|
1461
2308
|
assert!(agent["profile"].is_null());
|
|
1462
|
-
assert_eq!(agent["window"], json!("
|
|
2309
|
+
assert_eq!(agent["window"], json!("team-w1"));
|
|
1463
2310
|
assert_eq!(
|
|
1464
2311
|
agent["mcp_config"],
|
|
1465
|
-
json!(workspace
|
|
2312
|
+
json!(workspace
|
|
2313
|
+
.join(".team/runtime/mcp/implementer.json")
|
|
2314
|
+
.to_string_lossy())
|
|
1466
2315
|
);
|
|
1467
2316
|
assert_eq!(
|
|
1468
2317
|
agent["permissions"],
|
|
@@ -1479,6 +2328,12 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
|
1479
2328
|
assert!(agent["captured_at"].is_null());
|
|
1480
2329
|
assert!(agent["captured_via"].is_null());
|
|
1481
2330
|
assert!(agent["attribution_confidence"].is_null());
|
|
2331
|
+
assert_eq!(agent["layout_window"], json!("team-w1"));
|
|
2332
|
+
assert_eq!(agent["layout_index"], json!(0));
|
|
2333
|
+
assert_eq!(agent["pane_index"], json!(0));
|
|
2334
|
+
assert_eq!(agent.pointer("/display/backend"), Some(&json!("adaptive")));
|
|
2335
|
+
assert_eq!(agent.pointer("/display/window"), Some(&json!("team-w1")));
|
|
2336
|
+
assert_eq!(agent.pointer("/display/pane_id"), Some(&json!("%0")));
|
|
1482
2337
|
// D5 (#264) / Python launch/core.py:253 — fresh launch persists spawn_cwd=workspace.
|
|
1483
2338
|
assert_eq!(agent["spawn_cwd"], json!(workspace.to_string_lossy()));
|
|
1484
2339
|
assert_eq!(agent["spawned_at"], json!(FIXED_SPAWNED_AT));
|
|
@@ -1495,7 +2350,9 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
|
1495
2350
|
fn quick_start_paused_agent_state_is_paused_provider_only_and_not_spawned() {
|
|
1496
2351
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
1497
2352
|
let spec_path = compiled_spec_path_with_paused_agent(&team);
|
|
1498
|
-
let workspace = team
|
|
2353
|
+
let workspace = team
|
|
2354
|
+
.parent()
|
|
2355
|
+
.expect("team_workspace(<base>/teamdir) = <base>");
|
|
1499
2356
|
crate::state::persist::save_runtime_state(
|
|
1500
2357
|
workspace,
|
|
1501
2358
|
&json!({
|
|
@@ -1519,7 +2376,12 @@ fn quick_start_paused_agent_state_is_paused_provider_only_and_not_spawned() {
|
|
|
1519
2376
|
let agent = state
|
|
1520
2377
|
.pointer("/agents/implementer")
|
|
1521
2378
|
.unwrap_or_else(|| panic!("implementer agent state missing; raw={raw}"));
|
|
1522
|
-
let keys = agent
|
|
2379
|
+
let keys = agent
|
|
2380
|
+
.as_object()
|
|
2381
|
+
.expect("agent state object")
|
|
2382
|
+
.keys()
|
|
2383
|
+
.cloned()
|
|
2384
|
+
.collect::<Vec<_>>();
|
|
1523
2385
|
assert_eq!(
|
|
1524
2386
|
keys,
|
|
1525
2387
|
vec!["status", "provider"],
|