@team-agent/installer 0.5.61 → 0.5.62

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.
Files changed (250) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -2
  4. package/crates/team-agent/src/cli/attach_app_server_leader.rs +1 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +1 -0
  6. package/crates/team-agent/src/cli/emit.rs +41 -7
  7. package/crates/team-agent/src/cli/helpers.rs +1 -0
  8. package/crates/team-agent/src/cli/leader.rs +6 -1
  9. package/crates/team-agent/src/cli/leaders.rs +2 -0
  10. package/crates/team-agent/src/cli/mod.rs +33 -3
  11. package/crates/team-agent/src/cli/named_address.rs +8 -1
  12. package/crates/team-agent/src/cli/profile.rs +2 -1
  13. package/crates/team-agent/src/cli/send/coordinator.rs +1 -0
  14. package/crates/team-agent/src/cli/send/mailbox.rs +3 -0
  15. package/crates/team-agent/src/cli/send/persist.rs +1 -0
  16. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  17. package/crates/team-agent/src/cli/send/resolve.rs +206 -1
  18. package/crates/team-agent/src/cli/send.rs +2 -1
  19. package/crates/team-agent/src/cli/spec.rs +1 -0
  20. package/crates/team-agent/src/cli/status.rs +2 -1
  21. package/crates/team-agent/src/cli/status_port/compact.rs +1 -0
  22. package/crates/team-agent/src/cli/status_port/format.rs +1 -0
  23. package/crates/team-agent/src/cli/status_port/inbox.rs +1 -0
  24. package/crates/team-agent/src/cli/tests/verb_profile.rs +38 -0
  25. package/crates/team-agent/src/cli/types.rs +3 -2
  26. package/crates/team-agent/src/communication_mode/mod.rs +1 -0
  27. package/crates/team-agent/src/compiler.rs +1 -0
  28. package/crates/team-agent/src/conpty/backend.rs +1 -0
  29. package/crates/team-agent/src/conpty/mod.rs +1 -0
  30. package/crates/team-agent/src/coordinator/backoff.rs +5 -8
  31. package/crates/team-agent/src/coordinator/conpty_shim.rs +3 -2
  32. package/crates/team-agent/src/coordinator/health.rs +1 -0
  33. package/crates/team-agent/src/coordinator/mod.rs +2 -2
  34. package/crates/team-agent/src/coordinator/orphan.rs +4 -116
  35. package/crates/team-agent/src/coordinator/runtime_detectors.rs +1 -0
  36. package/crates/team-agent/src/coordinator/runtime_observation.rs +1 -0
  37. package/crates/team-agent/src/coordinator/steps/abnormal.rs +6 -5
  38. package/crates/team-agent/src/coordinator/steps/delivery.rs +1 -0
  39. package/crates/team-agent/src/coordinator/steps/health_sync.rs +1 -0
  40. package/crates/team-agent/src/coordinator/steps/mod.rs +2 -0
  41. package/crates/team-agent/src/coordinator/steps/persist.rs +1 -0
  42. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +1 -0
  43. package/crates/team-agent/src/coordinator/steps/session_gate.rs +1 -0
  44. package/crates/team-agent/src/coordinator/tests/abnormal.rs +0 -174
  45. package/crates/team-agent/src/coordinator/tests/api_error_recovery.rs +0 -9
  46. package/crates/team-agent/src/coordinator/tests/basics.rs +0 -184
  47. package/crates/team-agent/src/coordinator/tests/mod.rs +2 -17
  48. package/crates/team-agent/src/coordinator/tests/tick_core.rs +0 -42
  49. package/crates/team-agent/src/coordinator/tick.rs +24 -28
  50. package/crates/team-agent/src/coordinator/types.rs +3 -176
  51. package/crates/team-agent/src/db/agent_health_capture.rs +1 -0
  52. package/crates/team-agent/src/db/message_store.rs +2 -1
  53. package/crates/team-agent/src/db/migration.rs +5 -4
  54. package/crates/team-agent/src/db/mod.rs +1 -0
  55. package/crates/team-agent/src/db/schema.rs +1 -0
  56. package/crates/team-agent/src/diagnose/comms.rs +2 -1
  57. package/crates/team-agent/src/diagnose/mod.rs +1 -0
  58. package/crates/team-agent/src/diagnose/orphans.rs +1 -0
  59. package/crates/team-agent/src/layout/manager.rs +3 -7
  60. package/crates/team-agent/src/layout/mod.rs +2 -2
  61. package/crates/team-agent/src/layout/overlay.rs +6 -1
  62. package/crates/team-agent/src/layout/placement.rs +1 -0
  63. package/crates/team-agent/src/layout/recovery.rs +3 -0
  64. package/crates/team-agent/src/layout/runtime_sessions.rs +8 -3
  65. package/crates/team-agent/src/layout/sessions.rs +8 -1
  66. package/crates/team-agent/src/layout/tmux_endpoint.rs +3 -0
  67. package/crates/team-agent/src/layout/worker_env.rs +3 -0
  68. package/crates/team-agent/src/layout/worker_window_helpers.rs +2 -0
  69. package/crates/team-agent/src/leader/helpers.rs +1 -0
  70. package/crates/team-agent/src/leader/incident.rs +1 -0
  71. package/crates/team-agent/src/leader/lease.rs +27 -22
  72. package/crates/team-agent/src/leader/mod.rs +2 -3
  73. package/crates/team-agent/src/leader/owner_bind.rs +13 -3
  74. package/crates/team-agent/src/leader/provider_attribution.rs +1 -0
  75. package/crates/team-agent/src/leader/rediscover.rs +4 -17
  76. package/crates/team-agent/src/leader/registry.rs +3 -15
  77. package/crates/team-agent/src/leader/start.rs +373 -125
  78. package/crates/team-agent/src/leader/takeover.rs +10 -232
  79. package/crates/team-agent/src/leader/tests/claim_leader_mailbox_flush_contract.rs +544 -0
  80. package/crates/team-agent/src/leader/tests/historical_inventory_upgrade_gate_red.rs +624 -0
  81. package/crates/team-agent/src/leader/tests/identity.rs +7 -2
  82. package/crates/team-agent/src/leader/tests/idle.rs +0 -193
  83. package/crates/team-agent/src/leader/tests/lease_api.rs +0 -45
  84. package/crates/team-agent/src/leader/tests/mod.rs +4 -41
  85. package/crates/team-agent/src/leader/tests/rehomed_env.rs +83 -0
  86. package/crates/team-agent/src/leader/tests/team_in_team_state_scope_red.rs +1273 -0
  87. package/crates/team-agent/src/leader/tests/terminal_and_replay_vs_rerender_invariants_red.rs +417 -0
  88. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +22 -103
  89. package/crates/team-agent/src/leader/types.rs +1 -28
  90. package/crates/team-agent/src/lib.rs +3 -0
  91. package/crates/team-agent/src/lifecycle/display.rs +7 -1
  92. package/crates/team-agent/src/lifecycle/helpers.rs +3 -1
  93. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +2 -2
  94. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +1 -0
  95. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +1 -0
  96. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +1 -0
  97. package/crates/team-agent/src/lifecycle/launch/plan.rs +2 -2
  98. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +4 -26
  99. package/crates/team-agent/src/lifecycle/launch/readiness.rs +0 -27
  100. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -27
  101. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +4 -32
  102. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +1 -0
  103. package/crates/team-agent/src/lifecycle/launch.rs +11 -12
  104. package/crates/team-agent/src/lifecycle/mod.rs +2 -1
  105. package/crates/team-agent/src/lifecycle/restart/agent.rs +3 -3
  106. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +2 -2
  107. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -5
  108. package/crates/team-agent/src/lifecycle/restart/remove.rs +1 -1
  109. package/crates/team-agent/src/lifecycle/restart/selection.rs +3 -3
  110. package/crates/team-agent/src/lifecycle/restart.rs +13 -11
  111. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1007 -0
  112. package/crates/team-agent/src/lifecycle/tests/b0_legacy_snapshot_nonauthority_contract.rs +265 -0
  113. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +459 -0
  114. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +849 -0
  115. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +323 -0
  116. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +423 -0
  117. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +737 -0
  118. package/crates/team-agent/src/lifecycle/tests/codex_mcp_approval_inheritance_red.rs +1187 -0
  119. package/crates/team-agent/src/lifecycle/tests/codex_weak_window_attribution_red.rs +683 -0
  120. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +394 -0
  121. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +1856 -0
  122. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +917 -0
  123. package/crates/team-agent/src/lifecycle/tests/display_adaptive_red.rs +481 -0
  124. package/crates/team-agent/src/lifecycle/tests/f032_startup_prompt_best_effort_red.rs +301 -0
  125. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +516 -0
  126. package/crates/team-agent/src/lifecycle/tests/host_cotenant_death_p0_contract.rs +986 -0
  127. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +651 -0
  128. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +2 -0
  129. package/crates/team-agent/src/lifecycle/tests/quick_start_worker_readiness_red.rs +332 -0
  130. package/crates/team-agent/src/lifecycle/tests/realmachine_clusters_1_6_red.rs +846 -0
  131. package/crates/team-agent/src/lifecycle/tests/realmachine_residual_g1_g4_red.rs +648 -0
  132. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +910 -0
  133. package/crates/team-agent/src/lifecycle/tests/restart_liveness_red.rs +630 -0
  134. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1083 -0
  135. package/crates/team-agent/src/lifecycle/tests/restart_session_capture_red.rs +1677 -0
  136. package/crates/team-agent/src/lifecycle/tests/resume_recover_red.rs +410 -0
  137. package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +428 -0
  138. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1032 -0
  139. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +869 -0
  140. package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +1249 -0
  141. package/crates/team-agent/src/lifecycle/tests/swallow_batch4_semantics_red.rs +342 -0
  142. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +542 -0
  143. package/crates/team-agent/src/lifecycle/tests/team_in_team_sibling_quick_start_red.rs +1103 -0
  144. package/crates/team-agent/src/lifecycle/tests/team_in_team_state_scope_red.rs +1276 -0
  145. package/crates/team-agent/src/lifecycle/tests/team_key_retirement_roster_red.rs +145 -0
  146. package/crates/team-agent/src/lifecycle/tests/team_key_retirement_txn_red.rs +603 -0
  147. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +837 -0
  148. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +928 -0
  149. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +951 -0
  150. package/crates/team-agent/src/lifecycle/tests/worker_dangerous_bypass_argv_red.rs +327 -0
  151. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +760 -0
  152. package/crates/team-agent/src/lifecycle/tests.rs +59 -0
  153. package/crates/team-agent/src/lifecycle/types.rs +0 -9
  154. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  155. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -0
  156. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +1 -0
  157. package/crates/team-agent/src/mcp_server/mod.rs +1 -0
  158. package/crates/team-agent/src/mcp_server/normalize.rs +4 -3
  159. package/crates/team-agent/src/mcp_server/tools.rs +1 -0
  160. package/crates/team-agent/src/mcp_server/types.rs +1 -18
  161. package/crates/team-agent/src/mcp_server/wire.rs +5 -5
  162. package/crates/team-agent/src/messaging/activity.rs +1 -0
  163. package/crates/team-agent/src/messaging/address.rs +1 -0
  164. package/crates/team-agent/src/messaging/delivery.rs +4 -3
  165. package/crates/team-agent/src/messaging/helpers.rs +2 -1
  166. package/crates/team-agent/src/messaging/leader_channel.rs +1 -0
  167. package/crates/team-agent/src/messaging/leader_receiver.rs +2 -1
  168. package/crates/team-agent/src/messaging/mod.rs +10 -14
  169. package/crates/team-agent/src/messaging/peers.rs +2 -0
  170. package/crates/team-agent/src/messaging/persist.rs +3 -1
  171. package/crates/team-agent/src/messaging/presentation.rs +2 -1
  172. package/crates/team-agent/src/messaging/results.rs +1 -0
  173. package/crates/team-agent/src/messaging/scheduler.rs +1 -0
  174. package/crates/team-agent/src/messaging/selftest.rs +1 -1
  175. package/crates/team-agent/src/messaging/send.rs +41 -8
  176. package/crates/team-agent/src/messaging/tests/mod.rs +5 -0
  177. package/crates/team-agent/src/messaging/types.rs +2 -2
  178. package/crates/team-agent/src/messaging/watchers.rs +19 -13
  179. package/crates/team-agent/src/model/enums.rs +1 -0
  180. package/crates/team-agent/src/model/errors.rs +1 -0
  181. package/crates/team-agent/src/model/ids.rs +1 -0
  182. package/crates/team-agent/src/model/mod.rs +1 -0
  183. package/crates/team-agent/src/model/name_similarity.rs +1 -0
  184. package/crates/team-agent/src/model/pane_authority_refusal.rs +1 -0
  185. package/crates/team-agent/src/model/paths.rs +1 -0
  186. package/crates/team-agent/src/model/permissions.rs +3 -2
  187. package/crates/team-agent/src/model/routing.rs +1 -0
  188. package/crates/team-agent/src/model/spec.rs +1 -0
  189. package/crates/team-agent/src/model/task_graph.rs +1 -0
  190. package/crates/team-agent/src/model/yaml/tests.rs +1 -0
  191. package/crates/team-agent/src/model/yaml.rs +1 -0
  192. package/crates/team-agent/src/packaging/install.rs +9 -147
  193. package/crates/team-agent/src/packaging/migrate.rs +2 -0
  194. package/crates/team-agent/src/packaging/mod.rs +1 -0
  195. package/crates/team-agent/src/packaging/repair.rs +2 -0
  196. package/crates/team-agent/src/packaging/tests.rs +29 -218
  197. package/crates/team-agent/src/packaging/types.rs +8 -15
  198. package/crates/team-agent/src/platform/argv.rs +4 -0
  199. package/crates/team-agent/src/platform/errors.rs +10 -1
  200. package/crates/team-agent/src/platform/file_lock.rs +4 -157
  201. package/crates/team-agent/src/platform/mod.rs +0 -61
  202. package/crates/team-agent/src/platform/process.rs +1 -0
  203. package/crates/team-agent/src/provider/adapter.rs +1 -0
  204. package/crates/team-agent/src/provider/adapters/claude.rs +1 -0
  205. package/crates/team-agent/src/provider/adapters/claude_fork.rs +1 -0
  206. package/crates/team-agent/src/provider/adapters/codex.rs +1 -0
  207. package/crates/team-agent/src/provider/adapters/copilot.rs +1 -0
  208. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +1 -0
  209. package/crates/team-agent/src/provider/adapters/fake.rs +1 -0
  210. package/crates/team-agent/src/provider/adapters/mod.rs +1 -0
  211. package/crates/team-agent/src/provider/approvals/mod.rs +1 -0
  212. package/crates/team-agent/src/provider/approvals/parsing.rs +2 -5
  213. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +6 -5
  214. package/crates/team-agent/src/provider/classify.rs +1 -0
  215. package/crates/team-agent/src/provider/faults.rs +1 -26
  216. package/crates/team-agent/src/provider/helpers.rs +1 -0
  217. package/crates/team-agent/src/provider/mod.rs +1 -1
  218. package/crates/team-agent/src/provider/session/capture.rs +1 -4
  219. package/crates/team-agent/src/provider/session/context_fork/claude.rs +1 -0
  220. package/crates/team-agent/src/provider/session/context_fork/codex.rs +1 -0
  221. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +1 -0
  222. package/crates/team-agent/src/provider/session/context_fork.rs +1 -0
  223. package/crates/team-agent/src/provider/session/mod.rs +2 -4
  224. package/crates/team-agent/src/provider/session/resume.rs +2 -209
  225. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -0
  226. package/crates/team-agent/src/provider/session_scan/codex.rs +1 -0
  227. package/crates/team-agent/src/provider/session_scan/common.rs +1 -0
  228. package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
  229. package/crates/team-agent/src/provider/session_scan.rs +1 -42
  230. package/crates/team-agent/src/provider/startup_prompt.rs +1 -0
  231. package/crates/team-agent/src/provider/types.rs +1 -0
  232. package/crates/team-agent/src/provider/wire.rs +1 -0
  233. package/crates/team-agent/src/state/identity.rs +16 -5
  234. package/crates/team-agent/src/state/identity_keys.rs +1 -0
  235. package/crates/team-agent/src/state/mod.rs +2 -0
  236. package/crates/team-agent/src/state/owner_gate.rs +5 -0
  237. package/crates/team-agent/src/state/ownership.rs +10 -4
  238. package/crates/team-agent/src/state/paths.rs +12 -0
  239. package/crates/team-agent/src/state/persist.rs +9 -3
  240. package/crates/team-agent/src/state/projection.rs +15 -2
  241. package/crates/team-agent/src/state/repository/intent.rs +1 -0
  242. package/crates/team-agent/src/state/repository/tests.rs +1 -0
  243. package/crates/team-agent/src/state/repository.rs +7 -0
  244. package/crates/team-agent/src/state/selector.rs +1 -0
  245. package/crates/team-agent/src/tmux_backend/tests.rs +39 -0
  246. package/crates/team-agent/src/tmux_backend.rs +63 -0
  247. package/crates/team-agent/src/transport.rs +15 -0
  248. package/package.json +4 -4
  249. package/crates/team-agent/src/leader/inject.rs +0 -33
  250. package/crates/team-agent/src/provider/command.rs +0 -80
@@ -0,0 +1,1032 @@
1
+ //! 0.5.38 RED contract: startup latency instrumentation and bounded worker spawn.
2
+ //!
3
+ //! References:
4
+ //! - `.team/artifacts/startup-latency-locate.md` §5 / §8.
5
+ //!
6
+ //! User-visible contract:
7
+ //! - Restart/launch expose structured phase timing before optimizing latency.
8
+ //! - Restart may spawn independent workers concurrently, but persisted topology
9
+ //! matches transport identity regardless of completion order, and failure
10
+ //! aggregation remains equivalent to serial.
11
+ //! - Readiness still waits on session, worker panes, and coordinator truth.
12
+
13
+ #![cfg(unix)]
14
+ #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
15
+
16
+ #[path = "../../../tests/support/hermetic.rs"]
17
+ mod hermetic_guard;
18
+ #[allow(dead_code)]
19
+ fn _hermetic_boundary_marker(_: &hermetic_guard::HermeticTestEnv) {}
20
+
21
+ use std::collections::{BTreeMap, BTreeSet};
22
+ use std::fs;
23
+ use std::path::{Path, PathBuf};
24
+ use std::sync::{Arc, Mutex};
25
+ use std::time::{Duration, Instant};
26
+
27
+ use serde_json::{json, Value};
28
+ use serial_test::serial;
29
+ use team_agent::coordinator::{MetadataSource, Pid, WorkspacePath};
30
+ use team_agent::event_log::EventLog;
31
+ use team_agent::lifecycle::{
32
+ quick_start_with_transport_in_workspace_with_display,
33
+ restart_with_transport_with_readiness_deadline, QuickStartReport, RestartReport,
34
+ };
35
+ use team_agent::message_store::MessageStore;
36
+ use team_agent::model::paths::{runtime_dir, runtime_spec_path};
37
+ use team_agent::state::persist::{load_runtime_state, save_runtime_state};
38
+ use team_agent::transport::{
39
+ AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
40
+ InjectStage, InjectVerification, Key, PaneField, PaneId, PaneInfo, PaneLiveness, SessionName,
41
+ SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
42
+ TurnVerification, WindowName,
43
+ };
44
+
45
+ const TEAM: &str = "current";
46
+ const TEAM_SESSION: &str = "team-current";
47
+ const TMUX_ENDPOINT: &str = "/Volumes/nvme/tmp/ta-0538-startup-latency.sock";
48
+ const WRONG_INDEX_INJECTION: &str = "TEAM_AGENT_TEST_INJECT_WRONG_OUTCOME_INDEX";
49
+ const FAILURE_SNAPSHOT: &str = "TEAM_AGENT_STARTUP_FAILURE_SNAPSHOT";
50
+
51
+ #[test]
52
+ #[serial(env)]
53
+ fn restart_records_parallel_spawn_overlap_and_deterministic_state() {
54
+ let case = RestartLatencyCase::new("r1-parallel-state", 8);
55
+ let transport = StartupLatencyTransport::new().with_spawn_delay(Duration::from_millis(80));
56
+
57
+ let report = restart_with_transport_with_readiness_deadline(
58
+ &case.workspace,
59
+ true,
60
+ Some(TEAM),
61
+ &transport,
62
+ Some(2_000),
63
+ )
64
+ .expect("R1 setup: restart should complete against recording transport");
65
+ assert!(
66
+ matches!(report, RestartReport::Restarted { .. }),
67
+ "R1 setup: restart must reach Restarted before checking latency contract; report={report:?}"
68
+ );
69
+
70
+ let calls = transport.spawn_calls();
71
+ let new_windows = calls
72
+ .iter()
73
+ .filter(|call| call.kind == "spawn_into")
74
+ .collect::<Vec<_>>();
75
+ let mut state = case.read_state();
76
+ if std::env::var_os(WRONG_INDEX_INJECTION).is_some() {
77
+ inject_wrong_outcome_index(&mut state, "w1", "w2");
78
+ }
79
+ let panes = transport.pane_snapshot();
80
+ let mut violations = Vec::new();
81
+ if !has_overlap(&new_windows) {
82
+ violations.push(format!(
83
+ "expected at least two new-window spawns to overlap after the initial session creator; calls={calls:?}"
84
+ ));
85
+ }
86
+ let agent_keys = state
87
+ .pointer("/agents")
88
+ .and_then(Value::as_object)
89
+ .map(|agents| agents.keys().cloned().collect::<Vec<_>>())
90
+ .unwrap_or_default();
91
+ let expected_agents = worker_ids(8);
92
+ if agent_keys != expected_agents {
93
+ violations.push(format!(
94
+ "persisted agents must stay sorted/stable in plan order; got={agent_keys:?}"
95
+ ));
96
+ }
97
+ for worker in &expected_agents {
98
+ let agent = state
99
+ .pointer(&format!("/agents/{worker}"))
100
+ .unwrap_or_else(|| panic!("R1 setup: missing {worker}; state={state}"));
101
+ if state.pointer("/session_name").and_then(Value::as_str) != Some(TEAM_SESSION) {
102
+ violations.push(format!("{worker}: root session_name not {TEAM_SESSION}"));
103
+ }
104
+ if agent.get("window").and_then(Value::as_str) != Some(worker.as_str()) {
105
+ violations.push(format!(
106
+ "{worker}: persisted window mismatch; agent={agent}"
107
+ ));
108
+ }
109
+ if agent.get("spawn_epoch").and_then(Value::as_u64) != Some(1) {
110
+ violations.push(format!(
111
+ "{worker}: respawn must persist spawn_epoch=1, not leave the old/missing value; agent={agent}"
112
+ ));
113
+ }
114
+ }
115
+ violations.extend(identity_tuple_violations(&state, &panes, &expected_agents));
116
+ let failure_snapshot =
117
+ (!violations.is_empty()).then(|| case.write_failure_snapshot(&state, &panes, &violations));
118
+ assert!(
119
+ violations.is_empty(),
120
+ "R1: parallel restart must overlap while preserving persisted↔transport identity tuples; failure_snapshot={}:\n{}",
121
+ failure_snapshot
122
+ .as_deref()
123
+ .map_or_else(|| "<none>".to_string(), |path| path.display().to_string()),
124
+ violations.join("\n")
125
+ );
126
+ }
127
+
128
+ #[test]
129
+ #[serial(env)]
130
+ fn restart_failure_aggregation_matches_serial_semantics_without_early_abort() {
131
+ let case = RestartLatencyCase::new("r2-failure-aggregation", 8);
132
+ let transport =
133
+ StartupLatencyTransport::new().with_spawn_failure("w3", "injected w3 spawn failure");
134
+
135
+ let report = restart_with_transport_with_readiness_deadline(
136
+ &case.workspace,
137
+ true,
138
+ Some(TEAM),
139
+ &transport,
140
+ Some(2_000),
141
+ )
142
+ .expect("R2 setup: restart should return a typed report");
143
+ let RestartReport::Partial {
144
+ agents,
145
+ failed_agents,
146
+ ..
147
+ } = report
148
+ else {
149
+ panic!(
150
+ "R2: one worker spawn failure must produce Partial, not early abort; report={report:?}"
151
+ );
152
+ };
153
+
154
+ assert_eq!(
155
+ failed_agents
156
+ .iter()
157
+ .map(|agent| agent.agent_id.as_str())
158
+ .collect::<Vec<_>>(),
159
+ vec!["w3"],
160
+ "R2: only the injected worker should fail; failed_agents={failed_agents:?}"
161
+ );
162
+ assert_eq!(
163
+ agents
164
+ .iter()
165
+ .map(|agent| agent.agent_id.as_str().to_string())
166
+ .collect::<Vec<_>>(),
167
+ ["w1", "w2", "w4", "w5", "w6", "w7", "w8"],
168
+ "R2: successful workers after w3 must still be spawned and reported"
169
+ );
170
+ assert_eq!(
171
+ transport
172
+ .spawn_calls()
173
+ .iter()
174
+ .map(|call| call.window.as_str())
175
+ .collect::<Vec<_>>(),
176
+ worker_ids(8),
177
+ "R2: failure aggregation must attempt the full plan; no early return after w3"
178
+ );
179
+
180
+ let state = case.read_state();
181
+ assert_eq!(
182
+ state.pointer("/agents/w3/status").and_then(Value::as_str),
183
+ Some("failed"),
184
+ "R2: failed worker must be persisted as failed; state={state}"
185
+ );
186
+ for worker in ["w1", "w2", "w4", "w5", "w6", "w7", "w8"] {
187
+ assert_eq!(
188
+ state
189
+ .pointer(&format!("/agents/{worker}/status"))
190
+ .and_then(Value::as_str),
191
+ Some("running"),
192
+ "R2: successful worker {worker} must be persisted running; state={state}"
193
+ );
194
+ }
195
+ assert_phase_events(
196
+ &case.events(),
197
+ "restart.phase",
198
+ &[
199
+ "plan_classification",
200
+ "spawn_all",
201
+ "save_state",
202
+ "completed",
203
+ ],
204
+ );
205
+ }
206
+
207
+ #[test]
208
+ #[serial(env)]
209
+ fn restart_readiness_timeout_keeps_three_truth_booleans_and_no_false_restarted() {
210
+ let case = RestartLatencyCase::new("r3-readiness-timeout", 1);
211
+ let transport = StartupLatencyTransport::new().with_session_missing_for_readiness();
212
+
213
+ let result = restart_with_transport_with_readiness_deadline(
214
+ &case.workspace,
215
+ true,
216
+ Some(TEAM),
217
+ &transport,
218
+ Some(1),
219
+ );
220
+ assert!(
221
+ result.is_err(),
222
+ "R3: readiness timeout must return Err, not a false Restarted report; result={result:?}"
223
+ );
224
+ let err = result.unwrap_err().to_string();
225
+ assert!(
226
+ err.contains("restart not ready within") && err.contains("tmux session created"),
227
+ "R3: readiness error must name the missing truth gates; err={err}"
228
+ );
229
+
230
+ let events = case.events();
231
+ let timeout = events
232
+ .iter()
233
+ .find(|event| event_name(event) == Some("restart.readiness_timeout"))
234
+ .unwrap_or_else(|| {
235
+ panic!("R3: restart.readiness_timeout event missing; events={events:?}")
236
+ });
237
+ assert_eq!(
238
+ timeout.get("tmux_session_created").and_then(Value::as_bool),
239
+ Some(false),
240
+ "R3: timeout must expose tmux_session_created=false; event={timeout}"
241
+ );
242
+ assert_eq!(
243
+ timeout
244
+ .get("worker_pane_addressable")
245
+ .and_then(Value::as_bool),
246
+ Some(true),
247
+ "R3: timeout must expose worker_pane_addressable=true for the spawned pane; event={timeout}"
248
+ );
249
+ assert_eq!(
250
+ timeout.get("coordinator_alive").and_then(Value::as_bool),
251
+ Some(false),
252
+ "R3: coordinator_alive must be false when the team session is not live; event={timeout}"
253
+ );
254
+ assert!(
255
+ !events.iter().any(|event| {
256
+ event_name(event) == Some("restart.completed")
257
+ && event.get("rc").and_then(Value::as_str) == Some("ok")
258
+ }),
259
+ "R3: readiness timeout must not emit a false ok restart.completed; events={events:?}"
260
+ );
261
+ assert_phase_events(&events, "restart.phase", &["readiness_wait"]);
262
+ }
263
+
264
+ #[test]
265
+ #[serial(env)]
266
+ fn eight_worker_restart_with_100ms_spawn_delay_beats_serial_baseline() {
267
+ let case = RestartLatencyCase::new("r4-timing-smoke", 8);
268
+ let delay = Duration::from_millis(100);
269
+ let transport = StartupLatencyTransport::new().with_spawn_delay(delay);
270
+
271
+ let started = Instant::now();
272
+ let report = restart_with_transport_with_readiness_deadline(
273
+ &case.workspace,
274
+ true,
275
+ Some(TEAM),
276
+ &transport,
277
+ Some(2_000),
278
+ )
279
+ .expect("R4 setup: restart should complete against delayed transport");
280
+ let elapsed = started.elapsed();
281
+ assert!(
282
+ matches!(report, RestartReport::Restarted { .. }),
283
+ "R4 setup: restart must reach Restarted; report={report:?}"
284
+ );
285
+
286
+ let calls = transport.spawn_calls();
287
+ assert_eq!(
288
+ calls.len(),
289
+ 8,
290
+ "R4 setup: timing smoke must exercise all 8 workers; calls={calls:?}"
291
+ );
292
+ let serial_baseline = delay * calls.len() as u32;
293
+ assert!(
294
+ serial_baseline >= Duration::from_millis(800),
295
+ "R4 setup: serial baseline must represent 8 workers * 100ms; baseline={serial_baseline:?}"
296
+ );
297
+ assert!(
298
+ elapsed < serial_baseline - Duration::from_millis(150),
299
+ "R4: bounded-concurrency restart should complete below the serial spawn sum; elapsed={elapsed:?} serial_baseline={serial_baseline:?} calls={calls:?}"
300
+ );
301
+ assert_phase_events(&case.events(), "restart.phase", &["spawn_all", "completed"]);
302
+ }
303
+
304
+ #[test]
305
+ #[serial(env)]
306
+ fn restart_and_launch_emit_structured_latency_events_without_fake_ready() {
307
+ let restart_case = RestartLatencyCase::new("events-restart", 2);
308
+ let restart_transport = StartupLatencyTransport::new();
309
+ let restart = restart_with_transport_with_readiness_deadline(
310
+ &restart_case.workspace,
311
+ true,
312
+ Some(TEAM),
313
+ &restart_transport,
314
+ Some(1_000),
315
+ )
316
+ .expect("event setup: restart should complete");
317
+ assert!(
318
+ matches!(restart, RestartReport::Restarted { .. }),
319
+ "event setup: restart failed unexpectedly; report={restart:?}"
320
+ );
321
+ let restart_events = restart_case.events();
322
+ assert_phase_events(
323
+ &restart_events,
324
+ "restart.phase",
325
+ &[
326
+ "resolve_context",
327
+ "compile_spec",
328
+ "plan_classification",
329
+ "teardown",
330
+ "spawn_all",
331
+ "save_state",
332
+ "coordinator_start",
333
+ "readiness_wait",
334
+ "completed",
335
+ ],
336
+ );
337
+ assert_worker_spawn_timing_events(&restart_events, &worker_ids(2), "restart");
338
+ assert_no_fake_ready_events(&restart_events);
339
+
340
+ let launch_case = LaunchLatencyCase::new("events-launch", 2);
341
+ let launch_transport = StartupLatencyTransport::new();
342
+ let quick_start = quick_start_with_transport_in_workspace_with_display(
343
+ &launch_case.workspace,
344
+ &launch_case.team_dir,
345
+ None,
346
+ true,
347
+ Some(TEAM),
348
+ &launch_transport,
349
+ false,
350
+ )
351
+ .expect("event setup: quick-start should complete");
352
+ assert!(
353
+ matches!(quick_start, QuickStartReport::Ready { .. }),
354
+ "event setup: quick-start failed unexpectedly; report={quick_start:?}"
355
+ );
356
+ let launch_events = launch_case.events();
357
+ assert_phase_events(
358
+ &launch_events,
359
+ "launch.phase",
360
+ &[
361
+ "compile_spec",
362
+ "spawn_all",
363
+ "coordinator_start",
364
+ "readiness_wait",
365
+ "completed",
366
+ ],
367
+ );
368
+ assert_worker_spawn_timing_events(&launch_events, &worker_ids(2), "launch");
369
+ assert_no_fake_ready_events(&launch_events);
370
+ }
371
+
372
+ struct RestartLatencyCase {
373
+ _env: hermetic_guard::HermeticTestEnv,
374
+ workspace: PathBuf,
375
+ }
376
+
377
+ impl RestartLatencyCase {
378
+ fn new(tag: &str, workers: usize) -> Self {
379
+ let env = hermetic_guard::HermeticTestEnv::enter(tag);
380
+ env.scrub_tmux();
381
+ env.assert_no_real_tmux();
382
+ let workspace = env.workspace(tag);
383
+ write_team_docs(&workspace, workers);
384
+ write_runtime_spec(&workspace, &workspace);
385
+ seed_restart_state(&workspace, workers);
386
+ seed_healthy_coordinator(&workspace);
387
+ Self {
388
+ _env: env,
389
+ workspace,
390
+ }
391
+ }
392
+
393
+ fn read_state(&self) -> Value {
394
+ load_runtime_state(&self.workspace).expect("load runtime state")
395
+ }
396
+
397
+ fn events(&self) -> Vec<Value> {
398
+ EventLog::new(&self.workspace).tail(0).expect("read events")
399
+ }
400
+
401
+ fn write_failure_snapshot(
402
+ &self,
403
+ state: &Value,
404
+ panes: &[Value],
405
+ violations: &[String],
406
+ ) -> PathBuf {
407
+ let path = std::env::var_os(FAILURE_SNAPSHOT)
408
+ .map(PathBuf::from)
409
+ .unwrap_or_else(|| {
410
+ self._env
411
+ .root()
412
+ .parent()
413
+ .expect("hermetic root parent")
414
+ .join(format!(
415
+ "startup-latency-r1-failure-{}.json",
416
+ std::process::id()
417
+ ))
418
+ });
419
+ if let Some(parent) = path.parent() {
420
+ fs::create_dir_all(parent).expect("create R1 failure snapshot parent");
421
+ }
422
+ let snapshot = json!({
423
+ "wrong_index_injected": std::env::var_os(WRONG_INDEX_INJECTION).is_some(),
424
+ "workspace": self.workspace,
425
+ "persisted_session": state.get("session_name"),
426
+ "persisted_agents": state.get("agents"),
427
+ "transport_panes": panes,
428
+ "violations": violations,
429
+ });
430
+ fs::write(
431
+ &path,
432
+ serde_json::to_vec_pretty(&snapshot).expect("serialize R1 failure snapshot"),
433
+ )
434
+ .expect("write R1 failure snapshot before unwind");
435
+ path
436
+ }
437
+ }
438
+
439
+ struct LaunchLatencyCase {
440
+ _env: hermetic_guard::HermeticTestEnv,
441
+ workspace: PathBuf,
442
+ team_dir: PathBuf,
443
+ }
444
+
445
+ impl LaunchLatencyCase {
446
+ fn new(tag: &str, workers: usize) -> Self {
447
+ let env = hermetic_guard::HermeticTestEnv::enter(tag);
448
+ env.scrub_tmux();
449
+ env.assert_no_real_tmux();
450
+ let workspace = env.workspace(tag);
451
+ let team_dir = workspace.join("teamdir");
452
+ fs::create_dir_all(&team_dir).expect("create launch team dir");
453
+ write_team_docs(&team_dir, workers);
454
+ seed_healthy_coordinator(&workspace);
455
+ Self {
456
+ _env: env,
457
+ workspace,
458
+ team_dir,
459
+ }
460
+ }
461
+
462
+ fn events(&self) -> Vec<Value> {
463
+ EventLog::new(&self.workspace)
464
+ .tail(0)
465
+ .expect("read launch events")
466
+ }
467
+ }
468
+
469
+ fn write_team_docs(team_dir: &Path, workers: usize) {
470
+ fs::create_dir_all(team_dir.join("agents")).expect("create agents dir");
471
+ fs::write(
472
+ team_dir.join("TEAM.md"),
473
+ "---\nname: current\nobjective: Startup latency contract.\nprovider: fake\n---\n\nTeam.\n",
474
+ )
475
+ .expect("write TEAM.md");
476
+ for worker in worker_ids(workers) {
477
+ fs::write(
478
+ team_dir.join("agents").join(format!("{worker}.md")),
479
+ role_doc(&worker),
480
+ )
481
+ .expect("write role doc");
482
+ }
483
+ }
484
+
485
+ fn role_doc(worker: &str) -> String {
486
+ format!(
487
+ "---\nname: {worker}\nrole: Worker {worker}\nprovider: fake\nmodel: fake\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\n{worker}.\n"
488
+ )
489
+ }
490
+
491
+ fn write_runtime_spec(workspace: &Path, team_dir: &Path) {
492
+ let spec = team_agent::compiler::compile_team(team_dir).expect("compile team");
493
+ let spec_text = team_agent::model::yaml::dumps(&spec);
494
+ fs::write(team_dir.join("team.spec.yaml"), &spec_text).expect("write legacy spec");
495
+ let runtime_spec = runtime_spec_path(workspace, TEAM);
496
+ fs::create_dir_all(runtime_spec.parent().expect("runtime spec parent"))
497
+ .expect("create runtime spec dir");
498
+ fs::write(runtime_spec, spec_text).expect("write runtime spec");
499
+ }
500
+
501
+ fn seed_restart_state(workspace: &Path, workers: usize) {
502
+ let agents = worker_ids(workers)
503
+ .into_iter()
504
+ .map(|worker| {
505
+ (
506
+ worker.clone(),
507
+ json!({
508
+ "status": "running",
509
+ "provider": "fake",
510
+ "agent_id": worker,
511
+ "model": "fake",
512
+ "auth_mode": "subscription",
513
+ "window": worker,
514
+ "pane_id": format!("%old-{worker}"),
515
+ "spawn_epoch": 0,
516
+ "spawn_cwd": workspace.to_string_lossy(),
517
+ }),
518
+ )
519
+ })
520
+ .collect::<serde_json::Map<String, Value>>();
521
+ save_runtime_state(
522
+ workspace,
523
+ &json!({
524
+ "active_team_key": TEAM,
525
+ "team_key": TEAM,
526
+ "workspace": workspace.to_string_lossy(),
527
+ "team_dir": workspace.to_string_lossy(),
528
+ "spec_path": runtime_spec_path(workspace, TEAM).to_string_lossy(),
529
+ "session_name": TEAM_SESSION,
530
+ "tmux_endpoint": TMUX_ENDPOINT,
531
+ "tmux_socket": TMUX_ENDPOINT,
532
+ "agents": Value::Object(agents.clone()),
533
+ "teams": {
534
+ TEAM: {
535
+ "team_key": TEAM,
536
+ "workspace": workspace.to_string_lossy(),
537
+ "team_dir": workspace.to_string_lossy(),
538
+ "spec_path": runtime_spec_path(workspace, TEAM).to_string_lossy(),
539
+ "session_name": TEAM_SESSION,
540
+ "tmux_endpoint": TMUX_ENDPOINT,
541
+ "tmux_socket": TMUX_ENDPOINT,
542
+ "agents": Value::Object(agents),
543
+ }
544
+ }
545
+ }),
546
+ )
547
+ .expect("seed runtime state");
548
+ }
549
+
550
+ fn seed_healthy_coordinator(workspace: &Path) {
551
+ fs::create_dir_all(runtime_dir(workspace)).expect("create runtime dir");
552
+ let _store = MessageStore::open(workspace).expect("create message store schema");
553
+ let wp = WorkspacePath::new(workspace.to_path_buf());
554
+ let pid = Pid::new(std::process::id());
555
+ team_agent::coordinator::write_coordinator_metadata(&wp, pid, MetadataSource::Boot)
556
+ .expect("write coordinator metadata");
557
+ fs::write(
558
+ team_agent::coordinator::coordinator_pid_path(&wp),
559
+ pid.to_string(),
560
+ )
561
+ .expect("write coordinator pid");
562
+ }
563
+
564
+ fn worker_ids(workers: usize) -> Vec<String> {
565
+ (1..=workers).map(|i| format!("w{i}")).collect()
566
+ }
567
+
568
+ #[derive(Clone, Debug)]
569
+ struct SpawnCall {
570
+ kind: &'static str,
571
+ window: String,
572
+ start: Instant,
573
+ end: Instant,
574
+ }
575
+
576
+ #[derive(Default)]
577
+ struct TransportState {
578
+ session_present: bool,
579
+ calls: Vec<SpawnCall>,
580
+ panes: BTreeMap<String, PaneInfo>,
581
+ next_pane: usize,
582
+ }
583
+
584
+ #[derive(Clone)]
585
+ struct StartupLatencyTransport {
586
+ state: Arc<Mutex<TransportState>>,
587
+ spawn_delay: Duration,
588
+ fail_agent: Option<String>,
589
+ session_missing_for_readiness: bool,
590
+ }
591
+
592
+ impl StartupLatencyTransport {
593
+ fn new() -> Self {
594
+ Self {
595
+ state: Arc::new(Mutex::new(TransportState {
596
+ next_pane: 1,
597
+ ..TransportState::default()
598
+ })),
599
+ spawn_delay: Duration::ZERO,
600
+ fail_agent: None,
601
+ session_missing_for_readiness: false,
602
+ }
603
+ }
604
+
605
+ fn with_spawn_delay(mut self, delay: Duration) -> Self {
606
+ self.spawn_delay = delay;
607
+ self
608
+ }
609
+
610
+ fn with_spawn_failure(mut self, agent: &str, message: &str) -> Self {
611
+ self.fail_agent = Some(format!("{agent}:{message}"));
612
+ self
613
+ }
614
+
615
+ fn with_session_missing_for_readiness(mut self) -> Self {
616
+ self.session_missing_for_readiness = true;
617
+ self
618
+ }
619
+
620
+ fn spawn_calls(&self) -> Vec<SpawnCall> {
621
+ self.state.lock().unwrap().calls.clone()
622
+ }
623
+
624
+ fn pane_snapshot(&self) -> Vec<Value> {
625
+ self.state
626
+ .lock()
627
+ .unwrap()
628
+ .panes
629
+ .values()
630
+ .map(|pane| {
631
+ json!({
632
+ "pane_id": pane.pane_id.as_str(),
633
+ "session": pane.session.as_str(),
634
+ "window": pane.window_name.as_ref().map(WindowName::as_str),
635
+ })
636
+ })
637
+ .collect()
638
+ }
639
+
640
+ fn spawn(
641
+ &self,
642
+ kind: &'static str,
643
+ session: &SessionName,
644
+ window: &WindowName,
645
+ ) -> Result<SpawnResult, TransportError> {
646
+ let start = Instant::now();
647
+ if !self.spawn_delay.is_zero() {
648
+ std::thread::sleep(self.spawn_delay);
649
+ }
650
+ let end = Instant::now();
651
+ let mut state = self.state.lock().unwrap();
652
+ let pane_id = PaneId::new(format!("%{}", state.next_pane));
653
+ state.next_pane += 1;
654
+ state.calls.push(SpawnCall {
655
+ kind,
656
+ window: window.as_str().to_string(),
657
+ start,
658
+ end,
659
+ });
660
+ if let Some(failure) = self.fail_agent.as_deref() {
661
+ if failure.starts_with(&format!("{}:", window.as_str())) {
662
+ return Err(TransportError::Subprocess {
663
+ argv: vec!["tmux".to_string(), kind.to_string()],
664
+ code: Some(1),
665
+ stderr: failure.to_string(),
666
+ });
667
+ }
668
+ }
669
+ state.session_present = true;
670
+ let pane = PaneInfo {
671
+ pane_id: pane_id.clone(),
672
+ session: session.clone(),
673
+ window_index: None,
674
+ window_name: Some(window.clone()),
675
+ pane_index: None,
676
+ tty: None,
677
+ current_command: Some("team-agent-fake-worker".to_string()),
678
+ current_path: None,
679
+ active: true,
680
+ pane_pid: Some(10_000 + state.next_pane as u32),
681
+ leader_env: BTreeMap::new(),
682
+ };
683
+ state.panes.insert(pane_id.as_str().to_string(), pane);
684
+ Ok(SpawnResult {
685
+ pane_id,
686
+ session: session.clone(),
687
+ window: window.clone(),
688
+ child_pid: Some(10_000 + state.next_pane as u32),
689
+ })
690
+ }
691
+ }
692
+
693
+ impl Transport for StartupLatencyTransport {
694
+ fn kind(&self) -> BackendKind {
695
+ BackendKind::Tmux
696
+ }
697
+
698
+ fn tmux_endpoint(&self) -> Option<String> {
699
+ Some(TMUX_ENDPOINT.to_string())
700
+ }
701
+
702
+ fn spawn_first(
703
+ &self,
704
+ session: &SessionName,
705
+ window: &WindowName,
706
+ _argv: &[String],
707
+ _cwd: &Path,
708
+ _env: &BTreeMap<String, String>,
709
+ ) -> Result<SpawnResult, TransportError> {
710
+ self.spawn("spawn_first", session, window)
711
+ }
712
+
713
+ fn spawn_into(
714
+ &self,
715
+ session: &SessionName,
716
+ window: &WindowName,
717
+ _argv: &[String],
718
+ _cwd: &Path,
719
+ _env: &BTreeMap<String, String>,
720
+ ) -> Result<SpawnResult, TransportError> {
721
+ self.spawn("spawn_into", session, window)
722
+ }
723
+
724
+ fn inject(
725
+ &self,
726
+ _target: &Target,
727
+ _payload: &InjectPayload,
728
+ _submit: Key,
729
+ _bracketed: bool,
730
+ ) -> Result<InjectReport, TransportError> {
731
+ Ok(InjectReport {
732
+ stage_reached: InjectStage::Submit,
733
+ inject_verification: InjectVerification::NoToken,
734
+ submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
735
+ turn_verification: TurnVerification::NotRequired,
736
+ attempts: 1,
737
+ submit_diagnostics: None,
738
+ })
739
+ }
740
+
741
+ fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
742
+ Ok(())
743
+ }
744
+
745
+ fn capture(
746
+ &self,
747
+ _target: &Target,
748
+ range: CaptureRange,
749
+ ) -> Result<CapturedText, TransportError> {
750
+ Ok(CapturedText {
751
+ text: String::new(),
752
+ range,
753
+ })
754
+ }
755
+
756
+ fn query(&self, _target: &Target, _field: PaneField) -> Result<Option<String>, TransportError> {
757
+ Ok(None)
758
+ }
759
+
760
+ fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
761
+ if self.state.lock().unwrap().panes.contains_key(pane.as_str()) {
762
+ Ok(PaneLiveness::Live)
763
+ } else {
764
+ Ok(PaneLiveness::Dead)
765
+ }
766
+ }
767
+
768
+ fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
769
+ Ok(Some(
770
+ self.state.lock().unwrap().panes.contains_key(pane.as_str()),
771
+ ))
772
+ }
773
+
774
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
775
+ Ok(self.state.lock().unwrap().panes.values().cloned().collect())
776
+ }
777
+
778
+ fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
779
+ if self.session_missing_for_readiness {
780
+ return Ok(false);
781
+ }
782
+ Ok(self.state.lock().unwrap().session_present)
783
+ }
784
+
785
+ fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
786
+ let state = self.state.lock().unwrap();
787
+ if !state.session_present {
788
+ return Ok(Vec::new());
789
+ }
790
+ let mut windows = BTreeSet::new();
791
+ for pane in state.panes.values() {
792
+ if let Some(window) = pane.window_name.as_ref() {
793
+ windows.insert(window.clone());
794
+ }
795
+ }
796
+ Ok(windows.into_iter().collect())
797
+ }
798
+
799
+ fn set_session_env(
800
+ &self,
801
+ _session: &SessionName,
802
+ _key: &str,
803
+ _value: &str,
804
+ ) -> Result<SetEnvOutcome, TransportError> {
805
+ Ok(SetEnvOutcome::Applied)
806
+ }
807
+
808
+ fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
809
+ let mut state = self.state.lock().unwrap();
810
+ state.session_present = false;
811
+ state.panes.clear();
812
+ Ok(())
813
+ }
814
+
815
+ fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
816
+ if let Target::Pane(pane) = target {
817
+ self.state.lock().unwrap().panes.remove(pane.as_str());
818
+ }
819
+ Ok(())
820
+ }
821
+
822
+ fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
823
+ Ok(AttachOutcome::Attached)
824
+ }
825
+ }
826
+
827
+ fn has_overlap(calls: &[&SpawnCall]) -> bool {
828
+ calls.iter().enumerate().any(|(index, left)| {
829
+ calls
830
+ .iter()
831
+ .skip(index + 1)
832
+ .any(|right| left.start < right.end && right.start < left.end)
833
+ })
834
+ }
835
+
836
+ fn inject_wrong_outcome_index(state: &mut Value, left: &str, right: &str) {
837
+ swap_persisted_pane_ids(
838
+ state
839
+ .get_mut("agents")
840
+ .and_then(Value::as_object_mut)
841
+ .expect("wrong-index injection requires persisted agents"),
842
+ left,
843
+ right,
844
+ );
845
+ swap_persisted_pane_ids(
846
+ state
847
+ .pointer_mut(&format!("/teams/{TEAM}/agents"))
848
+ .and_then(Value::as_object_mut)
849
+ .expect("wrong-index injection requires team projection agents"),
850
+ left,
851
+ right,
852
+ );
853
+ }
854
+
855
+ fn swap_persisted_pane_ids(agents: &mut serde_json::Map<String, Value>, left: &str, right: &str) {
856
+ let left_pane = agents
857
+ .get(left)
858
+ .and_then(|agent| agent.get("pane_id"))
859
+ .cloned()
860
+ .expect("wrong-index injection left pane");
861
+ let right_pane = agents
862
+ .get(right)
863
+ .and_then(|agent| agent.get("pane_id"))
864
+ .cloned()
865
+ .expect("wrong-index injection right pane");
866
+ agents
867
+ .get_mut(left)
868
+ .and_then(Value::as_object_mut)
869
+ .expect("wrong-index injection left agent")
870
+ .insert("pane_id".to_string(), right_pane);
871
+ agents
872
+ .get_mut(right)
873
+ .and_then(Value::as_object_mut)
874
+ .expect("wrong-index injection right agent")
875
+ .insert("pane_id".to_string(), left_pane);
876
+ }
877
+
878
+ fn identity_tuple_violations(
879
+ state: &Value,
880
+ panes: &[Value],
881
+ expected_agents: &[String],
882
+ ) -> Vec<String> {
883
+ let mut violations = Vec::new();
884
+ let persisted_session = state
885
+ .get("session_name")
886
+ .and_then(Value::as_str)
887
+ .unwrap_or("<missing-session>");
888
+ let persisted_tuples = expected_agents
889
+ .iter()
890
+ .filter_map(|worker| {
891
+ let agent = state.pointer(&format!("/agents/{worker}"))?;
892
+ Some((
893
+ worker.clone(),
894
+ agent
895
+ .get("pane_id")
896
+ .and_then(Value::as_str)
897
+ .unwrap_or("<missing-pane>")
898
+ .to_string(),
899
+ persisted_session.to_string(),
900
+ ))
901
+ })
902
+ .collect::<BTreeSet<_>>();
903
+ let transport_tuples = panes
904
+ .iter()
905
+ .map(|pane| {
906
+ (
907
+ pane.get("window")
908
+ .and_then(Value::as_str)
909
+ .unwrap_or("<missing-window>")
910
+ .to_string(),
911
+ pane.get("pane_id")
912
+ .and_then(Value::as_str)
913
+ .unwrap_or("<missing-pane>")
914
+ .to_string(),
915
+ pane.get("session")
916
+ .and_then(Value::as_str)
917
+ .unwrap_or("<missing-session>")
918
+ .to_string(),
919
+ )
920
+ })
921
+ .collect::<BTreeSet<_>>();
922
+ let transport_windows = transport_tuples
923
+ .iter()
924
+ .map(|(window, _, _)| window.clone())
925
+ .collect::<BTreeSet<_>>();
926
+ let transport_pane_ids = transport_tuples
927
+ .iter()
928
+ .map(|(_, pane_id, _)| pane_id.clone())
929
+ .collect::<BTreeSet<_>>();
930
+ let expected_set = expected_agents.iter().cloned().collect::<BTreeSet<_>>();
931
+
932
+ if transport_windows != expected_set {
933
+ violations.push(format!(
934
+ "transport worker set incomplete or duplicated; expected={expected_set:?} got={transport_windows:?}"
935
+ ));
936
+ }
937
+ if transport_pane_ids.len() != expected_agents.len() {
938
+ violations.push(format!(
939
+ "transport pane_id values must be globally unique; expected={} got={} panes={transport_pane_ids:?}",
940
+ expected_agents.len(),
941
+ transport_pane_ids.len()
942
+ ));
943
+ }
944
+ if persisted_tuples != transport_tuples {
945
+ violations.push(format!(
946
+ "persisted↔transport identity tuple mismatch (worker/window, pane_id, session); persisted={persisted_tuples:?} transport={transport_tuples:?}"
947
+ ));
948
+ }
949
+ violations
950
+ }
951
+
952
+ fn assert_phase_events(events: &[Value], event_kind: &str, expected_phases: &[&str]) {
953
+ let phases = events
954
+ .iter()
955
+ .filter(|event| event_name(event) == Some(event_kind))
956
+ .collect::<Vec<_>>();
957
+ for phase in expected_phases {
958
+ assert!(
959
+ phases
960
+ .iter()
961
+ .any(|event| event.get("phase").and_then(Value::as_str) == Some(*phase)),
962
+ "{event_kind} missing phase `{phase}`; events={events:?}"
963
+ );
964
+ }
965
+ let mut last = 0;
966
+ for event in phases {
967
+ let elapsed = event
968
+ .get("elapsed_ms")
969
+ .and_then(Value::as_u64)
970
+ .unwrap_or_else(|| panic!("{event_kind} event missing numeric elapsed_ms: {event}"));
971
+ assert!(
972
+ elapsed >= last,
973
+ "{event_kind} elapsed_ms must be monotonic; previous={last} event={event}"
974
+ );
975
+ last = elapsed;
976
+ }
977
+ }
978
+
979
+ fn assert_worker_spawn_timing_events(events: &[Value], workers: &[String], source: &str) {
980
+ for worker in workers {
981
+ let event = events
982
+ .iter()
983
+ .find(|event| {
984
+ event_name(event) == Some("worker.spawn_timing")
985
+ && event.get("agent_id").and_then(Value::as_str) == Some(worker.as_str())
986
+ && event.get("source").and_then(Value::as_str) == Some(source)
987
+ })
988
+ .unwrap_or_else(|| {
989
+ panic!("worker.spawn_timing missing for {source}:{worker}; events={events:?}")
990
+ });
991
+ for field in [
992
+ "elapsed_ms",
993
+ "command_plan_ms",
994
+ "transport_spawn_ms",
995
+ "pane_verify_ms",
996
+ "startup_prompt_handler_ms",
997
+ ] {
998
+ assert!(
999
+ event.get(field).and_then(Value::as_u64).is_some(),
1000
+ "worker.spawn_timing {source}:{worker} missing numeric {field}; event={event}"
1001
+ );
1002
+ }
1003
+ assert!(
1004
+ event
1005
+ .get("tmux_start_mode")
1006
+ .and_then(Value::as_str)
1007
+ .is_some(),
1008
+ "worker.spawn_timing {source}:{worker} must carry tmux_start_mode; event={event}"
1009
+ );
1010
+ }
1011
+ }
1012
+
1013
+ fn assert_no_fake_ready_events(events: &[Value]) {
1014
+ let forbidden = events
1015
+ .iter()
1016
+ .filter_map(event_name)
1017
+ .filter(|name| {
1018
+ matches!(
1019
+ *name,
1020
+ "provider.ready" | "provider.prompt_ready" | "provider.fake_ready"
1021
+ )
1022
+ })
1023
+ .collect::<Vec<_>>();
1024
+ assert!(
1025
+ forbidden.is_empty(),
1026
+ "latency instrumentation must not invent provider ready/fake-ready events for recording transport; forbidden={forbidden:?} events={events:?}"
1027
+ );
1028
+ }
1029
+
1030
+ fn event_name(event: &Value) -> Option<&str> {
1031
+ event.get("event").and_then(Value::as_str)
1032
+ }