@team-agent/installer 0.2.11 → 0.3.0
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 +744 -0
- package/Cargo.toml +34 -0
- package/crates/team-agent/Cargo.toml +33 -0
- package/crates/team-agent/src/cli/adapters.rs +1343 -0
- package/crates/team-agent/src/cli/diagnose.rs +554 -0
- package/crates/team-agent/src/cli/emit.rs +1077 -0
- package/crates/team-agent/src/cli/helpers.rs +88 -0
- package/crates/team-agent/src/cli/leader.rs +216 -0
- package/crates/team-agent/src/cli/mod.rs +1141 -0
- package/crates/team-agent/src/cli/profile.rs +306 -0
- package/crates/team-agent/src/cli/send.rs +215 -0
- package/crates/team-agent/src/cli/status.rs +179 -0
- package/crates/team-agent/src/cli/status_port.rs +502 -0
- package/crates/team-agent/src/cli/tests/base.rs +616 -0
- package/crates/team-agent/src/cli/tests/compile.rs +96 -0
- package/crates/team-agent/src/cli/tests/divergence.rs +509 -0
- package/crates/team-agent/src/cli/tests/lane_c.rs +333 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +395 -0
- package/crates/team-agent/src/cli/tests/main_preserved.rs +675 -0
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +390 -0
- package/crates/team-agent/src/cli/tests/mod.rs +97 -0
- package/crates/team-agent/src/cli/tests/peer_allow.rs +137 -0
- package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +302 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +305 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +385 -0
- package/crates/team-agent/src/cli/tests/verb_profile.rs +182 -0
- package/crates/team-agent/src/cli/tests/verb_settle.rs +236 -0
- package/crates/team-agent/src/cli/tests/verb_validate.rs +184 -0
- package/crates/team-agent/src/cli/types.rs +605 -0
- package/crates/team-agent/src/compiler/tests.rs +701 -0
- package/crates/team-agent/src/compiler.rs +489 -0
- package/crates/team-agent/src/coordinator/backoff.rs +153 -0
- package/crates/team-agent/src/coordinator/health.rs +436 -0
- package/crates/team-agent/src/coordinator/mod.rs +80 -0
- package/crates/team-agent/src/coordinator/orphan.rs +179 -0
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +255 -0
- package/crates/team-agent/src/coordinator/tests/basics.rs +262 -0
- package/crates/team-agent/src/coordinator/tests/daemon.rs +323 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +263 -0
- package/crates/team-agent/src/coordinator/tests/main_preserved.rs +136 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +310 -0
- package/crates/team-agent/src/coordinator/tests/spine.rs +261 -0
- package/crates/team-agent/src/coordinator/tests/takeover.rs +227 -0
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +256 -0
- package/crates/team-agent/src/coordinator/tests/watch.rs +167 -0
- package/crates/team-agent/src/coordinator/tick.rs +2032 -0
- package/crates/team-agent/src/coordinator/types.rs +584 -0
- package/crates/team-agent/src/db/migration.rs +716 -0
- package/crates/team-agent/src/db/mod.rs +23 -0
- package/crates/team-agent/src/db/schema.rs +378 -0
- package/crates/team-agent/src/event_log.rs +375 -0
- package/crates/team-agent/src/fake_worker.rs +253 -0
- package/crates/team-agent/src/leader/helpers.rs +190 -0
- package/crates/team-agent/src/leader/inject.rs +33 -0
- package/crates/team-agent/src/leader/lease.rs +1063 -0
- package/crates/team-agent/src/leader/mod.rs +99 -0
- package/crates/team-agent/src/leader/owner_bind.rs +292 -0
- package/crates/team-agent/src/leader/rediscover/tests.rs +525 -0
- package/crates/team-agent/src/leader/rediscover.rs +1099 -0
- package/crates/team-agent/src/leader/start.rs +273 -0
- package/crates/team-agent/src/leader/takeover.rs +235 -0
- package/crates/team-agent/src/leader/tests/basics.rs +183 -0
- package/crates/team-agent/src/leader/tests/byte_findings.rs +234 -0
- package/crates/team-agent/src/leader/tests/identity.rs +206 -0
- package/crates/team-agent/src/leader/tests/idle.rs +271 -0
- package/crates/team-agent/src/leader/tests/lease_api.rs +225 -0
- package/crates/team-agent/src/leader/tests/lease_claim.rs +253 -0
- package/crates/team-agent/src/leader/tests/mod.rs +125 -0
- package/crates/team-agent/src/leader/tests/rediscover.rs +351 -0
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +204 -0
- package/crates/team-agent/src/leader/types.rs +487 -0
- package/crates/team-agent/src/lib.rs +85 -0
- package/crates/team-agent/src/lifecycle/display.rs +228 -0
- package/crates/team-agent/src/lifecycle/helpers.rs +112 -0
- package/crates/team-agent/src/lifecycle/launch/plan.rs +227 -0
- package/crates/team-agent/src/lifecycle/launch.rs +1833 -0
- package/crates/team-agent/src/lifecycle/mod.rs +62 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +533 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +517 -0
- package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +41 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +268 -0
- package/crates/team-agent/src/lifecycle/restart/remove.rs +780 -0
- package/crates/team-agent/src/lifecycle/restart/selection.rs +208 -0
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +242 -0
- package/crates/team-agent/src/lifecycle/restart.rs +76 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +455 -0
- package/crates/team-agent/src/lifecycle/tests/core.rs +989 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +583 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +933 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +265 -0
- package/crates/team-agent/src/lifecycle/tests.rs +27 -0
- package/crates/team-agent/src/lifecycle/types.rs +685 -0
- package/crates/team-agent/src/main.rs +41 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +228 -0
- package/crates/team-agent/src/mcp_server/mod.rs +183 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +312 -0
- package/crates/team-agent/src/mcp_server/tests/golden.rs +283 -0
- package/crates/team-agent/src/mcp_server/tests/normalize.rs +244 -0
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +189 -0
- package/crates/team-agent/src/mcp_server/tests/send.rs +222 -0
- package/crates/team-agent/src/mcp_server/tests/tools.rs +158 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +159 -0
- package/crates/team-agent/src/mcp_server/tests.rs +38 -0
- package/crates/team-agent/src/mcp_server/tools.rs +603 -0
- package/crates/team-agent/src/mcp_server/types.rs +421 -0
- package/crates/team-agent/src/mcp_server/wire.rs +388 -0
- package/crates/team-agent/src/message_store.rs +767 -0
- package/crates/team-agent/src/messaging/activity.rs +433 -0
- package/crates/team-agent/src/messaging/delivery.rs +542 -0
- package/crates/team-agent/src/messaging/helpers.rs +209 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +340 -0
- package/crates/team-agent/src/messaging/mod.rs +147 -0
- package/crates/team-agent/src/messaging/peers.rs +32 -0
- package/crates/team-agent/src/messaging/results.rs +537 -0
- package/crates/team-agent/src/messaging/scheduler.rs +344 -0
- package/crates/team-agent/src/messaging/selftest.rs +100 -0
- package/crates/team-agent/src/messaging/send.rs +582 -0
- package/crates/team-agent/src/messaging/tests/basic.rs +357 -0
- package/crates/team-agent/src/messaging/tests/main_preserved.rs +122 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +293 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +1422 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +437 -0
- package/crates/team-agent/src/messaging/trust.rs +192 -0
- package/crates/team-agent/src/messaging/types.rs +355 -0
- package/crates/team-agent/src/messaging/watchers.rs +591 -0
- package/crates/team-agent/src/model/enums.rs +311 -0
- package/crates/team-agent/src/model/errors.rs +17 -0
- package/crates/team-agent/src/model/ids.rs +155 -0
- package/crates/team-agent/src/model/mod.rs +22 -0
- package/crates/team-agent/src/model/paths.rs +228 -0
- package/crates/team-agent/src/model/permissions.rs +567 -0
- package/crates/team-agent/src/model/routing.rs +340 -0
- package/crates/team-agent/src/model/spec.rs +680 -0
- package/crates/team-agent/src/model/task_graph.rs +380 -0
- package/crates/team-agent/src/model/testdata/fuzz.golden.yaml +43 -0
- package/crates/team-agent/src/model/testdata/fuzz.yaml +43 -0
- package/crates/team-agent/src/model/testdata/spec_invalid_a.yaml +207 -0
- package/crates/team-agent/src/model/testdata/team.spec.golden.yaml +206 -0
- package/crates/team-agent/src/model/testdata/team.spec.yaml +206 -0
- package/crates/team-agent/src/model/yaml/tests.rs +288 -0
- package/crates/team-agent/src/model/yaml.rs +800 -0
- package/crates/team-agent/src/packaging/install.rs +305 -0
- package/crates/team-agent/src/packaging/migrate.rs +30 -0
- package/crates/team-agent/src/packaging/mod.rs +82 -0
- package/crates/team-agent/src/packaging/repair.rs +24 -0
- package/crates/team-agent/src/packaging/tests.rs +829 -0
- package/crates/team-agent/src/packaging/types.rs +369 -0
- package/crates/team-agent/src/provider/adapter.rs +801 -0
- package/crates/team-agent/src/provider/approvals/mod.rs +2 -0
- package/crates/team-agent/src/provider/approvals/parsing.rs +452 -0
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +163 -0
- package/crates/team-agent/src/provider/classify.rs +456 -0
- package/crates/team-agent/src/provider/faults.rs +136 -0
- package/crates/team-agent/src/provider/helpers.rs +41 -0
- package/crates/team-agent/src/provider/mod.rs +53 -0
- package/crates/team-agent/src/provider/startup_prompt.rs +423 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +239 -0
- package/crates/team-agent/src/provider/tests/classify.rs +240 -0
- package/crates/team-agent/src/provider/tests/faults.rs +120 -0
- package/crates/team-agent/src/provider/tests/idle.rs +208 -0
- package/crates/team-agent/src/provider/tests/wire.rs +213 -0
- package/crates/team-agent/src/provider/tests.rs +31 -0
- package/crates/team-agent/src/provider/types.rs +424 -0
- package/crates/team-agent/src/state/identity.rs +656 -0
- package/crates/team-agent/src/state/mod.rs +58 -0
- package/crates/team-agent/src/state/owner_gate.rs +423 -0
- package/crates/team-agent/src/state/persist.rs +712 -0
- package/crates/team-agent/src/state/projection.rs +657 -0
- package/crates/team-agent/src/state/selector.rs +105 -0
- package/crates/team-agent/src/state/testdata/state-rich.canonical.json +133 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +586 -0
- package/crates/team-agent/src/tmux_backend.rs +758 -0
- package/crates/team-agent/src/transport/test_support.rs +252 -0
- package/crates/team-agent/src/transport/tests/behavior.rs +327 -0
- package/crates/team-agent/src/transport/tests/mod.rs +199 -0
- package/crates/team-agent/src/transport/tests/wire.rs +527 -0
- package/crates/team-agent/src/transport.rs +774 -0
- package/npm/install.mjs +90 -106
- package/package.json +15 -13
- package/crates/team-agent-core/Cargo.toml +0 -12
- package/crates/team-agent-core/src/lib.rs +0 -332
- package/crates/team-agent-core/src/main.rs +0 -152
- package/pyproject.toml +0 -18
- package/scripts/install.py +0 -88
- package/scripts/run_regression_tests.py +0 -83
- package/src/team_agent/__init__.py +0 -3
- package/src/team_agent/__main__.py +0 -5
- package/src/team_agent/_legacy_pane_discovery.py +0 -186
- package/src/team_agent/abnormal_track.py +0 -253
- package/src/team_agent/approvals/__init__.py +0 -65
- package/src/team_agent/approvals/constants.py +0 -6
- package/src/team_agent/approvals/parsing.py +0 -176
- package/src/team_agent/approvals/runtime_prompts.py +0 -171
- package/src/team_agent/approvals/status.py +0 -176
- package/src/team_agent/cli/__init__.py +0 -137
- package/src/team_agent/cli/commands.py +0 -481
- package/src/team_agent/cli/e2e.py +0 -202
- package/src/team_agent/cli/helpers.py +0 -226
- package/src/team_agent/cli/parser.py +0 -540
- package/src/team_agent/compiler.py +0 -334
- package/src/team_agent/coordinator/__init__.py +0 -53
- package/src/team_agent/coordinator/__main__.py +0 -119
- package/src/team_agent/coordinator/lifecycle.py +0 -411
- package/src/team_agent/coordinator/metadata.py +0 -61
- package/src/team_agent/coordinator/paths.py +0 -17
- package/src/team_agent/diagnose/__init__.py +0 -48
- package/src/team_agent/diagnose/checks.py +0 -101
- package/src/team_agent/diagnose/comms.py +0 -213
- package/src/team_agent/diagnose/health.py +0 -241
- package/src/team_agent/diagnose/orphan_cleanup.py +0 -364
- package/src/team_agent/diagnose/preflight.py +0 -194
- package/src/team_agent/diagnose/quick_start.py +0 -324
- package/src/team_agent/display/__init__.py +0 -92
- package/src/team_agent/display/adaptive.py +0 -511
- package/src/team_agent/display/backend.py +0 -46
- package/src/team_agent/display/close.py +0 -154
- package/src/team_agent/display/ghostty.py +0 -77
- package/src/team_agent/display/rebuild.py +0 -102
- package/src/team_agent/display/tiling.py +0 -156
- package/src/team_agent/display/worker_window.py +0 -114
- package/src/team_agent/display/workspace.py +0 -382
- package/src/team_agent/errors.py +0 -10
- package/src/team_agent/events.py +0 -84
- package/src/team_agent/fake_worker.py +0 -80
- package/src/team_agent/idle_predicate.py +0 -218
- package/src/team_agent/idle_takeover.py +0 -59
- package/src/team_agent/idle_takeover_wiring.py +0 -114
- package/src/team_agent/launch/__init__.py +0 -41
- package/src/team_agent/launch/bootstrap.py +0 -85
- package/src/team_agent/launch/config.py +0 -106
- package/src/team_agent/launch/core.py +0 -301
- package/src/team_agent/launch/requirements.py +0 -57
- package/src/team_agent/leader/__init__.py +0 -926
- package/src/team_agent/leader_binding.py +0 -183
- package/src/team_agent/lifecycle/__init__.py +0 -5
- package/src/team_agent/lifecycle/agents.py +0 -278
- package/src/team_agent/lifecycle/operations.py +0 -411
- package/src/team_agent/lifecycle/paste_buffer_hygiene.py +0 -39
- package/src/team_agent/lifecycle/start.py +0 -363
- package/src/team_agent/mcp_server/__init__.py +0 -42
- package/src/team_agent/mcp_server/__main__.py +0 -7
- package/src/team_agent/mcp_server/contracts.py +0 -148
- package/src/team_agent/mcp_server/normalize.py +0 -257
- package/src/team_agent/mcp_server/server.py +0 -150
- package/src/team_agent/mcp_server/tools.py +0 -352
- package/src/team_agent/message_store/__init__.py +0 -23
- package/src/team_agent/message_store/agent_health.py +0 -113
- package/src/team_agent/message_store/core.py +0 -497
- package/src/team_agent/message_store/leader_notification_log.py +0 -198
- package/src/team_agent/message_store/result_watchers.py +0 -251
- package/src/team_agent/message_store/schema.py +0 -308
- package/src/team_agent/message_store/schema_migration.py +0 -448
- package/src/team_agent/messaging/__init__.py +0 -1
- package/src/team_agent/messaging/activity_detector.py +0 -262
- package/src/team_agent/messaging/delivery.py +0 -504
- package/src/team_agent/messaging/deps.py +0 -247
- package/src/team_agent/messaging/idle_alerts.py +0 -423
- package/src/team_agent/messaging/internal_delivery.py +0 -46
- package/src/team_agent/messaging/leader.py +0 -497
- package/src/team_agent/messaging/leader_api_errors.py +0 -216
- package/src/team_agent/messaging/leader_panes.py +0 -673
- package/src/team_agent/messaging/owner_bypass.py +0 -29
- package/src/team_agent/messaging/result_delivery.py +0 -539
- package/src/team_agent/messaging/results.py +0 -447
- package/src/team_agent/messaging/scheduler.py +0 -450
- package/src/team_agent/messaging/send.py +0 -532
- package/src/team_agent/messaging/session_drift.py +0 -94
- package/src/team_agent/messaging/tmux_io.py +0 -506
- package/src/team_agent/messaging/tmux_prompt.py +0 -338
- package/src/team_agent/messaging/trust_auto_answer.py +0 -52
- package/src/team_agent/orchestrator/__init__.py +0 -376
- package/src/team_agent/orchestrator/plan.py +0 -122
- package/src/team_agent/orchestrator/state.py +0 -128
- package/src/team_agent/paths.py +0 -45
- package/src/team_agent/permissions.py +0 -123
- package/src/team_agent/profiles/__init__.py +0 -82
- package/src/team_agent/profiles/constants.py +0 -19
- package/src/team_agent/profiles/core.py +0 -407
- package/src/team_agent/profiles/helpers.py +0 -69
- package/src/team_agent/profiles/provider_env.py +0 -188
- package/src/team_agent/profiles/smoke.py +0 -201
- package/src/team_agent/provider_cli/__init__.py +0 -43
- package/src/team_agent/provider_cli/adapter.py +0 -172
- package/src/team_agent/provider_cli/base.py +0 -48
- package/src/team_agent/provider_cli/claude.py +0 -503
- package/src/team_agent/provider_cli/codex.py +0 -336
- package/src/team_agent/provider_cli/copilot.py +0 -8
- package/src/team_agent/provider_cli/fake.py +0 -39
- package/src/team_agent/provider_cli/gemini.py +0 -95
- package/src/team_agent/provider_cli/opencode.py +0 -8
- package/src/team_agent/provider_cli/prompt.py +0 -62
- package/src/team_agent/provider_cli/registry.py +0 -18
- package/src/team_agent/provider_cli/unsupported.py +0 -32
- package/src/team_agent/provider_state/README.md +0 -78
- package/src/team_agent/provider_state/__init__.py +0 -91
- package/src/team_agent/provider_state/claude.py +0 -86
- package/src/team_agent/provider_state/codex.py +0 -84
- package/src/team_agent/provider_state/common.py +0 -207
- package/src/team_agent/provider_state/registry.py +0 -118
- package/src/team_agent/providers.py +0 -163
- package/src/team_agent/quality_gates.py +0 -104
- package/src/team_agent/restart/__init__.py +0 -34
- package/src/team_agent/restart/orchestration.py +0 -554
- package/src/team_agent/restart/selection.py +0 -89
- package/src/team_agent/restart/snapshot.py +0 -70
- package/src/team_agent/routing.py +0 -84
- package/src/team_agent/runtime.py +0 -1243
- package/src/team_agent/rust_core.py +0 -327
- package/src/team_agent/sessions/__init__.py +0 -25
- package/src/team_agent/sessions/capture.py +0 -144
- package/src/team_agent/sessions/inventory.py +0 -44
- package/src/team_agent/sessions/resume.py +0 -135
- package/src/team_agent/simple_yaml.py +0 -236
- package/src/team_agent/spec.py +0 -370
- package/src/team_agent/state.py +0 -693
- package/src/team_agent/status/__init__.py +0 -63
- package/src/team_agent/status/approvals.py +0 -52
- package/src/team_agent/status/compact.py +0 -158
- package/src/team_agent/status/constants.py +0 -18
- package/src/team_agent/status/inbox.py +0 -58
- package/src/team_agent/status/peek.py +0 -117
- package/src/team_agent/status/queries.py +0 -199
- package/src/team_agent/task_graph.py +0 -80
- package/src/team_agent/terminal.py +0 -57
- package/src/team_agent/wake.py +0 -58
- package/src/team_agent/watch/__init__.py +0 -145
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
use crate::transport::test_support::OfflineTransport;
|
|
3
|
+
use serial_test::serial;
|
|
4
|
+
|
|
5
|
+
const QS_TEAM_MD: &str =
|
|
6
|
+
"---\nname: quickteam\nobjective: Quick start.\nprovider: codex\n---\n\nQuick-start team.\n";
|
|
7
|
+
pub(super) const QS_VALID_ROLE: &str = "---\nname: implementer\nrole: Implementation Engineer\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nImplement bounded tasks.\n";
|
|
8
|
+
const QS_ROLE_NO_PROVIDER: &str = "---\nname: implementer\nrole: Implementation Engineer\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nImplement bounded tasks.\n";
|
|
9
|
+
|
|
10
|
+
/// A REAL team dir `<base>/teamdir/{TEAM.md, agents/implementer.md}` — already in team-dir
|
|
11
|
+
/// layout, so quick_start's prepare_quick_start_team returns it as-is and writes the compiled
|
|
12
|
+
/// `team.spec.yaml` into it (diagnose/quick_start.py:prepare_quick_start_team, before launch).
|
|
13
|
+
pub(super) fn quick_start_team_dir(role_doc: &str) -> PathBuf {
|
|
14
|
+
let team = temp_ws().join("teamdir");
|
|
15
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
16
|
+
std::fs::write(team.join("TEAM.md"), QS_TEAM_MD).unwrap();
|
|
17
|
+
std::fs::write(team.join("agents").join("implementer.md"), role_doc).unwrap();
|
|
18
|
+
team
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// A no-owner running workspace: state.json carries session_name + agents but NO team_owner →
|
|
22
|
+
/// check_team_owner returns None = allowed (owner_gate.rs:48), so the owner-gated entry points can
|
|
23
|
+
/// proceed PAST the owner gate. Also inits the real team.db.
|
|
24
|
+
fn unowned_running_ws() -> PathBuf {
|
|
25
|
+
let ws = temp_ws();
|
|
26
|
+
crate::state::persist::save_runtime_state(
|
|
27
|
+
&ws,
|
|
28
|
+
&json!({
|
|
29
|
+
"session_name": "team-x",
|
|
30
|
+
"agents": { "w1": { "provider": "codex", "role": "Worker", "model": "gpt-5.5", "auth_mode": "subscription" } },
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
33
|
+
.unwrap();
|
|
34
|
+
let _ = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
35
|
+
ws
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// P0 — quick_start over a VALID team dir must COMPILE the real spec (the compiler runs before any
|
|
39
|
+
// launch/spawn) → team.spec.yaml is written carrying the agent set, and the result is NOT the
|
|
40
|
+
// hardcoded "no role docs found" PreflightBlocked. Golden: quick_start.py → _compile_team_dir_spec
|
|
41
|
+
// writes spec_path=team_dir/team.spec.yaml BEFORE launch.
|
|
42
|
+
#[test]
|
|
43
|
+
fn quick_start_compiles_real_spec_to_team_spec_yaml() {
|
|
44
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
45
|
+
let transport = OfflineTransport::new();
|
|
46
|
+
let result = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
47
|
+
|
|
48
|
+
// OBSERVABLE 1 (real compiler ran; spawn-independent): the compiled spec is written.
|
|
49
|
+
let spec_path = team.join("team.spec.yaml");
|
|
50
|
+
assert!(
|
|
51
|
+
spec_path.exists(),
|
|
52
|
+
"quick_start must compile the team dir and write team.spec.yaml (the real compiler runs \
|
|
53
|
+
before launch); the stub returns before compiling. result={result:?}"
|
|
54
|
+
);
|
|
55
|
+
let spec_text = std::fs::read_to_string(&spec_path).unwrap_or_default();
|
|
56
|
+
assert!(
|
|
57
|
+
spec_text.contains("implementer"),
|
|
58
|
+
"the compiled team.spec.yaml must carry the role-doc agent 'implementer'"
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// OBSERVABLE 2 (report, robust): a VALID dir must never be the hardcoded always-blocked stub.
|
|
62
|
+
if let Ok(QuickStartReport::PreflightBlocked { blockers, .. }) = &result {
|
|
63
|
+
assert!(
|
|
64
|
+
!blockers.iter().any(|b| b.contains("no role docs found")),
|
|
65
|
+
"a VALID team dir must never yield the hardcoded 'no role docs found' blocker"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// P0 — quick_start over an INVALID role doc (missing `provider`) must surface the REAL compile
|
|
71
|
+
// error, distinct from the stub's hardcoded "no role docs found". Golden: compile_team raises
|
|
72
|
+
// "missing front matter field provider" (compiler.py:_validate_role_doc), before preflight.
|
|
73
|
+
#[test]
|
|
74
|
+
fn quick_start_invalid_role_doc_surfaces_real_compile_error() {
|
|
75
|
+
let team = quick_start_team_dir(QS_ROLE_NO_PROVIDER);
|
|
76
|
+
let transport = OfflineTransport::new();
|
|
77
|
+
let result = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
78
|
+
|
|
79
|
+
let text = format!("{result:?}");
|
|
80
|
+
assert!(
|
|
81
|
+
text.contains("provider"),
|
|
82
|
+
"an invalid role doc (missing provider) must surface the real compile error mentioning \
|
|
83
|
+
'provider'; got {text}"
|
|
84
|
+
);
|
|
85
|
+
assert!(
|
|
86
|
+
!text.contains("no role docs found"),
|
|
87
|
+
"must NOT be the hardcoded stub blocker — real preflight/compile distinguishes the cause"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// P0 — launch (dry_run) over a real compiled spec must resolve the REAL route/permission plan
|
|
92
|
+
// (no spawn) — NOT the hardcoded RequirementUnmet stub. Golden: launch/core.py dry_run resolves
|
|
93
|
+
// routing + permissions without starting any process.
|
|
94
|
+
#[test]
|
|
95
|
+
fn launch_dry_run_resolves_real_plan_not_stub_error() {
|
|
96
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
97
|
+
let spec = crate::compiler::compile_team(&team).expect("compile the seeded valid team");
|
|
98
|
+
let spec_path = team.join("team.spec.yaml");
|
|
99
|
+
std::fs::write(&spec_path, crate::model::yaml::dumps(&spec)).unwrap();
|
|
100
|
+
|
|
101
|
+
let result = launch(&spec_path, true, true, true);
|
|
102
|
+
|
|
103
|
+
match result {
|
|
104
|
+
Err(LifecycleError::RequirementUnmet(msg)) if msg.contains("not available") => {
|
|
105
|
+
panic!("launch returned the hardcoded stub error; the real dry_run plan was never resolved");
|
|
106
|
+
}
|
|
107
|
+
Ok(report) => {
|
|
108
|
+
assert!(report.dry_run, "dry_run launch must report dry_run=true");
|
|
109
|
+
assert!(
|
|
110
|
+
!report.routes.is_empty() || !report.permissions.is_empty(),
|
|
111
|
+
"dry_run launch must resolve a real route/permission plan from the compiled spec"
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
// any OTHER Err (a real requirement/transport error) still proves the stub is gone.
|
|
115
|
+
Err(_) => {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// P1 — start_agent must PROCEED past the owner gate for a no-owner workspace (check_team_owner →
|
|
120
|
+
// None = allowed). The stub hard-returns OwnerRefused unconditionally. Golden: lifecycle/start.py
|
|
121
|
+
// runs under the owner gate, then the resume-or-fresh decision. (The resume/fresh PLAN + real
|
|
122
|
+
// spawn is the #[ignore] OS boundary — see quick_start_full_ready_real_spawn.)
|
|
123
|
+
#[test]
|
|
124
|
+
fn start_agent_proceeds_past_owner_gate_for_unowned_workspace() {
|
|
125
|
+
let ws = unowned_running_ws();
|
|
126
|
+
let transport = OfflineTransport::new();
|
|
127
|
+
let result = start_agent_with_transport(&ws, &AgentId::new("w1"), false, false, true, None, &transport);
|
|
128
|
+
assert!(
|
|
129
|
+
!matches!(result, Err(LifecycleError::OwnerRefused(_))),
|
|
130
|
+
"start_agent must reach the real chain for a no-owner workspace, not hard-refuse owner; got {result:?}"
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// P1 — restart (Route B) must PROCEED past the owner gate for a no-owner workspace. Stub →
|
|
135
|
+
// OwnerRefused. Golden: restart/orchestration.py runs the resume-selection under the owner gate.
|
|
136
|
+
#[test]
|
|
137
|
+
fn restart_proceeds_past_owner_gate_for_unowned_workspace() {
|
|
138
|
+
let ws = unowned_running_ws();
|
|
139
|
+
let transport = OfflineTransport::new();
|
|
140
|
+
let result = restart_with_transport(&ws, true, None, &transport);
|
|
141
|
+
assert!(
|
|
142
|
+
!matches!(result, Err(LifecycleError::OwnerRefused(_))),
|
|
143
|
+
"restart must reach the real Route-B chain for a no-owner workspace; got {result:?}"
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// P1 — add_agent must PROCEED past the owner gate (no-owner ws) and reach the real recompile chain.
|
|
148
|
+
// Stub → OwnerRefused. Golden: lifecycle/operations.py:add_agent recompiles the role doc into the
|
|
149
|
+
// spec under the owner gate.
|
|
150
|
+
#[test]
|
|
151
|
+
fn add_agent_proceeds_past_owner_gate_and_reaches_recompile() {
|
|
152
|
+
let ws = unowned_running_ws();
|
|
153
|
+
let role = ws.join("w2-role.md");
|
|
154
|
+
std::fs::write(
|
|
155
|
+
&role,
|
|
156
|
+
"---\nname: w2\nrole: Second Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nSecond worker.\n",
|
|
157
|
+
)
|
|
158
|
+
.unwrap();
|
|
159
|
+
let transport = OfflineTransport::new();
|
|
160
|
+
let result = add_agent_with_transport(&ws, &AgentId::new("w2"), &role, false, None, &transport);
|
|
161
|
+
assert!(
|
|
162
|
+
!matches!(result, Err(LifecycleError::OwnerRefused(_))),
|
|
163
|
+
"add_agent must reach the real recompile chain for a no-owner workspace; got {result:?}"
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// P1 — fork_agent must PROCEED past the owner gate (no-owner ws). Stub → OwnerRefused.
|
|
168
|
+
// Golden: lifecycle/operations.py:fork_agent native session fork under the owner gate.
|
|
169
|
+
#[test]
|
|
170
|
+
fn fork_agent_proceeds_past_owner_gate_for_unowned_workspace() {
|
|
171
|
+
let ws = unowned_running_ws();
|
|
172
|
+
let transport = OfflineTransport::new();
|
|
173
|
+
let result = fork_agent_with_transport(
|
|
174
|
+
&ws,
|
|
175
|
+
&AgentId::new("w1"),
|
|
176
|
+
&AgentId::new("w1-fork"),
|
|
177
|
+
false,
|
|
178
|
+
None,
|
|
179
|
+
&transport,
|
|
180
|
+
);
|
|
181
|
+
assert!(
|
|
182
|
+
!matches!(result, Err(LifecycleError::OwnerRefused(_))),
|
|
183
|
+
"fork_agent must reach the real native-fork chain for a no-owner workspace; got {result:?}"
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// REAL-MACHINE boundary (acceptance framework): a full quick_start that actually LAUNCHES workers
|
|
188
|
+
// needs a live tmux + provider spawn — db/state seeding + the Ready report happen INSIDE launch()'s
|
|
189
|
+
// real spawn. Asserted by the acceptance crate, not here (do NOT fake the spawn).
|
|
190
|
+
#[test]
|
|
191
|
+
#[ignore = "real-machine: full quick_start Ready needs a live tmux + provider spawn (db/state seeding \
|
|
192
|
+
happens inside launch's real spawn). Acceptance-crate boundary, not a unit RED."]
|
|
193
|
+
fn quick_start_full_ready_real_spawn() {
|
|
194
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
195
|
+
let report = quick_start(&team, None, true, true, None).expect("quick_start launches the team");
|
|
196
|
+
assert!(matches!(report, QuickStartReport::Ready { .. }), "got {report:?}");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
200
|
+
// SPINE-WIRING (③ review→fix) RED — lifecycle wiring divergences vs golden v0.2.11
|
|
201
|
+
// (diagnose/quick_start.py, launch/core.py + config.py + routing.py, restart/orchestration.py,
|
|
202
|
+
// lifecycle/operations.py). /tmp/spine_divergences.md #8/#15, #10, #12, #13, #14.
|
|
203
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
204
|
+
|
|
205
|
+
const QS_TEAM_MD_DANGEROUS: &str =
|
|
206
|
+
"---\nname: dangerteam\nobjective: Dangerous.\nprovider: codex\ndangerous_auto_approve: true\n---\n\nteam.\n";
|
|
207
|
+
|
|
208
|
+
fn quick_start_team_dir_custom(team_md: &str, role_doc: &str) -> PathBuf {
|
|
209
|
+
let team = temp_ws().join("teamdir");
|
|
210
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
211
|
+
std::fs::write(team.join("TEAM.md"), team_md).unwrap();
|
|
212
|
+
std::fs::write(team.join("agents").join("implementer.md"), role_doc).unwrap();
|
|
213
|
+
team
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
fn compiled_spec_path(team: &std::path::Path) -> PathBuf {
|
|
217
|
+
let spec = crate::compiler::compile_team(team).expect("compile team");
|
|
218
|
+
let spec_path = team.join("team.spec.yaml");
|
|
219
|
+
std::fs::write(&spec_path, crate::model::yaml::dumps(&spec)).unwrap();
|
|
220
|
+
spec_path
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
fn raw_runtime_state(workspace: &std::path::Path) -> (String, serde_json::Value) {
|
|
224
|
+
let state_path = crate::model::paths::runtime_dir(workspace).join("state.json");
|
|
225
|
+
let raw = std::fs::read_to_string(&state_path)
|
|
226
|
+
.unwrap_or_else(|e| panic!("read state.json at {}: {e}", state_path.display()));
|
|
227
|
+
let state = serde_json::from_str(&raw).expect("state.json must parse");
|
|
228
|
+
(raw, state)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
struct EnvVarGuard {
|
|
232
|
+
key: &'static str,
|
|
233
|
+
previous: Option<String>,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
impl EnvVarGuard {
|
|
237
|
+
fn set(key: &'static str, value: &str) -> Self {
|
|
238
|
+
let previous = std::env::var(key).ok();
|
|
239
|
+
unsafe {
|
|
240
|
+
std::env::set_var(key, value);
|
|
241
|
+
}
|
|
242
|
+
Self { key, previous }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
fn unset(key: &'static str) -> Self {
|
|
246
|
+
let previous = std::env::var(key).ok();
|
|
247
|
+
unsafe {
|
|
248
|
+
std::env::remove_var(key);
|
|
249
|
+
}
|
|
250
|
+
Self { key, previous }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
impl Drop for EnvVarGuard {
|
|
255
|
+
fn drop(&mut self) {
|
|
256
|
+
unsafe {
|
|
257
|
+
if let Some(value) = self.previous.take() {
|
|
258
|
+
std::env::set_var(self.key, value);
|
|
259
|
+
} else {
|
|
260
|
+
std::env::remove_var(self.key);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
fn compiled_spec_path_with_paused_agent(team: &std::path::Path) -> PathBuf {
|
|
267
|
+
let mut spec = crate::compiler::compile_team(team).expect("compile team");
|
|
268
|
+
let crate::model::yaml::Value::Map(root) = &mut spec else {
|
|
269
|
+
panic!("compiled spec must be a map");
|
|
270
|
+
};
|
|
271
|
+
let agents = root
|
|
272
|
+
.iter_mut()
|
|
273
|
+
.find(|(key, _)| key == "agents")
|
|
274
|
+
.and_then(|(_, value)| match value {
|
|
275
|
+
crate::model::yaml::Value::List(agents) => Some(agents),
|
|
276
|
+
_ => None,
|
|
277
|
+
})
|
|
278
|
+
.expect("compiled spec must contain agents");
|
|
279
|
+
let first = agents.first_mut().expect("compiled spec must contain an agent");
|
|
280
|
+
let crate::model::yaml::Value::Map(agent) = first else {
|
|
281
|
+
panic!("compiled agent must be a map");
|
|
282
|
+
};
|
|
283
|
+
agent.push(("paused".to_string(), crate::model::yaml::Value::Bool(true)));
|
|
284
|
+
let spec_path = team.join("team.spec.yaml");
|
|
285
|
+
std::fs::write(&spec_path, crate::model::yaml::dumps(&spec)).unwrap();
|
|
286
|
+
spec_path
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
fn unowned_running_ws_all_paused() -> PathBuf {
|
|
290
|
+
let ws = temp_ws();
|
|
291
|
+
crate::state::persist::save_runtime_state(
|
|
292
|
+
&ws,
|
|
293
|
+
&json!({
|
|
294
|
+
"session_name": "team-x",
|
|
295
|
+
"agents": { "w1": { "provider": "codex", "role": "Worker", "model": "gpt-5.5", "auth_mode": "subscription", "status": "paused" } },
|
|
296
|
+
}),
|
|
297
|
+
)
|
|
298
|
+
.unwrap();
|
|
299
|
+
let _ = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
300
|
+
ws
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// #15 — quick_start seeds runtime state under team_workspace(team_dir) (the PARENT), not inside the
|
|
304
|
+
// team dir, so restart/status can locate it. Golden quick_start.py:35 team_workspace(team_dir).
|
|
305
|
+
#[test]
|
|
306
|
+
fn spine_quick_start_seeds_state_under_team_workspace_not_team_dir() {
|
|
307
|
+
let team = quick_start_team_dir(QS_VALID_ROLE); // <base>/teamdir
|
|
308
|
+
let transport = OfflineTransport::new();
|
|
309
|
+
let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
310
|
+
let workspace = team.parent().unwrap(); // team_workspace(<base>/teamdir) = <base>
|
|
311
|
+
let ws_state = crate::model::paths::runtime_dir(workspace).join("state.json");
|
|
312
|
+
assert!(
|
|
313
|
+
ws_state.exists(),
|
|
314
|
+
"quick_start must seed runtime state under team_workspace(team_dir)={} (not inside the team dir)",
|
|
315
|
+
workspace.display()
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// #10 — launch dry-run safety is derived from spec.runtime.dangerous_auto_approve (source=runtime_config),
|
|
320
|
+
// NOT a hardcoded Disabled. Golden config.py:effective_runtime_config.
|
|
321
|
+
#[test]
|
|
322
|
+
fn spine_launch_dry_run_safety_reflects_spec_dangerous() {
|
|
323
|
+
let team = quick_start_team_dir_custom(QS_TEAM_MD_DANGEROUS, QS_VALID_ROLE);
|
|
324
|
+
let spec_path = compiled_spec_path(&team);
|
|
325
|
+
let report = launch(&spec_path, true, false, true).expect("dry_run launch");
|
|
326
|
+
assert!(report.safety.enabled, "safety must reflect spec.runtime.dangerous_auto_approve=true");
|
|
327
|
+
assert_eq!(
|
|
328
|
+
report.safety.source,
|
|
329
|
+
DangerousApprovalSource::RuntimeConfig,
|
|
330
|
+
"safety source must be runtime_config when spec-declared"
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// #12 — launch dry-run produces one RoutingDecision PER spec.task via route_task, with the real task id
|
|
335
|
+
// and the route_task reason taxonomy — not one synthetic 'default_assignee' route. Golden core.py:77-88.
|
|
336
|
+
#[test]
|
|
337
|
+
fn spine_launch_dry_run_routes_one_per_task_with_route_reason() {
|
|
338
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
339
|
+
let spec_path = compiled_spec_path(&team);
|
|
340
|
+
let report = launch(&spec_path, true, true, true).expect("dry_run launch");
|
|
341
|
+
// compile_team emits one task 'task_initial' assigned to 'implementer' → reason 'explicit assignee on task'.
|
|
342
|
+
let route = report
|
|
343
|
+
.routes
|
|
344
|
+
.iter()
|
|
345
|
+
.find(|r| r.task_id.as_deref() == Some("task_initial"))
|
|
346
|
+
.unwrap_or_else(|| panic!("a route for task_initial expected; got {:?}", report.routes));
|
|
347
|
+
assert_eq!(
|
|
348
|
+
route.reason, "explicit assignee on task",
|
|
349
|
+
"the route reason must come from route_task, not the hardcoded 'default_assignee'"
|
|
350
|
+
);
|
|
351
|
+
assert_eq!(route.selected_agent.as_str(), "implementer");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// #13 — an empty/all-paused restart decision set is NOT an atomic refusal (remove the empty-decisions
|
|
355
|
+
// clause); only a non-empty unresumable set (and !allow_fresh) refuses. Golden orchestration.py.
|
|
356
|
+
#[test]
|
|
357
|
+
fn spine_restart_empty_team_is_not_atomic_refusal() {
|
|
358
|
+
let ws = unowned_running_ws_all_paused();
|
|
359
|
+
let transport = OfflineTransport::new();
|
|
360
|
+
let result = restart_with_transport(&ws, true, None, &transport);
|
|
361
|
+
assert!(
|
|
362
|
+
!matches!(result, Ok(RestartReport::RefusedResumeAtomicity { .. })),
|
|
363
|
+
"an all-paused/empty team must NOT yield an atomic refusal (empty decision set is not a refusal); got {result:?}"
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// #14 — add_agent rejects a duplicate agent id (golden operations.py:301 'agent id already exists'),
|
|
368
|
+
// BEFORE any full recompile. Current does a full compile_team with no duplicate-id guard.
|
|
369
|
+
#[test]
|
|
370
|
+
fn spine_add_agent_rejects_duplicate_agent_id() {
|
|
371
|
+
let team = quick_start_team_dir(QS_VALID_ROLE); // team already has agent 'implementer'
|
|
372
|
+
let role = team.join("dup-role.md");
|
|
373
|
+
std::fs::write(&role, QS_VALID_ROLE).unwrap(); // a role doc named 'implementer' = duplicate
|
|
374
|
+
let transport = OfflineTransport::new();
|
|
375
|
+
let result = add_agent_with_transport(&team, &AgentId::new("implementer"), &role, false, None, &transport);
|
|
376
|
+
let text = format!("{result:?}");
|
|
377
|
+
assert!(
|
|
378
|
+
text.contains("already exists"),
|
|
379
|
+
"add_agent must reject a duplicate agent id (golden 'agent id already exists'); got {text}"
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
384
|
+
// SPAWN sub-phase RED — launch(dry_run=false) must REALLY spawn (unlocks the acceptance
|
|
385
|
+
// framework's cheap real-machine Tier-1). Golden launch/core.py: create the session + one worker
|
|
386
|
+
// window per agent running its provider command (with workspace + agent-id context), populate the
|
|
387
|
+
// started list, attach the leader receiver. Today launch(dry_run=false) is the hardcoded stub
|
|
388
|
+
// RequirementUnmet("launch spawn requires live transport/provider boundary").
|
|
389
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
390
|
+
|
|
391
|
+
// P0 — launch(dry_run=false) over a real compiled spec must run the real spawn chain (create session +
|
|
392
|
+
// spawn ≥1 worker, populating LaunchReport.started), NOT the hardcoded spawn stub. The recording-
|
|
393
|
+
// transport spawn-CALL assertion (spawn_first/spawn_into argv) needs a transport-injection seam in
|
|
394
|
+
// launch — flagged to the leader (launch takes no transport today); the real tmux new-session is the
|
|
395
|
+
// #[ignore] acceptance boundary (see quick_start_full_ready_real_spawn). Here we assert the seam-
|
|
396
|
+
// independent observable: the spawn stub is gone + LaunchReport.started is populated + dry_run=false.
|
|
397
|
+
//
|
|
398
|
+
// CONVERTED to #[ignore] (final real-daemon sub-phase): the porter wires PUBLIC launch() to the real
|
|
399
|
+
// TmuxBackend (today it uses NoopLaunchTransport), so once wired this test spawns REAL tmux — a unit
|
|
400
|
+
// test must not. The OS-edge-mocked spawn-orchestration coverage moved to
|
|
401
|
+
// `launch_with_transport_records_one_spawn_per_agent_carrying_build_command` (recording mock transport).
|
|
402
|
+
#[test]
|
|
403
|
+
#[ignore = "real-machine: public launch(dry_run=false) spawns real tmux once wired to TmuxBackend; \
|
|
404
|
+
in-process coverage is launch_with_transport_records_one_spawn_per_agent_carrying_build_command"]
|
|
405
|
+
fn spawn_launch_not_dry_run_is_no_longer_the_spawn_stub() {
|
|
406
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
407
|
+
let spec_path = compiled_spec_path(&team);
|
|
408
|
+
|
|
409
|
+
let result = launch(&spec_path, false, true, true);
|
|
410
|
+
|
|
411
|
+
match result {
|
|
412
|
+
Err(LifecycleError::RequirementUnmet(msg)) if msg.contains("requires live transport") => {
|
|
413
|
+
panic!(
|
|
414
|
+
"launch(dry_run=false) still returns the hardcoded spawn stub; the real spawn was never wired: {msg}"
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
Ok(report) => {
|
|
418
|
+
assert!(!report.dry_run, "a real launch must report dry_run=false");
|
|
419
|
+
assert!(
|
|
420
|
+
!report.started.is_empty(),
|
|
421
|
+
"launch(dry_run=false) must spawn >=1 worker and populate LaunchReport.started; got {:?}",
|
|
422
|
+
report.started
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
// any OTHER Err (a real transport/preflight error from the wired spawn) still proves the stub is gone.
|
|
426
|
+
Err(_) => {}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
431
|
+
// (C) public launch → real TmuxBackend. The OS edge is mocked by a RECORDING transport via the
|
|
432
|
+
// launch_with_transport seam; the REAL TmuxBackend swap is exercised by the acceptance framework
|
|
433
|
+
// (#[ignore] spawn_launch_not_dry_run_is_no_longer_the_spawn_stub). Golden launch/core.py.
|
|
434
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
435
|
+
|
|
436
|
+
// (C) — launch_with_transport(dry_run=false, <recording transport>) creates one spawn per compiled
|
|
437
|
+
// agent, the recorded spawn argv carries that agent's provider build_command, and LaunchReport.started
|
|
438
|
+
// lists them. NOTE: GREEN today — spawn_agents already drives the injected transport; this LOCKS the
|
|
439
|
+
// spawn orchestration contract (replacing the now-#[ignore]'d public-launch test's in-process coverage).
|
|
440
|
+
// The genuine remaining gap — public launch() must construct a real TmuxBackend (today NoopLaunchTransport)
|
|
441
|
+
// — is not cleanly assertable in-process without spawning; it rides the porter's wiring + the #[ignore]
|
|
442
|
+
// real-machine test above.
|
|
443
|
+
#[test]
|
|
444
|
+
fn launch_with_transport_records_one_spawn_per_agent_carrying_build_command() {
|
|
445
|
+
let team = quick_start_team_dir(QS_VALID_ROLE); // one agent: implementer / provider codex
|
|
446
|
+
let spec_path = compiled_spec_path(&team);
|
|
447
|
+
let transport = OfflineTransport::new();
|
|
448
|
+
|
|
449
|
+
let report = launch_with_transport(&spec_path, false, true, true, &transport)
|
|
450
|
+
.expect("launch_with_transport must spawn against the recording transport");
|
|
451
|
+
|
|
452
|
+
let recorded = transport.spawn_records();
|
|
453
|
+
assert_eq!(
|
|
454
|
+
recorded.len(),
|
|
455
|
+
report.started.len(),
|
|
456
|
+
"exactly one spawn per started agent; recorded={recorded:?} started={:?}",
|
|
457
|
+
report.started
|
|
458
|
+
);
|
|
459
|
+
assert!(!report.started.is_empty(), "a real (non-dry-run) launch must spawn >=1 worker");
|
|
460
|
+
assert!(!report.dry_run, "launch_with_transport(dry_run=false) must report dry_run=false");
|
|
461
|
+
assert_eq!(recorded[0].0, "spawn_first", "the first worker uses new-session (spawn_first)");
|
|
462
|
+
assert!(
|
|
463
|
+
recorded.iter().any(|(_, argv)| argv.iter().any(|a| a == "codex")),
|
|
464
|
+
"each spawn argv must carry the agent's provider build_command (codex); got {recorded:?}"
|
|
465
|
+
);
|
|
466
|
+
assert!(
|
|
467
|
+
report.started.iter().any(|s| s.agent_id.as_str() == "implementer"),
|
|
468
|
+
"LaunchReport.started must list the compiled agent; got {:?}",
|
|
469
|
+
report.started
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
474
|
+
// rt-host-a REGRESSION — `team-agent quick-start` compiles+seeds but NEVER spawns workers:
|
|
475
|
+
// quick_start_with_transport hardcodes launch dry_run=TRUE, so launch takes the started=Vec::new()
|
|
476
|
+
// branch and spawn_agents is never called. The 788-green missed it (no test drove quick_start through
|
|
477
|
+
// the spawn path). Golden: a real quick-start must spawn one worker per compiled agent into the session.
|
|
478
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
479
|
+
|
|
480
|
+
/// Seed a HEALTHY coordinator at `workspace` (this process's pid + matching metadata + db schema) so
|
|
481
|
+
/// quick_start's internal start_coordinator returns AlreadyRunning — NO real daemon subprocess spawn.
|
|
482
|
+
pub(super) fn seed_healthy_coordinator(workspace: &std::path::Path) {
|
|
483
|
+
let wp = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
|
|
484
|
+
std::fs::create_dir_all(crate::model::paths::runtime_dir(workspace)).unwrap();
|
|
485
|
+
let _ = crate::message_store::MessageStore::open(workspace).unwrap(); // create db schema (schema.ok)
|
|
486
|
+
let me = crate::coordinator::Pid::new(std::process::id());
|
|
487
|
+
crate::coordinator::write_coordinator_metadata(&wp, me, crate::coordinator::MetadataSource::Boot).unwrap();
|
|
488
|
+
std::fs::write(crate::coordinator::coordinator_pid_path(&wp), me.to_string()).unwrap();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// RED — quick_start_with_transport must drive the REAL spawn path: launch.dry_run==false, started
|
|
492
|
+
// non-empty (one per compiled agent), and the transport records >=1 spawn carrying that agent's
|
|
493
|
+
// provider build_command. Today the dry_run=true bug -> launch.started empty + ZERO spawns recorded ->
|
|
494
|
+
// RED at assertion (NOT a panic). OS-safe: recording transport (no real tmux) + seeded-healthy
|
|
495
|
+
// coordinator (start_coordinator AlreadyRunning -> no daemon subprocess).
|
|
496
|
+
#[test]
|
|
497
|
+
fn quick_start_with_transport_spawns_workers_not_dry_run() {
|
|
498
|
+
let team = quick_start_team_dir(QS_VALID_ROLE); // one agent: implementer / provider codex
|
|
499
|
+
let workspace = team.parent().expect("team_workspace(team_dir) = parent"); // where start_coordinator runs
|
|
500
|
+
seed_healthy_coordinator(workspace);
|
|
501
|
+
let transport = OfflineTransport::new();
|
|
502
|
+
|
|
503
|
+
let report = quick_start_with_transport(&team, None, true, true, None, &transport)
|
|
504
|
+
.expect("quick_start_with_transport must reach a report");
|
|
505
|
+
|
|
506
|
+
let launch = match report {
|
|
507
|
+
QuickStartReport::Ready { launch, .. } => *launch,
|
|
508
|
+
other => panic!("quick_start must reach Ready (the spawn path); got {other:?}"),
|
|
509
|
+
};
|
|
510
|
+
// (1) the rt-host-a bug: launch dry_run hardcoded true -> this is the load-bearing regression assert.
|
|
511
|
+
assert!(
|
|
512
|
+
!launch.dry_run,
|
|
513
|
+
"quick_start must SPAWN workers (launch.dry_run == false); the rt-host-a bug hardcodes dry_run=true \
|
|
514
|
+
so launch takes the empty started branch and never spawns"
|
|
515
|
+
);
|
|
516
|
+
// (2) one started entry per compiled agent (the dry-run branch leaves this empty).
|
|
517
|
+
assert!(
|
|
518
|
+
!launch.started.is_empty(),
|
|
519
|
+
"quick_start must populate launch.started (>=1 worker spawned), not the dry-run empty Vec; got {:?}",
|
|
520
|
+
launch.started
|
|
521
|
+
);
|
|
522
|
+
assert!(
|
|
523
|
+
launch.started.iter().any(|s| s.agent_id.as_str() == "implementer"),
|
|
524
|
+
"launch.started must list the compiled agent 'implementer'; got {:?}",
|
|
525
|
+
launch.started
|
|
526
|
+
);
|
|
527
|
+
// (3) the transport actually recorded the spawn, carrying the provider build_command.
|
|
528
|
+
let recorded = transport.spawn_records();
|
|
529
|
+
assert!(
|
|
530
|
+
!recorded.is_empty(),
|
|
531
|
+
"quick_start must drive the transport spawn path (>=1 spawn recorded); the dry-run bug records ZERO spawns"
|
|
532
|
+
);
|
|
533
|
+
assert_eq!(recorded[0].0, "spawn_first", "the first worker uses new-session (spawn_first); got {recorded:?}");
|
|
534
|
+
assert!(
|
|
535
|
+
recorded.iter().any(|(_, argv)| argv.iter().any(|a| a == "codex")),
|
|
536
|
+
"the spawn argv must carry the agent's provider build_command (codex); got {recorded:?}"
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// REAL-MACHINE residency boundary (acceptance framework): the PUBLIC quick_start (real TmuxBackend +
|
|
541
|
+
// real start_coordinator) on a fresh ws must leave a LIVE tmux session AND a ps-verifiable resident
|
|
542
|
+
// coordinator daemon (start_coordinator -> live pid). The framework verifies residency via ps; here we
|
|
543
|
+
// only assert the in-report spawn observable. #[ignore] — spawns real tmux + a real daemon subprocess.
|
|
544
|
+
#[test]
|
|
545
|
+
#[ignore = "real-machine: live tmux session + resident coordinator daemon (framework verifies via ps)"]
|
|
546
|
+
fn quick_start_fresh_ws_spawns_resident_tmux_and_coordinator() {
|
|
547
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
548
|
+
let report = quick_start(&team, None, true, true, None).expect("quick_start");
|
|
549
|
+
match report {
|
|
550
|
+
QuickStartReport::Ready { launch, .. } => {
|
|
551
|
+
assert!(!launch.dry_run, "a real quick_start must spawn (not dry-run)");
|
|
552
|
+
assert!(
|
|
553
|
+
!launch.started.is_empty(),
|
|
554
|
+
"a real quick_start must spawn >=1 worker into a live tmux session; got {:?}",
|
|
555
|
+
launch.started
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
other => panic!("a fresh quick_start must reach Ready; got {other:?}"),
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
563
|
+
// rt-host-a LOOP #2 — cli-handler DELEGATION sweep (same-class stub): restart / start_agent /
|
|
564
|
+
// add_agent return RequirementUnmet at the spawn boundary and NEVER drive the transport (zero spawns)
|
|
565
|
+
// nor start the coordinator. RED via the new *_with_transport seams + a RecordingTransport. OS-safe:
|
|
566
|
+
// recording transport (no real tmux) + seeded-healthy-coordinator (start_coordinator AlreadyRunning).
|
|
567
|
+
// Golden: restart/orchestration.py (Route-B resume spawn), lifecycle/start.py, lifecycle/operations.py.
|
|
568
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
569
|
+
|
|
570
|
+
pub(super) const DELEG_ROLE_ALPHA: &str = "---\nname: alpha\nrole: Alpha Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nAlpha.\n";
|
|
571
|
+
pub(super) const DELEG_ROLE_BRAVO: &str = "---\nname: bravo\nrole: Bravo Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nBravo.\n";
|
|
572
|
+
const DELEG_ROLE_WORKER2: &str = "---\nname: worker2\nrole: Second Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nSecond worker.\n";
|
|
573
|
+
|
|
574
|
+
/// A workspace (= self-contained team dir) with a compiled 2-agent spec + state listing alpha/bravo as
|
|
575
|
+
/// RESUMABLE (running, valid first_send_at, session_id) + a seeded HEALTHY coordinator.
|
|
576
|
+
pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
|
|
577
|
+
let ws = temp_ws().join("restartteam");
|
|
578
|
+
std::fs::create_dir_all(ws.join("agents")).unwrap();
|
|
579
|
+
std::fs::write(ws.join("TEAM.md"), "---\nname: restartteam\nobjective: Restart probe.\nprovider: codex\n---\n\nteam.\n").unwrap();
|
|
580
|
+
std::fs::write(ws.join("agents").join("alpha.md"), DELEG_ROLE_ALPHA).unwrap();
|
|
581
|
+
std::fs::write(ws.join("agents").join("bravo.md"), DELEG_ROLE_BRAVO).unwrap();
|
|
582
|
+
let spec = crate::compiler::compile_team(&ws).expect("compile 2-agent team");
|
|
583
|
+
std::fs::write(ws.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
|
|
584
|
+
crate::state::persist::save_runtime_state(
|
|
585
|
+
&ws,
|
|
586
|
+
&json!({
|
|
587
|
+
"session_name": "team-restartteam",
|
|
588
|
+
"agents": {
|
|
589
|
+
"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "first_send_at": "2026-05-27T10:00:00+00:00"},
|
|
590
|
+
"bravo": {"status": "running", "provider": "codex", "session_id": "sess-b", "first_send_at": "2026-05-27T10:00:00+00:00"}
|
|
591
|
+
}
|
|
592
|
+
}),
|
|
593
|
+
)
|
|
594
|
+
.unwrap();
|
|
595
|
+
seed_healthy_coordinator(&ws); // start_coordinator -> AlreadyRunning (no real daemon subprocess)
|
|
596
|
+
ws
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// 2 [P0] — restart_with_transport must drive the REAL Route-B resume spawn: one spawn per resumable
|
|
600
|
+
// worker (first=spawn_first, rest=spawn_into), each carrying the provider build_command, + coordinator
|
|
601
|
+
// started. Today the stub returns RequirementUnmet with ZERO spawns -> RED at recorded.len().
|
|
602
|
+
#[test]
|
|
603
|
+
fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
604
|
+
let ws = restart_ws_two_resumable_workers();
|
|
605
|
+
let transport = OfflineTransport::new();
|
|
606
|
+
|
|
607
|
+
let result = restart_with_transport(&ws, false, None, &transport);
|
|
608
|
+
|
|
609
|
+
let recorded = transport.spawn_records();
|
|
610
|
+
assert_eq!(
|
|
611
|
+
recorded.len(),
|
|
612
|
+
2,
|
|
613
|
+
"restart must spawn ONE worker per resumable agent (alpha, bravo); the rt-host-a stub returns \
|
|
614
|
+
RequirementUnmet with ZERO spawns; got {recorded:?}"
|
|
615
|
+
);
|
|
616
|
+
assert_eq!(recorded[0].0, "spawn_first", "the first resumed worker uses new-session (spawn_first); got {recorded:?}");
|
|
617
|
+
assert!(
|
|
618
|
+
recorded.iter().any(|(kind, _)| kind == "spawn_into"),
|
|
619
|
+
"subsequent resumed workers use new-window (spawn_into); got {recorded:?}"
|
|
620
|
+
);
|
|
621
|
+
assert!(
|
|
622
|
+
recorded.iter().all(|(_, argv)| argv.iter().any(|a| a == "codex")),
|
|
623
|
+
"each resumed worker's spawn argv must carry the provider build_command (codex); got {recorded:?}"
|
|
624
|
+
);
|
|
625
|
+
assert!(
|
|
626
|
+
matches!(result, Ok(RestartReport::Restarted { coordinator_started: true, .. })),
|
|
627
|
+
"restart must reach RestartReport::Restarted with coordinator_started=true (AlreadyRunning, seeded); got {result:?}"
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// 3 [P0] — start_agent_with_transport on a non-paused agent with a session_id must spawn EXACTLY ONE
|
|
632
|
+
// worker (resume) carrying the provider build_command. Today the stub returns RequirementUnmet with
|
|
633
|
+
// ZERO spawns -> RED at recorded.len().
|
|
634
|
+
#[test]
|
|
635
|
+
fn start_agent_with_transport_spawns_resume_not_stub() {
|
|
636
|
+
let ws = temp_ws().join("startagentws");
|
|
637
|
+
std::fs::create_dir_all(&ws).unwrap();
|
|
638
|
+
crate::state::persist::save_runtime_state(
|
|
639
|
+
&ws,
|
|
640
|
+
&json!({
|
|
641
|
+
"session_name": "team-sa",
|
|
642
|
+
"agents": {"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "first_send_at": "2026-05-27T10:00:00+00:00"}}
|
|
643
|
+
}),
|
|
644
|
+
)
|
|
645
|
+
.unwrap();
|
|
646
|
+
seed_healthy_coordinator(&ws);
|
|
647
|
+
let transport = OfflineTransport::new();
|
|
648
|
+
|
|
649
|
+
let _result = start_agent_with_transport(&ws, &AgentId::new("alpha"), false, false, false, None, &transport);
|
|
650
|
+
|
|
651
|
+
let recorded = transport.spawn_records();
|
|
652
|
+
assert_eq!(
|
|
653
|
+
recorded.len(),
|
|
654
|
+
1,
|
|
655
|
+
"start_agent must spawn EXACTLY ONE worker (resume); the rt-host-a stub returns RequirementUnmet \
|
|
656
|
+
with ZERO spawns; got {recorded:?}"
|
|
657
|
+
);
|
|
658
|
+
assert!(
|
|
659
|
+
recorded[0].1.iter().any(|a| a == "codex"),
|
|
660
|
+
"the resume spawn argv must carry the provider build_command (codex); got {:?}",
|
|
661
|
+
recorded[0].1
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// 4 [P0] — add_agent_with_transport must (a) recompile + write the spec (already works) AND (b) spawn
|
|
666
|
+
// the new worker window. Today the stub recompiles then returns RequirementUnmet with ZERO spawns ->
|
|
667
|
+
// RED at (b). OS-safe: the new role file is OUTSIDE agents/ (so it's not a dup), seeded healthy coordinator.
|
|
668
|
+
#[test]
|
|
669
|
+
fn add_agent_with_transport_spawns_new_worker_not_stub() {
|
|
670
|
+
let team = temp_ws().join("addteam");
|
|
671
|
+
std::fs::create_dir_all(team.join("agents")).unwrap();
|
|
672
|
+
std::fs::write(team.join("TEAM.md"), "---\nname: addteam\nobjective: Add probe.\nprovider: codex\n---\n\nteam.\n").unwrap();
|
|
673
|
+
std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap(); // existing agent
|
|
674
|
+
let role_file = team.join("worker2-role.md"); // OUTSIDE agents/ -> not a duplicate of an existing agent
|
|
675
|
+
std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
|
|
676
|
+
seed_healthy_coordinator(&team);
|
|
677
|
+
let transport = OfflineTransport::new();
|
|
678
|
+
|
|
679
|
+
let _result = add_agent_with_transport(&team, &AgentId::new("worker2"), &role_file, false, None, &transport);
|
|
680
|
+
|
|
681
|
+
// (a) the recompiled spec was written (real subsystem step — works today).
|
|
682
|
+
assert!(team.join("team.spec.yaml").exists(), "add_agent must recompile + write team.spec.yaml under the team dir");
|
|
683
|
+
// (b) the new worker window was spawned (RED: stub recompiles then RequirementUnmet -> ZERO spawns).
|
|
684
|
+
let recorded = transport.spawn_records();
|
|
685
|
+
assert!(
|
|
686
|
+
!recorded.is_empty(),
|
|
687
|
+
"add_agent must spawn the new worker window (>=1 recorded spawn); the rt-host-a stub recompiles \
|
|
688
|
+
then returns RequirementUnmet with ZERO spawns; got {recorded:?}"
|
|
689
|
+
);
|
|
690
|
+
assert!(
|
|
691
|
+
recorded.iter().any(|(_, argv)| argv.iter().any(|a| a == "codex")),
|
|
692
|
+
"the new worker's spawn argv must carry the provider build_command (codex); got {recorded:?}"
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
697
|
+
// WAVE 2 · LANE A — agent-lifecycle byte-parity contracts. stop_agent / reset_agent / remove_agent /
|
|
698
|
+
// fork_agent are stubs (OwnerRefused / RequirementUnmet / "session_id missing"). These RED contracts
|
|
699
|
+
// LOCK the golden behavior (lifecycle/operations.py + agents.py). The existing tolerant tests accept the
|
|
700
|
+
// stub; these are STRICTER (RED today). OS-safe: no-owner ws + seeded spec/state, non-running agents
|
|
701
|
+
// (pure fs/state). Transport-asserting parts (kill_window / native-fork spawn) -> seam + #[ignore].
|
|
702
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
703
|
+
|
|
704
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
705
|
+
// collect #223 — task-scoped seeding (RED). Golden launch/core.py:69 ALWAYS seeds the runtime
|
|
706
|
+
// state's `tasks` key from spec.tasks (≥ []); the doc compiler emits a default task
|
|
707
|
+
// {id:"task_initial",…} (compiler.rs:308). Rust initial_runtime_state (launch.rs) emits only
|
|
708
|
+
// {session_name,team_dir,agents} — NO tasks key → send/collect cannot resolve the task. RED.
|
|
709
|
+
// OS-safe: OfflineTransport (zero real spawn).
|
|
710
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
711
|
+
#[test]
|
|
712
|
+
fn quick_start_seeds_tasks_key_from_compiled_spec() {
|
|
713
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
714
|
+
let transport = OfflineTransport::new();
|
|
715
|
+
let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
716
|
+
let workspace = team.parent().expect("team_workspace(<base>/teamdir) = <base>");
|
|
717
|
+
let state = crate::state::persist::load_runtime_state(workspace)
|
|
718
|
+
.expect("runtime state.json must exist after quick_start");
|
|
719
|
+
// (b) the tasks KEY must always be present and a JSON array (golden launch/core.py:69 default []).
|
|
720
|
+
let tasks = match state.get("tasks") {
|
|
721
|
+
Some(t) => t,
|
|
722
|
+
None => panic!(
|
|
723
|
+
"initial_runtime_state MUST seed a `tasks` key (golden launch/core.py:69 `tasks=[…spec.tasks]`); \
|
|
724
|
+
state keys = {:?}",
|
|
725
|
+
state.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>())
|
|
726
|
+
),
|
|
727
|
+
};
|
|
728
|
+
let tasks = tasks.as_array().expect("`tasks` must be a JSON array (golden default [])");
|
|
729
|
+
// (a) spec.tasks must be carried into runtime state — the doc compiler's default task id.
|
|
730
|
+
assert!(
|
|
731
|
+
tasks.iter().any(|t| t.get("id").and_then(|v| v.as_str()) == Some("task_initial")),
|
|
732
|
+
"state.tasks must carry the compiled spec's task (id=task_initial); got {tasks:?}"
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Stage A — golden launch/core.py:62-71 seeded top-level runtime state in insertion order:
|
|
737
|
+
// spec_path, workspace, team_dir, session_name, leader, agents, tasks, display_backend.
|
|
738
|
+
//
|
|
739
|
+
// OLD (Python parity): the top-level shape ended at `display_backend`.
|
|
740
|
+
// NEW (Bug 1/2 — team-in-team state scope, see tests/team_in_team_state_scope_red.rs):
|
|
741
|
+
// `active_team_key` and `teams` are appended at the tail so the runtime can carry the
|
|
742
|
+
// nested team-in-team scope alongside the original flat fields. Owner-binding fields
|
|
743
|
+
// (`leader_receiver` / `team_owner` / `owner_epoch`) live ONLY under
|
|
744
|
+
// `teams[<active_team_key>]` — Bug 2 owner team-scope (N1/N12/N18/N29) deliberately
|
|
745
|
+
// keeps them OFF the root so per-team isolation has a single source of truth and
|
|
746
|
+
// cross-team reads cannot accidentally pick up another team's binding from the root.
|
|
747
|
+
// The post-launch hook drops the top-level owner triple when the launched pane is
|
|
748
|
+
// unbound (empty pane_id), and only the per-team entry retains the binding.
|
|
749
|
+
// Order remains the golden prefix (spec_path … display_backend) + new suffix
|
|
750
|
+
// (active_team_key, teams). display_backend stays the resolved backend from
|
|
751
|
+
// display/backend.py:12-29 (default adaptive), not the raw optional spec field.
|
|
752
|
+
#[test]
|
|
753
|
+
#[serial(env)]
|
|
754
|
+
fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
755
|
+
// Bug 2 owner team-scope: top-level owner triple is dropped when the seeded
|
|
756
|
+
// pane is empty; an ambient TMUX_PANE (tests run inside the dev's tmux session)
|
|
757
|
+
// would otherwise leak into the caller-identity seed, inflate the seeded owner
|
|
758
|
+
// with a non-empty pane, and keep the top-level keys present — masking the
|
|
759
|
+
// per-team-isolation invariant this assertion locks. Force-unset for the test.
|
|
760
|
+
let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
|
|
761
|
+
let _tmux = EnvVarGuard::unset("TMUX");
|
|
762
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
763
|
+
let transport = OfflineTransport::new();
|
|
764
|
+
let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
765
|
+
let workspace = team.parent().expect("team_workspace(<base>/teamdir) = <base>");
|
|
766
|
+
let spec_path = team.join("team.spec.yaml");
|
|
767
|
+
let (raw, state) = raw_runtime_state(workspace);
|
|
768
|
+
let keys = state.as_object().expect("state root object").keys().cloned().collect::<Vec<_>>();
|
|
769
|
+
assert_eq!(
|
|
770
|
+
keys,
|
|
771
|
+
vec![
|
|
772
|
+
"spec_path",
|
|
773
|
+
"workspace",
|
|
774
|
+
"team_dir",
|
|
775
|
+
"session_name",
|
|
776
|
+
"leader",
|
|
777
|
+
"agents",
|
|
778
|
+
"tasks",
|
|
779
|
+
"display_backend",
|
|
780
|
+
"active_team_key",
|
|
781
|
+
"teams",
|
|
782
|
+
],
|
|
783
|
+
"state.json top-level key order must match golden launch/core.py:62-71 \
|
|
784
|
+
plus Bug 1/2 team-in-team suffix (active_team_key, teams). \
|
|
785
|
+
Bug 2 owner team-scope (N1/N12/N18/N29) deliberately keeps owner / \
|
|
786
|
+
leader_receiver / owner_epoch OFF the top level — they live ONLY under \
|
|
787
|
+
teams[<active_team_key>] so per-team isolation has a single source of \
|
|
788
|
+
truth (no shadow copy on the root that callers could read across teams); \
|
|
789
|
+
raw={raw}"
|
|
790
|
+
);
|
|
791
|
+
assert_eq!(state["spec_path"], json!(spec_path.canonicalize().unwrap().to_string_lossy()));
|
|
792
|
+
assert_eq!(state["workspace"], json!(workspace.to_string_lossy()));
|
|
793
|
+
assert_eq!(state["team_dir"], json!(team.to_string_lossy()));
|
|
794
|
+
assert_eq!(state["session_name"], json!("team-quickteam"));
|
|
795
|
+
assert_eq!(
|
|
796
|
+
state["leader"]["id"],
|
|
797
|
+
json!("leader"),
|
|
798
|
+
"leader must be copied from compiled spec.leader"
|
|
799
|
+
);
|
|
800
|
+
assert!(
|
|
801
|
+
state["agents"].as_object().is_some_and(|agents| agents.contains_key("implementer")),
|
|
802
|
+
"existing agents value must remain seeded from compiled spec; got {:?}",
|
|
803
|
+
state["agents"]
|
|
804
|
+
);
|
|
805
|
+
assert!(
|
|
806
|
+
state["tasks"].as_array().is_some_and(|tasks| {
|
|
807
|
+
tasks.iter().any(|task| task.get("id").and_then(|id| id.as_str()) == Some("task_initial"))
|
|
808
|
+
}),
|
|
809
|
+
"existing tasks value must remain seeded from compiled spec; got {:?}",
|
|
810
|
+
state["tasks"]
|
|
811
|
+
);
|
|
812
|
+
assert_eq!(
|
|
813
|
+
state["display_backend"],
|
|
814
|
+
json!("adaptive"),
|
|
815
|
+
"golden resolve_display_backend(None, source='launch') defaults to adaptive"
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Stage B1 — golden launch/core.py:238-255 writes the running agent state only after
|
|
820
|
+
// a successful spawn. The fixed clock is a test seam for `spawned_at`; capture intentionally
|
|
821
|
+
// misses, so session/capture fields remain present JSON nulls. MCP install is deterministic:
|
|
822
|
+
// provider_cli/adapter.py:111-114 writes workspace/.team/runtime/mcp/<agent>.json.
|
|
823
|
+
#[test]
|
|
824
|
+
#[serial(env)]
|
|
825
|
+
fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
|
|
826
|
+
const FIXED_SPAWNED_AT: &str = "2026-06-04T00:00:00+00:00";
|
|
827
|
+
let _clock_guard =
|
|
828
|
+
EnvVarGuard::set("TEAM_AGENT_TEST_FIXED_SPAWNED_AT", FIXED_SPAWNED_AT);
|
|
829
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
830
|
+
let transport = OfflineTransport::new();
|
|
831
|
+
let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
|
|
832
|
+
let workspace = team.parent().expect("team_workspace(<base>/teamdir) = <base>");
|
|
833
|
+
let (raw, state) = raw_runtime_state(workspace);
|
|
834
|
+
let agent = state
|
|
835
|
+
.pointer("/agents/implementer")
|
|
836
|
+
.unwrap_or_else(|| panic!("implementer agent state missing; raw={raw}"));
|
|
837
|
+
let keys = agent.as_object().expect("agent state object").keys().cloned().collect::<Vec<_>>();
|
|
838
|
+
assert_eq!(
|
|
839
|
+
keys,
|
|
840
|
+
vec![
|
|
841
|
+
"status",
|
|
842
|
+
"provider",
|
|
843
|
+
"agent_id",
|
|
844
|
+
"model",
|
|
845
|
+
"auth_mode",
|
|
846
|
+
"profile",
|
|
847
|
+
"window",
|
|
848
|
+
"mcp_config",
|
|
849
|
+
"permissions",
|
|
850
|
+
"session_id",
|
|
851
|
+
"rollout_path",
|
|
852
|
+
"captured_at",
|
|
853
|
+
"captured_via",
|
|
854
|
+
"attribution_confidence",
|
|
855
|
+
"spawn_cwd",
|
|
856
|
+
"spawned_at",
|
|
857
|
+
],
|
|
858
|
+
"running agent state key order must match golden launch/core.py:238-255; raw={raw}"
|
|
859
|
+
);
|
|
860
|
+
assert_eq!(agent["status"], json!("running"));
|
|
861
|
+
assert_eq!(agent["provider"], json!("codex"));
|
|
862
|
+
assert_eq!(agent["agent_id"], json!("implementer"));
|
|
863
|
+
assert_eq!(agent["model"], json!("gpt-5.5"));
|
|
864
|
+
assert_eq!(agent["auth_mode"], json!("subscription"));
|
|
865
|
+
assert!(agent["profile"].is_null());
|
|
866
|
+
assert_eq!(agent["window"], json!("implementer"));
|
|
867
|
+
assert_eq!(
|
|
868
|
+
agent["mcp_config"],
|
|
869
|
+
json!(workspace.join(".team/runtime/mcp/implementer.json").to_string_lossy())
|
|
870
|
+
);
|
|
871
|
+
assert_eq!(
|
|
872
|
+
agent["permissions"],
|
|
873
|
+
json!({
|
|
874
|
+
"agent_id": "implementer",
|
|
875
|
+
"provider": "codex",
|
|
876
|
+
"tools": ["mcp_team"],
|
|
877
|
+
"resolved_tools": [{"tool": "mcp_team", "enforcement": "prompt_only"}],
|
|
878
|
+
"has_prompt_only": true
|
|
879
|
+
})
|
|
880
|
+
);
|
|
881
|
+
assert!(agent["session_id"].is_null());
|
|
882
|
+
assert!(agent["rollout_path"].is_null());
|
|
883
|
+
assert!(agent["captured_at"].is_null());
|
|
884
|
+
assert!(agent["captured_via"].is_null());
|
|
885
|
+
assert!(agent["attribution_confidence"].is_null());
|
|
886
|
+
assert_eq!(agent["spawn_cwd"], json!(workspace.to_string_lossy()));
|
|
887
|
+
assert_eq!(agent["spawned_at"], json!(FIXED_SPAWNED_AT));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Stage B2 — golden launch/core.py:171-173 writes paused workers as exactly
|
|
891
|
+
// {status, provider} and skips spawn entirely.
|
|
892
|
+
#[test]
|
|
893
|
+
fn quick_start_paused_agent_state_is_paused_provider_only_and_not_spawned() {
|
|
894
|
+
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
895
|
+
let spec_path = compiled_spec_path_with_paused_agent(&team);
|
|
896
|
+
let workspace = team.parent().expect("team_workspace(<base>/teamdir) = <base>");
|
|
897
|
+
crate::state::persist::save_runtime_state(
|
|
898
|
+
workspace,
|
|
899
|
+
&json!({
|
|
900
|
+
"session_name": "team-quickteam",
|
|
901
|
+
"team_dir": team.to_string_lossy(),
|
|
902
|
+
"agents": {
|
|
903
|
+
"implementer": {
|
|
904
|
+
"provider": "codex",
|
|
905
|
+
"role": "Implementation Engineer",
|
|
906
|
+
"model": "gpt-5.5",
|
|
907
|
+
"auth_mode": "subscription"
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
"tasks": []
|
|
911
|
+
}),
|
|
912
|
+
)
|
|
913
|
+
.expect("seed pre-launch runtime state");
|
|
914
|
+
let transport = OfflineTransport::new();
|
|
915
|
+
let _ = launch_with_transport(&spec_path, false, true, true, &transport);
|
|
916
|
+
let (raw, state) = raw_runtime_state(workspace);
|
|
917
|
+
let agent = state
|
|
918
|
+
.pointer("/agents/implementer")
|
|
919
|
+
.unwrap_or_else(|| panic!("implementer agent state missing; raw={raw}"));
|
|
920
|
+
let keys = agent.as_object().expect("agent state object").keys().cloned().collect::<Vec<_>>();
|
|
921
|
+
assert_eq!(
|
|
922
|
+
keys,
|
|
923
|
+
vec!["status", "provider"],
|
|
924
|
+
"paused agent state must be exactly golden launch/core.py:171-173; raw={raw}"
|
|
925
|
+
);
|
|
926
|
+
assert_eq!(agent["status"], json!("paused"));
|
|
927
|
+
assert_eq!(agent["provider"], json!("codex"));
|
|
928
|
+
assert!(
|
|
929
|
+
transport.spawn_records().is_empty(),
|
|
930
|
+
"paused agent must not spawn any terminal window; got {:?}",
|
|
931
|
+
transport.spawn_records()
|
|
932
|
+
);
|
|
933
|
+
}
|