@team-agent/installer 0.2.10 → 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.
Files changed (326) hide show
  1. package/Cargo.lock +744 -0
  2. package/Cargo.toml +34 -0
  3. package/crates/team-agent/Cargo.toml +33 -0
  4. package/crates/team-agent/src/cli/adapters.rs +1343 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +554 -0
  6. package/crates/team-agent/src/cli/emit.rs +1077 -0
  7. package/crates/team-agent/src/cli/helpers.rs +88 -0
  8. package/crates/team-agent/src/cli/leader.rs +216 -0
  9. package/crates/team-agent/src/cli/mod.rs +1141 -0
  10. package/crates/team-agent/src/cli/profile.rs +306 -0
  11. package/crates/team-agent/src/cli/send.rs +215 -0
  12. package/crates/team-agent/src/cli/status.rs +179 -0
  13. package/crates/team-agent/src/cli/status_port.rs +502 -0
  14. package/crates/team-agent/src/cli/tests/base.rs +616 -0
  15. package/crates/team-agent/src/cli/tests/compile.rs +96 -0
  16. package/crates/team-agent/src/cli/tests/divergence.rs +509 -0
  17. package/crates/team-agent/src/cli/tests/lane_c.rs +333 -0
  18. package/crates/team-agent/src/cli/tests/leader_watch.rs +395 -0
  19. package/crates/team-agent/src/cli/tests/main_preserved.rs +675 -0
  20. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +390 -0
  21. package/crates/team-agent/src/cli/tests/mod.rs +97 -0
  22. package/crates/team-agent/src/cli/tests/peer_allow.rs +137 -0
  23. package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +302 -0
  24. package/crates/team-agent/src/cli/tests/run_delegation.rs +305 -0
  25. package/crates/team-agent/src/cli/tests/status_send.rs +385 -0
  26. package/crates/team-agent/src/cli/tests/verb_profile.rs +182 -0
  27. package/crates/team-agent/src/cli/tests/verb_settle.rs +236 -0
  28. package/crates/team-agent/src/cli/tests/verb_validate.rs +184 -0
  29. package/crates/team-agent/src/cli/types.rs +605 -0
  30. package/crates/team-agent/src/compiler/tests.rs +701 -0
  31. package/crates/team-agent/src/compiler.rs +489 -0
  32. package/crates/team-agent/src/coordinator/backoff.rs +153 -0
  33. package/crates/team-agent/src/coordinator/health.rs +436 -0
  34. package/crates/team-agent/src/coordinator/mod.rs +80 -0
  35. package/crates/team-agent/src/coordinator/orphan.rs +179 -0
  36. package/crates/team-agent/src/coordinator/tests/abnormal.rs +255 -0
  37. package/crates/team-agent/src/coordinator/tests/basics.rs +262 -0
  38. package/crates/team-agent/src/coordinator/tests/daemon.rs +323 -0
  39. package/crates/team-agent/src/coordinator/tests/health_sync.rs +263 -0
  40. package/crates/team-agent/src/coordinator/tests/main_preserved.rs +136 -0
  41. package/crates/team-agent/src/coordinator/tests/mod.rs +310 -0
  42. package/crates/team-agent/src/coordinator/tests/spine.rs +261 -0
  43. package/crates/team-agent/src/coordinator/tests/takeover.rs +227 -0
  44. package/crates/team-agent/src/coordinator/tests/tick_core.rs +256 -0
  45. package/crates/team-agent/src/coordinator/tests/watch.rs +167 -0
  46. package/crates/team-agent/src/coordinator/tick.rs +2032 -0
  47. package/crates/team-agent/src/coordinator/types.rs +584 -0
  48. package/crates/team-agent/src/db/migration.rs +716 -0
  49. package/crates/team-agent/src/db/mod.rs +23 -0
  50. package/crates/team-agent/src/db/schema.rs +378 -0
  51. package/crates/team-agent/src/event_log.rs +375 -0
  52. package/crates/team-agent/src/fake_worker.rs +253 -0
  53. package/crates/team-agent/src/leader/helpers.rs +190 -0
  54. package/crates/team-agent/src/leader/inject.rs +33 -0
  55. package/crates/team-agent/src/leader/lease.rs +1063 -0
  56. package/crates/team-agent/src/leader/mod.rs +99 -0
  57. package/crates/team-agent/src/leader/owner_bind.rs +292 -0
  58. package/crates/team-agent/src/leader/rediscover/tests.rs +525 -0
  59. package/crates/team-agent/src/leader/rediscover.rs +1099 -0
  60. package/crates/team-agent/src/leader/start.rs +273 -0
  61. package/crates/team-agent/src/leader/takeover.rs +235 -0
  62. package/crates/team-agent/src/leader/tests/basics.rs +183 -0
  63. package/crates/team-agent/src/leader/tests/byte_findings.rs +234 -0
  64. package/crates/team-agent/src/leader/tests/identity.rs +206 -0
  65. package/crates/team-agent/src/leader/tests/idle.rs +271 -0
  66. package/crates/team-agent/src/leader/tests/lease_api.rs +225 -0
  67. package/crates/team-agent/src/leader/tests/lease_claim.rs +253 -0
  68. package/crates/team-agent/src/leader/tests/mod.rs +125 -0
  69. package/crates/team-agent/src/leader/tests/rediscover.rs +351 -0
  70. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +204 -0
  71. package/crates/team-agent/src/leader/types.rs +487 -0
  72. package/crates/team-agent/src/lib.rs +85 -0
  73. package/crates/team-agent/src/lifecycle/display.rs +228 -0
  74. package/crates/team-agent/src/lifecycle/helpers.rs +112 -0
  75. package/crates/team-agent/src/lifecycle/launch/plan.rs +227 -0
  76. package/crates/team-agent/src/lifecycle/launch.rs +1833 -0
  77. package/crates/team-agent/src/lifecycle/mod.rs +62 -0
  78. package/crates/team-agent/src/lifecycle/restart/agent.rs +533 -0
  79. package/crates/team-agent/src/lifecycle/restart/common.rs +517 -0
  80. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +41 -0
  81. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +268 -0
  82. package/crates/team-agent/src/lifecycle/restart/remove.rs +780 -0
  83. package/crates/team-agent/src/lifecycle/restart/selection.rs +208 -0
  84. package/crates/team-agent/src/lifecycle/restart/team_state.rs +242 -0
  85. package/crates/team-agent/src/lifecycle/restart.rs +76 -0
  86. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +455 -0
  87. package/crates/team-agent/src/lifecycle/tests/core.rs +989 -0
  88. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +583 -0
  89. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +933 -0
  90. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +265 -0
  91. package/crates/team-agent/src/lifecycle/tests.rs +27 -0
  92. package/crates/team-agent/src/lifecycle/types.rs +685 -0
  93. package/crates/team-agent/src/main.rs +41 -0
  94. package/crates/team-agent/src/mcp_server/helpers.rs +228 -0
  95. package/crates/team-agent/src/mcp_server/mod.rs +183 -0
  96. package/crates/team-agent/src/mcp_server/normalize.rs +312 -0
  97. package/crates/team-agent/src/mcp_server/tests/golden.rs +283 -0
  98. package/crates/team-agent/src/mcp_server/tests/normalize.rs +244 -0
  99. package/crates/team-agent/src/mcp_server/tests/scoped.rs +189 -0
  100. package/crates/team-agent/src/mcp_server/tests/send.rs +222 -0
  101. package/crates/team-agent/src/mcp_server/tests/tools.rs +158 -0
  102. package/crates/team-agent/src/mcp_server/tests/wire.rs +159 -0
  103. package/crates/team-agent/src/mcp_server/tests.rs +38 -0
  104. package/crates/team-agent/src/mcp_server/tools.rs +603 -0
  105. package/crates/team-agent/src/mcp_server/types.rs +421 -0
  106. package/crates/team-agent/src/mcp_server/wire.rs +388 -0
  107. package/crates/team-agent/src/message_store.rs +767 -0
  108. package/crates/team-agent/src/messaging/activity.rs +433 -0
  109. package/crates/team-agent/src/messaging/delivery.rs +542 -0
  110. package/crates/team-agent/src/messaging/helpers.rs +209 -0
  111. package/crates/team-agent/src/messaging/leader_receiver.rs +340 -0
  112. package/crates/team-agent/src/messaging/mod.rs +147 -0
  113. package/crates/team-agent/src/messaging/peers.rs +32 -0
  114. package/crates/team-agent/src/messaging/results.rs +537 -0
  115. package/crates/team-agent/src/messaging/scheduler.rs +344 -0
  116. package/crates/team-agent/src/messaging/selftest.rs +100 -0
  117. package/crates/team-agent/src/messaging/send.rs +582 -0
  118. package/crates/team-agent/src/messaging/tests/basic.rs +357 -0
  119. package/crates/team-agent/src/messaging/tests/main_preserved.rs +122 -0
  120. package/crates/team-agent/src/messaging/tests/mod.rs +293 -0
  121. package/crates/team-agent/src/messaging/tests/runtime.rs +1422 -0
  122. package/crates/team-agent/src/messaging/tests/spine.rs +437 -0
  123. package/crates/team-agent/src/messaging/trust.rs +192 -0
  124. package/crates/team-agent/src/messaging/types.rs +355 -0
  125. package/crates/team-agent/src/messaging/watchers.rs +591 -0
  126. package/crates/team-agent/src/model/enums.rs +311 -0
  127. package/crates/team-agent/src/model/errors.rs +17 -0
  128. package/crates/team-agent/src/model/ids.rs +155 -0
  129. package/crates/team-agent/src/model/mod.rs +22 -0
  130. package/crates/team-agent/src/model/paths.rs +228 -0
  131. package/crates/team-agent/src/model/permissions.rs +567 -0
  132. package/crates/team-agent/src/model/routing.rs +340 -0
  133. package/crates/team-agent/src/model/spec.rs +680 -0
  134. package/crates/team-agent/src/model/task_graph.rs +380 -0
  135. package/crates/team-agent/src/model/testdata/fuzz.golden.yaml +43 -0
  136. package/crates/team-agent/src/model/testdata/fuzz.yaml +43 -0
  137. package/crates/team-agent/src/model/testdata/spec_invalid_a.yaml +207 -0
  138. package/crates/team-agent/src/model/testdata/team.spec.golden.yaml +206 -0
  139. package/crates/team-agent/src/model/testdata/team.spec.yaml +206 -0
  140. package/crates/team-agent/src/model/yaml/tests.rs +288 -0
  141. package/crates/team-agent/src/model/yaml.rs +800 -0
  142. package/crates/team-agent/src/packaging/install.rs +305 -0
  143. package/crates/team-agent/src/packaging/migrate.rs +30 -0
  144. package/crates/team-agent/src/packaging/mod.rs +82 -0
  145. package/crates/team-agent/src/packaging/repair.rs +24 -0
  146. package/crates/team-agent/src/packaging/tests.rs +829 -0
  147. package/crates/team-agent/src/packaging/types.rs +369 -0
  148. package/crates/team-agent/src/provider/adapter.rs +801 -0
  149. package/crates/team-agent/src/provider/approvals/mod.rs +2 -0
  150. package/crates/team-agent/src/provider/approvals/parsing.rs +452 -0
  151. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +163 -0
  152. package/crates/team-agent/src/provider/classify.rs +456 -0
  153. package/crates/team-agent/src/provider/faults.rs +136 -0
  154. package/crates/team-agent/src/provider/helpers.rs +41 -0
  155. package/crates/team-agent/src/provider/mod.rs +53 -0
  156. package/crates/team-agent/src/provider/startup_prompt.rs +423 -0
  157. package/crates/team-agent/src/provider/tests/adapter.rs +239 -0
  158. package/crates/team-agent/src/provider/tests/classify.rs +240 -0
  159. package/crates/team-agent/src/provider/tests/faults.rs +120 -0
  160. package/crates/team-agent/src/provider/tests/idle.rs +208 -0
  161. package/crates/team-agent/src/provider/tests/wire.rs +213 -0
  162. package/crates/team-agent/src/provider/tests.rs +31 -0
  163. package/crates/team-agent/src/provider/types.rs +424 -0
  164. package/crates/team-agent/src/state/identity.rs +656 -0
  165. package/crates/team-agent/src/state/mod.rs +58 -0
  166. package/crates/team-agent/src/state/owner_gate.rs +423 -0
  167. package/crates/team-agent/src/state/persist.rs +712 -0
  168. package/crates/team-agent/src/state/projection.rs +657 -0
  169. package/crates/team-agent/src/state/selector.rs +105 -0
  170. package/crates/team-agent/src/state/testdata/state-rich.canonical.json +133 -0
  171. package/crates/team-agent/src/tmux_backend/tests.rs +586 -0
  172. package/crates/team-agent/src/tmux_backend.rs +758 -0
  173. package/crates/team-agent/src/transport/test_support.rs +252 -0
  174. package/crates/team-agent/src/transport/tests/behavior.rs +327 -0
  175. package/crates/team-agent/src/transport/tests/mod.rs +199 -0
  176. package/crates/team-agent/src/transport/tests/wire.rs +527 -0
  177. package/crates/team-agent/src/transport.rs +774 -0
  178. package/npm/install.mjs +90 -106
  179. package/package.json +15 -13
  180. package/crates/team-agent-core/Cargo.toml +0 -12
  181. package/crates/team-agent-core/src/lib.rs +0 -332
  182. package/crates/team-agent-core/src/main.rs +0 -152
  183. package/pyproject.toml +0 -18
  184. package/scripts/install.py +0 -88
  185. package/scripts/run_regression_tests.py +0 -83
  186. package/src/team_agent/__init__.py +0 -3
  187. package/src/team_agent/__main__.py +0 -5
  188. package/src/team_agent/_legacy_pane_discovery.py +0 -186
  189. package/src/team_agent/abnormal_track.py +0 -253
  190. package/src/team_agent/approvals/__init__.py +0 -65
  191. package/src/team_agent/approvals/constants.py +0 -6
  192. package/src/team_agent/approvals/parsing.py +0 -176
  193. package/src/team_agent/approvals/runtime_prompts.py +0 -171
  194. package/src/team_agent/approvals/status.py +0 -176
  195. package/src/team_agent/cli/__init__.py +0 -137
  196. package/src/team_agent/cli/commands.py +0 -481
  197. package/src/team_agent/cli/e2e.py +0 -202
  198. package/src/team_agent/cli/helpers.py +0 -226
  199. package/src/team_agent/cli/parser.py +0 -540
  200. package/src/team_agent/compiler.py +0 -334
  201. package/src/team_agent/coordinator/__init__.py +0 -53
  202. package/src/team_agent/coordinator/__main__.py +0 -83
  203. package/src/team_agent/coordinator/lifecycle.py +0 -363
  204. package/src/team_agent/coordinator/metadata.py +0 -61
  205. package/src/team_agent/coordinator/paths.py +0 -17
  206. package/src/team_agent/diagnose/__init__.py +0 -48
  207. package/src/team_agent/diagnose/checks.py +0 -101
  208. package/src/team_agent/diagnose/comms.py +0 -213
  209. package/src/team_agent/diagnose/health.py +0 -241
  210. package/src/team_agent/diagnose/orphan_cleanup.py +0 -364
  211. package/src/team_agent/diagnose/preflight.py +0 -194
  212. package/src/team_agent/diagnose/quick_start.py +0 -324
  213. package/src/team_agent/display/__init__.py +0 -92
  214. package/src/team_agent/display/adaptive.py +0 -511
  215. package/src/team_agent/display/backend.py +0 -46
  216. package/src/team_agent/display/close.py +0 -154
  217. package/src/team_agent/display/ghostty.py +0 -77
  218. package/src/team_agent/display/rebuild.py +0 -102
  219. package/src/team_agent/display/tiling.py +0 -156
  220. package/src/team_agent/display/worker_window.py +0 -114
  221. package/src/team_agent/display/workspace.py +0 -382
  222. package/src/team_agent/errors.py +0 -10
  223. package/src/team_agent/events.py +0 -84
  224. package/src/team_agent/fake_worker.py +0 -80
  225. package/src/team_agent/idle_predicate.py +0 -200
  226. package/src/team_agent/idle_takeover.py +0 -59
  227. package/src/team_agent/idle_takeover_wiring.py +0 -111
  228. package/src/team_agent/launch/__init__.py +0 -41
  229. package/src/team_agent/launch/bootstrap.py +0 -85
  230. package/src/team_agent/launch/config.py +0 -106
  231. package/src/team_agent/launch/core.py +0 -301
  232. package/src/team_agent/launch/requirements.py +0 -57
  233. package/src/team_agent/leader/__init__.py +0 -926
  234. package/src/team_agent/leader_binding.py +0 -183
  235. package/src/team_agent/lifecycle/__init__.py +0 -5
  236. package/src/team_agent/lifecycle/agents.py +0 -278
  237. package/src/team_agent/lifecycle/operations.py +0 -411
  238. package/src/team_agent/lifecycle/paste_buffer_hygiene.py +0 -39
  239. package/src/team_agent/lifecycle/start.py +0 -363
  240. package/src/team_agent/mcp_server/__init__.py +0 -42
  241. package/src/team_agent/mcp_server/__main__.py +0 -7
  242. package/src/team_agent/mcp_server/contracts.py +0 -148
  243. package/src/team_agent/mcp_server/normalize.py +0 -257
  244. package/src/team_agent/mcp_server/server.py +0 -150
  245. package/src/team_agent/mcp_server/tools.py +0 -352
  246. package/src/team_agent/message_store/__init__.py +0 -23
  247. package/src/team_agent/message_store/agent_health.py +0 -113
  248. package/src/team_agent/message_store/core.py +0 -497
  249. package/src/team_agent/message_store/leader_notification_log.py +0 -198
  250. package/src/team_agent/message_store/result_watchers.py +0 -251
  251. package/src/team_agent/message_store/schema.py +0 -308
  252. package/src/team_agent/message_store/schema_migration.py +0 -448
  253. package/src/team_agent/messaging/__init__.py +0 -1
  254. package/src/team_agent/messaging/activity_detector.py +0 -254
  255. package/src/team_agent/messaging/delivery.py +0 -473
  256. package/src/team_agent/messaging/deps.py +0 -247
  257. package/src/team_agent/messaging/idle_alerts.py +0 -423
  258. package/src/team_agent/messaging/internal_delivery.py +0 -46
  259. package/src/team_agent/messaging/leader.py +0 -497
  260. package/src/team_agent/messaging/leader_api_errors.py +0 -216
  261. package/src/team_agent/messaging/leader_panes.py +0 -673
  262. package/src/team_agent/messaging/owner_bypass.py +0 -29
  263. package/src/team_agent/messaging/result_delivery.py +0 -539
  264. package/src/team_agent/messaging/results.py +0 -447
  265. package/src/team_agent/messaging/scheduler.py +0 -450
  266. package/src/team_agent/messaging/send.py +0 -532
  267. package/src/team_agent/messaging/session_drift.py +0 -94
  268. package/src/team_agent/messaging/tmux_io.py +0 -506
  269. package/src/team_agent/messaging/tmux_prompt.py +0 -338
  270. package/src/team_agent/messaging/trust_auto_answer.py +0 -52
  271. package/src/team_agent/orchestrator/__init__.py +0 -376
  272. package/src/team_agent/orchestrator/plan.py +0 -122
  273. package/src/team_agent/orchestrator/state.py +0 -128
  274. package/src/team_agent/paths.py +0 -45
  275. package/src/team_agent/permissions.py +0 -123
  276. package/src/team_agent/profiles/__init__.py +0 -82
  277. package/src/team_agent/profiles/constants.py +0 -19
  278. package/src/team_agent/profiles/core.py +0 -407
  279. package/src/team_agent/profiles/helpers.py +0 -69
  280. package/src/team_agent/profiles/provider_env.py +0 -188
  281. package/src/team_agent/profiles/smoke.py +0 -201
  282. package/src/team_agent/provider_cli/__init__.py +0 -43
  283. package/src/team_agent/provider_cli/adapter.py +0 -172
  284. package/src/team_agent/provider_cli/base.py +0 -48
  285. package/src/team_agent/provider_cli/claude.py +0 -457
  286. package/src/team_agent/provider_cli/codex.py +0 -336
  287. package/src/team_agent/provider_cli/copilot.py +0 -8
  288. package/src/team_agent/provider_cli/fake.py +0 -39
  289. package/src/team_agent/provider_cli/gemini.py +0 -95
  290. package/src/team_agent/provider_cli/opencode.py +0 -8
  291. package/src/team_agent/provider_cli/prompt.py +0 -62
  292. package/src/team_agent/provider_cli/registry.py +0 -18
  293. package/src/team_agent/provider_cli/unsupported.py +0 -32
  294. package/src/team_agent/provider_state/README.md +0 -78
  295. package/src/team_agent/provider_state/__init__.py +0 -86
  296. package/src/team_agent/provider_state/claude.py +0 -86
  297. package/src/team_agent/provider_state/codex.py +0 -84
  298. package/src/team_agent/provider_state/common.py +0 -207
  299. package/src/team_agent/provider_state/registry.py +0 -118
  300. package/src/team_agent/providers.py +0 -163
  301. package/src/team_agent/quality_gates.py +0 -104
  302. package/src/team_agent/restart/__init__.py +0 -34
  303. package/src/team_agent/restart/orchestration.py +0 -554
  304. package/src/team_agent/restart/selection.py +0 -89
  305. package/src/team_agent/restart/snapshot.py +0 -70
  306. package/src/team_agent/routing.py +0 -84
  307. package/src/team_agent/runtime.py +0 -1239
  308. package/src/team_agent/rust_core.py +0 -327
  309. package/src/team_agent/sessions/__init__.py +0 -25
  310. package/src/team_agent/sessions/capture.py +0 -143
  311. package/src/team_agent/sessions/inventory.py +0 -44
  312. package/src/team_agent/sessions/resume.py +0 -135
  313. package/src/team_agent/simple_yaml.py +0 -236
  314. package/src/team_agent/spec.py +0 -370
  315. package/src/team_agent/state.py +0 -602
  316. package/src/team_agent/status/__init__.py +0 -63
  317. package/src/team_agent/status/approvals.py +0 -52
  318. package/src/team_agent/status/compact.py +0 -158
  319. package/src/team_agent/status/constants.py +0 -18
  320. package/src/team_agent/status/inbox.py +0 -58
  321. package/src/team_agent/status/peek.py +0 -117
  322. package/src/team_agent/status/queries.py +0 -199
  323. package/src/team_agent/task_graph.py +0 -80
  324. package/src/team_agent/terminal.py +0 -57
  325. package/src/team_agent/wake.py +0 -58
  326. package/src/team_agent/watch/__init__.py +0 -145
@@ -0,0 +1,262 @@
1
+ use super::*;
2
+
3
+ // ═════════════════════════════════════════════════════════════════════════
4
+ // GROUP A — typed-enum / event byte-lock (committed model derive; GREEN baseline)
5
+ // 这些钉死 serde 契约,确保 events.jsonl 字节不漂移(§3/§22)。
6
+ // ═════════════════════════════════════════════════════════════════════════
7
+
8
+ #[test]
9
+ fn protocol_version_is_two() {
10
+ // metadata.py:13 COORDINATOR_PROTOCOL_VERSION = 2
11
+ assert_eq!(PROTOCOL_VERSION, 2);
12
+ }
13
+
14
+ #[test]
15
+ fn default_tick_interval_is_five_seconds() {
16
+ // __main__.py:101 DEFAULT_TICK_INTERVAL_SEC = 5.0 (Gap 36c)
17
+ assert_eq!(DEFAULT_TICK_INTERVAL_SEC, 5.0);
18
+ assert_eq!(BACKOFF_MAX_SEC, 60.0);
19
+ }
20
+
21
+ #[test]
22
+ fn rotation_marker_is_byte_exact() {
23
+ // watch.py:22 — byte-for-byte (note the U+2014 em dash).
24
+ assert_eq!(
25
+ ROTATION_MARKER,
26
+ "[watch] log rotated; archived segment events.jsonl.1 not replayed — historical replay deferred to a future --replay flag"
27
+ );
28
+ }
29
+
30
+ #[test]
31
+ fn status_enums_serialize_to_exact_python_strings() {
32
+ let cases: &[(String, &str)] = &[
33
+ (serde_json::to_string(&CoordinatorHealthStatus::Missing).unwrap(), "\"missing\""),
34
+ (serde_json::to_string(&CoordinatorHealthStatus::InvalidPid).unwrap(), "\"invalid_pid\""),
35
+ (serde_json::to_string(&CoordinatorHealthStatus::Running).unwrap(), "\"running\""),
36
+ (serde_json::to_string(&CoordinatorHealthStatus::Stale).unwrap(), "\"stale\""),
37
+ (serde_json::to_string(&StartOutcome::AlreadyRunning).unwrap(), "\"already_running\""),
38
+ (serde_json::to_string(&StartOutcome::RestartIncompatibleStopFailed).unwrap(), "\"restart_incompatible_stop_failed\""),
39
+ (serde_json::to_string(&StartOutcome::SchemaIncompatible).unwrap(), "\"schema_incompatible\""),
40
+ (serde_json::to_string(&StartOutcome::Started).unwrap(), "\"started\""),
41
+ (serde_json::to_string(&StopOutcome::Missing).unwrap(), "\"missing\""),
42
+ (serde_json::to_string(&StopOutcome::InvalidPidRemoved).unwrap(), "\"invalid_pid_removed\""),
43
+ (serde_json::to_string(&StopOutcome::KillFailed).unwrap(), "\"kill_failed\""),
44
+ (serde_json::to_string(&StopOutcome::Stopped).unwrap(), "\"stopped\""),
45
+ (serde_json::to_string(&TickStopReason::TmuxSessionMissing).unwrap(), "\"tmux_session_missing\""),
46
+ (serde_json::to_string(&TickStopReason::PersistenceDegraded).unwrap(), "\"persistence_degraded\""),
47
+ (serde_json::to_string(&MetadataSource::Boot).unwrap(), "\"boot\""),
48
+ (serde_json::to_string(&MetadataSource::Start).unwrap(), "\"start\""),
49
+ (serde_json::to_string(&AbnormalDecision::Skip).unwrap(), "\"skip\""),
50
+ (serde_json::to_string(&AbnormalDecision::NotifyBlacklist).unwrap(), "\"notify_blacklist\""),
51
+ (serde_json::to_string(&AbnormalDecision::NotifyDefault).unwrap(), "\"notify_default\""),
52
+ (serde_json::to_string(&WholeTeamGoneClass::Alive).unwrap(), "\"alive\""),
53
+ (serde_json::to_string(&WholeTeamGoneClass::CleanShutdown).unwrap(), "\"clean_shutdown\""),
54
+ (serde_json::to_string(&WholeTeamGoneClass::RestartInProgress).unwrap(), "\"restart_in_progress\""),
55
+ (serde_json::to_string(&WholeTeamGoneClass::UnexpectedExit).unwrap(), "\"unexpected_exit\""),
56
+ ];
57
+ for (got, want) in cases {
58
+ assert_eq!(got, want);
59
+ }
60
+ }
61
+
62
+ #[test]
63
+ fn coordinator_event_tags_are_byte_stable() {
64
+ // 逐一钉 events.jsonl tag(events.py 稳定契约)。
65
+ let pid = Pid(4242);
66
+ let cases: Vec<(CoordinatorEvent, &str)> = vec![
67
+ (CoordinatorEvent::Boot { workspace: "/w".into(), once: true }, "coordinator.boot"),
68
+ (CoordinatorEvent::Started { pid, log: "/l".into() }, "coordinator.started"),
69
+ (CoordinatorEvent::Stopped { pid }, "coordinator.stopped"),
70
+ (CoordinatorEvent::Exit { stop: true }, "coordinator.exit"),
71
+ (CoordinatorEvent::SessionMissing { session: "s".into() }, "coordinator.session_missing"),
72
+ (
73
+ CoordinatorEvent::OrphanSelfTerminate { initial_ppid: 9, current_ppid: 1, workspace: "/w".into() },
74
+ "coordinator.orphan_self_terminate",
75
+ ),
76
+ (
77
+ CoordinatorEvent::TickError { error: "boom".into(), exc_type: "OSError".into(), consecutive_failures: 1, next_sleep_sec: 5.0 },
78
+ "coordinator.tick_error",
79
+ ),
80
+ (
81
+ CoordinatorEvent::TickErrorSuppressed { consecutive_failures: 2, next_sleep_sec: 10.0 },
82
+ "coordinator.tick_error.suppressed",
83
+ ),
84
+ (CoordinatorEvent::TickRecovered { consecutive_failures: 3 }, "coordinator.tick_recovered"),
85
+ (
86
+ CoordinatorEvent::RestartIncompatible { pid: Some(pid), expected_protocol: 2, expected_schema: 3 },
87
+ "coordinator.restart_incompatible",
88
+ ),
89
+ (
90
+ CoordinatorEvent::RestartIncompatibleStopFailed { pid: Some(pid) },
91
+ "coordinator.restart_incompatible_stop_failed",
92
+ ),
93
+ (
94
+ CoordinatorEvent::SchemaIncompatible { table: Some("messages".into()), missing_columns: vec!["owner_team_id".into()] },
95
+ "coordinator.schema_incompatible",
96
+ ),
97
+ (
98
+ CoordinatorEvent::IdleTakeoverUnknownPersistent {
99
+ node_id: "w1".into(),
100
+ provider: Some(Provider::Codex),
101
+ auth_mode: None,
102
+ consecutive_ticks: 60,
103
+ rollout_path: None,
104
+ },
105
+ "idle_takeover.unknown_persistent",
106
+ ),
107
+ (
108
+ CoordinatorEvent::AbnormalNotify { signature: "sig".into(), turn_id: None, decision: AbnormalDecision::NotifyDefault },
109
+ "abnormal.notify",
110
+ ),
111
+ (
112
+ CoordinatorEvent::AbnormalWholeTeamGone { classification: WholeTeamGoneClass::UnexpectedExit },
113
+ "abnormal.whole_team_gone",
114
+ ),
115
+ (CoordinatorEvent::LeaderNotificationLogPruned { removed: 7 }, "leader_notification.log_pruned"),
116
+ (CoordinatorEvent::LeaderNotificationPruneFailed { error: "io".into() }, "leader_notification.prune_failed"),
117
+ (
118
+ CoordinatorEvent::RuntimeStateSaveFailed { phase: "tick_end".into(), error: "replace".into(), exc_type: "OSError".into() },
119
+ "runtime.state.save_failed",
120
+ ),
121
+ ];
122
+ for (evt, want_tag) in &cases {
123
+ let json = serde_json::to_value(evt).unwrap();
124
+ assert_eq!(json["event"], *want_tag, "tag mismatch for {want_tag}");
125
+ }
126
+ }
127
+
128
+ #[test]
129
+ fn tick_error_event_carries_exact_python_fields() {
130
+ // __main__.py:69-74 — error / exc_type / consecutive_failures / next_sleep_sec.
131
+ let evt = CoordinatorEvent::TickError {
132
+ error: "os.replace failed".into(),
133
+ exc_type: "OSError".into(),
134
+ consecutive_failures: 4,
135
+ next_sleep_sec: 40.0,
136
+ };
137
+ let json = serde_json::to_value(&evt).unwrap();
138
+ assert_eq!(json["event"], "coordinator.tick_error");
139
+ assert_eq!(json["error"], "os.replace failed");
140
+ assert_eq!(json["exc_type"], "OSError");
141
+ assert_eq!(json["consecutive_failures"], 4);
142
+ assert_eq!(json["next_sleep_sec"], 40.0);
143
+ }
144
+
145
+ #[test]
146
+ fn unknown_persistent_event_provider_none_is_null_not_missing() {
147
+ // lifecycle.py:407 rollout_path=node.get(...) — None 漏穿堵死(bug-085):
148
+ // provider/rollout_path 缺失序列化为 JSON null,绝不省略键。
149
+ let evt = CoordinatorEvent::IdleTakeoverUnknownPersistent {
150
+ node_id: "w7".into(),
151
+ provider: None,
152
+ auth_mode: None,
153
+ consecutive_ticks: 72,
154
+ rollout_path: None,
155
+ };
156
+ let json = serde_json::to_value(&evt).unwrap();
157
+ assert_eq!(json["event"], "idle_takeover.unknown_persistent");
158
+ assert_eq!(json["node_id"], "w7");
159
+ assert!(json.get("provider").is_some(), "provider key MUST be present (None→null)");
160
+ assert!(json["provider"].is_null(), "provider None → JSON null");
161
+ assert!(json.get("auth_mode").is_some(), "auth_mode key MUST be present");
162
+ assert!(json["auth_mode"].is_null(), "auth_mode None → JSON null");
163
+ assert!(json.get("rollout_path").is_some(), "rollout_path key MUST be present");
164
+ assert!(json["rollout_path"].is_null());
165
+ assert_eq!(json["consecutive_ticks"], 72);
166
+ }
167
+
168
+ #[test]
169
+ fn coordinator_metadata_json_field_names_are_stable() {
170
+ // metadata.py:50-57 — 稳定 coordinator.json 契约。
171
+ let m = meta(123, PROTOCOL_VERSION, GOLDEN_SCHEMA_VERSION);
172
+ let json = serde_json::to_value(&m).unwrap();
173
+ assert_eq!(json["pid"], 123);
174
+ assert_eq!(json["protocol_version"], 2);
175
+ assert_eq!(json["message_store_schema_version"], 3);
176
+ assert_eq!(json["source"], "boot");
177
+ assert_eq!(json["updated_at"], "2026-06-02T00:00:00+00:00");
178
+ }
179
+
180
+ // ═════════════════════════════════════════════════════════════════════════
181
+ // GROUP B — pure functions (backoff / orphan / metadata_ok) — RED via unimplemented!()
182
+ // ═════════════════════════════════════════════════════════════════════════
183
+
184
+ #[test]
185
+ fn backoff_sequence_is_5_10_20_40_60_60() {
186
+ // __main__.py:65 min(interval * 2^min(failures-1, 5), 60.0). interval=5.0.
187
+ // Golden (probe): [5, 10, 20, 40, 60, 60, 60, ...].
188
+ assert_eq!(backoff_sleep_sec(5.0, 1), 5.0);
189
+ assert_eq!(backoff_sleep_sec(5.0, 2), 10.0);
190
+ assert_eq!(backoff_sleep_sec(5.0, 3), 20.0);
191
+ assert_eq!(backoff_sleep_sec(5.0, 4), 40.0);
192
+ assert_eq!(backoff_sleep_sec(5.0, 5), 60.0);
193
+ assert_eq!(backoff_sleep_sec(5.0, 6), 60.0);
194
+ assert_eq!(backoff_sleep_sec(5.0, 9), 60.0);
195
+ }
196
+
197
+ #[test]
198
+ fn backoff_caps_at_60_even_with_large_interval() {
199
+ // min(.., 60.0) — interval 30 → 30,60,(cap)60...
200
+ assert_eq!(backoff_sleep_sec(30.0, 1), 30.0);
201
+ assert_eq!(backoff_sleep_sec(30.0, 2), 60.0);
202
+ assert_eq!(backoff_sleep_sec(30.0, 7), 60.0);
203
+ }
204
+
205
+ #[test]
206
+ fn orphan_self_terminate_requires_all_three_conditions() {
207
+ // __main__.py:52 — current_ppid != initial_ppid ∧ current_ppid == 1 ∧ !workspace.exists().
208
+ let missing = WorkspacePath::new("/tmp/team-agent-NONEXISTENT-coord-orphan-xyz");
209
+ // 全三成立 → true.
210
+ assert!(should_orphan_self_terminate(9000, 1, &missing));
211
+ // ppid 未变(== initial)→ false,即便 ppid==1 且 workspace 不存在。
212
+ assert!(!should_orphan_self_terminate(1, 1, &missing));
213
+ // ppid != 1(被正常 supervisor 收养)→ false。
214
+ assert!(!should_orphan_self_terminate(9000, 4242, &missing));
215
+ }
216
+
217
+ #[test]
218
+ fn orphan_self_terminate_false_when_workspace_exists() {
219
+ // workspace 仍在磁盘 → 绝不自杀,即便 ppid 变成 1(card §91)。
220
+ let alive = WorkspacePath::new("/tmp"); // /tmp 存在
221
+ assert!(!should_orphan_self_terminate(9000, 1, &alive));
222
+ }
223
+
224
+ #[test]
225
+ fn metadata_ok_requires_all_three_to_match() {
226
+ // metadata.py:37-43 — pid ∧ protocol_version==2 ∧ schema_version==3.
227
+ let good = meta(555, PROTOCOL_VERSION, GOLDEN_SCHEMA_VERSION);
228
+ assert!(coordinator_metadata_ok(Some(&good), Pid(555)));
229
+ // pid 不符 → false。
230
+ assert!(!coordinator_metadata_ok(Some(&good), Pid(999)));
231
+ // protocol_version 不符(bump 触发 restart_incompatible)→ false。
232
+ let bad_proto = meta(555, 1, GOLDEN_SCHEMA_VERSION);
233
+ assert!(!coordinator_metadata_ok(Some(&bad_proto), Pid(555)));
234
+ // schema_version 不符 → false(不可静默继续旧 schema 写库,card §89)。
235
+ let bad_schema = meta(555, PROTOCOL_VERSION, 2);
236
+ assert!(!coordinator_metadata_ok(Some(&bad_schema), Pid(555)));
237
+ }
238
+
239
+ #[test]
240
+ fn metadata_ok_none_is_false() {
241
+ // metadata.py:38 — bool(metadata and ...) — None → False.
242
+ assert!(!coordinator_metadata_ok(None, Pid(1)));
243
+ }
244
+
245
+ // ═════════════════════════════════════════════════════════════════════════
246
+ // GROUP C — paths (paths.py) — RED
247
+ // ═════════════════════════════════════════════════════════════════════════
248
+
249
+ #[test]
250
+ fn coordinator_paths_end_with_expected_filenames() {
251
+ // paths.py:8-17 — runtime_dir(ws)/coordinator.{pid,json,log}.
252
+ let w = ws();
253
+ assert!(coordinator_pid_path(&w).ends_with("coordinator.pid"));
254
+ assert!(coordinator_meta_path(&w).ends_with("coordinator.json"));
255
+ assert!(coordinator_log_path(&w).ends_with("coordinator.log"));
256
+ }
257
+
258
+ // NOTE: schema_health / health / start / stop / tick are `&self` methods on
259
+ // `Coordinator`. They are NO LONGER deferred: `Coordinator::for_test` (a #[cfg(test)]
260
+ // constructor) + the local `MockTransport` (all ~15 Transport methods stubbed, recording
261
+ // calls) + `MockRegistry` give a fully constructible Coordinator. Their behavior contracts
262
+ // are ASSERTED in GROUP I/J below (RED via the unimplemented production bodies).
@@ -0,0 +1,323 @@
1
+ use super::*;
2
+
3
+ // ═════════════════════════════════════════════════════════════════════════
4
+ // (B) coordinator daemon — health gate + idempotent start_coordinator + --once daemon boot.
5
+ // coordinator_health / start_coordinator are unimplemented!() skeletons (panic today = RED). Golden
6
+ // coordinator/lifecycle.py:28-121. HARD: no in-process test spawns a real daemon — the spawn /
7
+ // multi-tick / real-Coordinator paths are #[ignore] real-machine.
8
+ // ═════════════════════════════════════════════════════════════════════════
9
+
10
+ /// A unique workspace with the db schema created (so coordinator_health's schema_ok can be true) and
11
+ /// the runtime dir present (so coordinator.pid / coordinator.json writes land).
12
+ fn daemon_ws() -> (WorkspacePath, std::path::PathBuf) {
13
+ use std::sync::atomic::{AtomicU64, Ordering};
14
+ static N: AtomicU64 = AtomicU64::new(0);
15
+ let dir = std::env::temp_dir().join(format!("ta-rs-daemon-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
16
+ std::fs::create_dir_all(crate::model::paths::runtime_dir(&dir)).unwrap();
17
+ let _ = crate::message_store::MessageStore::open(&dir).unwrap(); // create the schema (schema_ok)
18
+ (WorkspacePath::new(dir.clone()), dir)
19
+ }
20
+
21
+ // coordinator_health (lifecycle.py:28-46): pid_path missing -> ok:false / status missing.
22
+ #[test]
23
+ fn coordinator_health_missing_pid_is_not_ok() {
24
+ let (wp, _dir) = daemon_ws();
25
+ let h = coordinator_health(&wp);
26
+ assert!(!h.ok, "no coordinator.pid -> not healthy");
27
+ assert_eq!(h.status, CoordinatorHealthStatus::Missing);
28
+ }
29
+
30
+ // coordinator_health: pid running (this process) + metadata pid/protocol/schema all match -> healthy.
31
+ #[test]
32
+ fn coordinator_health_running_with_matching_metadata_is_ok() {
33
+ let (wp, _dir) = daemon_ws();
34
+ let me = Pid(std::process::id());
35
+ write_coordinator_metadata(&wp, me, MetadataSource::Boot).unwrap();
36
+ std::fs::write(coordinator_pid_path(&wp), me.0.to_string()).unwrap();
37
+ let h = coordinator_health(&wp);
38
+ assert_eq!(h.status, CoordinatorHealthStatus::Running, "a live pid -> status running");
39
+ assert!(h.metadata_ok, "pid+protocol+schema all match -> metadata_ok");
40
+ assert!(h.ok, "running ∧ metadata_ok ∧ schema_ok -> healthy");
41
+ }
42
+
43
+ // coordinator_health: a pid that is NOT running -> ok:false / status stale (stale != missing).
44
+ #[test]
45
+ fn coordinator_health_dead_pid_is_stale_not_ok() {
46
+ let (wp, _dir) = daemon_ws();
47
+ let dead = Pid(4_000_000); // far above the macOS/Linux pid ceiling -> kill(pid,0)=ESRCH -> not running
48
+ write_coordinator_metadata(&wp, dead, MetadataSource::Boot).unwrap();
49
+ std::fs::write(coordinator_pid_path(&wp), dead.0.to_string()).unwrap();
50
+ let h = coordinator_health(&wp);
51
+ assert_eq!(h.status, CoordinatorHealthStatus::Stale, "a dead pid -> status stale");
52
+ assert!(!h.ok, "a stale daemon is not healthy");
53
+ }
54
+
55
+ // start_coordinator (lifecycle.py:49-54) IDEMPOTENT: already-healthy -> AlreadyRunning no-op, NO spawn.
56
+ #[test]
57
+ fn start_coordinator_when_healthy_is_already_running_no_spawn() {
58
+ let (wp, _dir) = daemon_ws();
59
+ let me = Pid(std::process::id());
60
+ write_coordinator_metadata(&wp, me, MetadataSource::Boot).unwrap();
61
+ std::fs::write(coordinator_pid_path(&wp), me.0.to_string()).unwrap();
62
+ let report = start_coordinator(&wp).expect("start_coordinator");
63
+ assert_eq!(report.status, StartOutcome::AlreadyRunning, "a healthy coordinator -> AlreadyRunning (no spawn)");
64
+ assert!(report.ok);
65
+ assert_eq!(report.pid, Some(me));
66
+ }
67
+
68
+ // start_coordinator: a fresh workspace DECIDES Started. The actual `team-agent coordinator` daemon
69
+ // subprocess spawn is the real-machine boundary (#[ignore]).
70
+ #[test]
71
+ #[ignore = "real-machine: start_coordinator spawns the `team-agent coordinator` daemon subprocess"]
72
+ fn start_coordinator_fresh_workspace_decides_started() {
73
+ let (wp, _dir) = daemon_ws();
74
+ let report = start_coordinator(&wp).expect("start_coordinator");
75
+ assert_eq!(report.status, StartOutcome::Started);
76
+ assert!(report.ok && report.pid.is_some());
77
+ }
78
+
79
+ // run_daemon --once: writes the boot pid/metadata + runs exactly one tick + returns Ok. run_daemon
80
+ // constructs a real Coordinator (TmuxBackend) internally with NO injection seam, so a single tick
81
+ // would touch real tmux — #[ignore] real-machine until a run_daemon_with_coordinator(args, coord) seam
82
+ // (mirroring lifecycle::launch_with_transport) exists. SURFACED to the leader.
83
+ #[test]
84
+ #[ignore = "real-machine: run_daemon builds a real Coordinator (TmuxBackend); needs a \
85
+ run_daemon_with_coordinator(args, coord) seam (mirror launch_with_transport) for OS-safe \
86
+ single-tick testing"]
87
+ fn run_daemon_once_writes_boot_metadata_and_returns_ok() {
88
+ let (wp, dir) = daemon_ws();
89
+ crate::state::persist::save_runtime_state(&dir, &serde_json::json!({"session_name": "team-x", "agents": {}})).unwrap();
90
+ let r = run_daemon(DaemonArgs { workspace: wp.clone(), once: true, tick_interval_sec: None });
91
+ assert!(r.is_ok(), "run_daemon --once must return Ok; got {r:?}");
92
+ assert!(coordinator_meta_path(&wp).exists(), "run_daemon must write the coordinator boot metadata");
93
+ }
94
+
95
+ // ═════════════════════════════════════════════════════════════════════════
96
+ // HOST-B P1 — coordinator transient-session race (timeout-tolerated vs definitive-stop fork).
97
+ //
98
+ // GOLDEN (truth source, settle by it):
99
+ // - terminal.py:12-13 run_cmd(args, timeout=timeout, check=False)
100
+ // - runtime.py:1010-14 _tmux_session_exists -> run_cmd(["tmux","has-session","-t",s], timeout=5);
101
+ // return proc.returncode == 0
102
+ // - lifecycle.py:276-9 if session_name and not _tmux_session_exists(name):
103
+ // emit coordinator.session_missing; return {ok:False, stop:True,
104
+ // reason:"tmux_session_missing"} # stops on the FIRST definitive miss
105
+ // - __main__.py:60-97 a tick that RAISES (`except Exception`) -> exponential backoff + retry +
106
+ // (on the next clean tick) coordinator.tick_recovered [TOLERATED];
107
+ // a tick that returns stop -> break (then coordinator.exit).
108
+ //
109
+ // THE CRUX: golden's ONLY tolerance for a transient session-missing is the 5s subprocess timeout.
110
+ // - SLOW/HUNG has-session (>5s) -> subprocess.TimeoutExpired -> daemon `except` -> backoff + retry
111
+ // (server recovers -> next tick fine). In Rust the timeout surfaces from RealCommandRunner as
112
+ // io::ErrorKind::TimedOut -> TmuxBackend::has_session maps it to a TransportError (NOT Ok(false))
113
+ // -> Coordinator::tick() returns Err (a TOLERATED error the daemon backs off on).
114
+ // - FAST DEFINITIVE non-zero (session genuinely gone) -> returncode != 0 -> has_session=false ->
115
+ // tick() returns Ok{stop:true, reason:tmux_session_missing}. A genuine miss MUST still stop.
116
+ // NO grace-window, NO K-consecutive counting: slow=tolerated(retry), genuine-fast-miss=stop.
117
+ //
118
+ // These three are REGRESSION-LOCKS, not REDs: the tick/backend mapping (`?` propagation) and the
119
+ // daemon backoff/recover loop are ALREADY correct today. The actual gap is purely in
120
+ // RealCommandRunner::run lacking the 5s timeout (the #[ignore] real-machine RED lives in
121
+ // tmux_backend.rs::real_command_runner_enforces_golden_5s_timeout_on_hang). These locks guard the
122
+ // tick/daemon semantics from regressing when the porter adds that timeout seam.
123
+ // ═════════════════════════════════════════════════════════════════════════
124
+
125
+ /// A staged tmux `CommandRunner` for the transient-session-race fork. Each `run` pops the next
126
+ /// staged step (then repeats `last`): `Timeout` models a >5s hung has-session that the golden 5s
127
+ /// subprocess timeout converts into `io::ErrorKind::TimedOut`; `Exit(success)` models a definitive
128
+ /// tmux exit (`success=false` => session genuinely gone). Records every argv it is asked to run so a
129
+ /// test can assert the probe was exactly `tmux has-session -t <s>`.
130
+ #[derive(Clone)]
131
+ enum RunnerStep {
132
+ /// >5s hang -> RealCommandRunner returns Err(TimedOut) (golden subprocess.TimeoutExpired).
133
+ Timeout,
134
+ /// fast definitive tmux exit; `false` => returncode!=0 => session genuinely gone.
135
+ Exit(bool),
136
+ }
137
+
138
+ struct StagedTmuxRunner {
139
+ steps: std::sync::Mutex<std::collections::VecDeque<RunnerStep>>,
140
+ last: RunnerStep,
141
+ seen: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
142
+ }
143
+
144
+ impl crate::tmux_backend::CommandRunner for StagedTmuxRunner {
145
+ fn run(&self, argv: &[String]) -> Result<crate::tmux_backend::CommandOutput, std::io::Error> {
146
+ self.seen.lock().unwrap().push(argv.to_vec());
147
+ let step = self
148
+ .steps
149
+ .lock()
150
+ .unwrap()
151
+ .pop_front()
152
+ .unwrap_or_else(|| self.last.clone());
153
+ match step {
154
+ RunnerStep::Timeout => Err(std::io::Error::new(
155
+ std::io::ErrorKind::TimedOut,
156
+ "tmux has-session exceeded the golden 5s timeout (subprocess.TimeoutExpired analog)",
157
+ )),
158
+ RunnerStep::Exit(success) => Ok(crate::tmux_backend::CommandOutput {
159
+ success,
160
+ code: Some(if success { 0 } else { 1 }),
161
+ stdout: String::new(),
162
+ stderr: if success { String::new() } else { "can't find session".to_string() },
163
+ }),
164
+ }
165
+ }
166
+ }
167
+
168
+ /// Build a real `Coordinator` over a real `TmuxBackend` whose OS edge is the staged runner above,
169
+ /// seeding a TRUTHY `session_name` so the tmux-session gate actually runs. Returns
170
+ /// `(coord, workspace_dir, recorded_argv)`. The workspace + schema mirror `daemon_ws`.
171
+ fn coord_over_staged_tmux(
172
+ session_name: &str,
173
+ steps: Vec<RunnerStep>,
174
+ last: RunnerStep,
175
+ ) -> (
176
+ Coordinator,
177
+ std::path::PathBuf,
178
+ std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
179
+ ) {
180
+ use std::sync::atomic::{AtomicU64, Ordering};
181
+ static N: AtomicU64 = AtomicU64::new(0);
182
+ let dir = std::env::temp_dir().join(format!(
183
+ "ta-rs-coord-session-race-{}-{}",
184
+ std::process::id(),
185
+ N.fetch_add(1, Ordering::Relaxed)
186
+ ));
187
+ std::fs::create_dir_all(crate::model::paths::runtime_dir(&dir)).unwrap();
188
+ let _ = crate::message_store::MessageStore::open(&dir).unwrap(); // create the schema
189
+ crate::state::persist::save_runtime_state(
190
+ &dir,
191
+ &serde_json::json!({ "session_name": session_name }),
192
+ )
193
+ .unwrap();
194
+ let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
195
+ let runner = StagedTmuxRunner {
196
+ steps: std::sync::Mutex::new(steps.into_iter().collect()),
197
+ last,
198
+ seen: std::sync::Arc::clone(&seen),
199
+ };
200
+ let backend = crate::tmux_backend::TmuxBackend::with_runner(Box::new(runner));
201
+ let reg: Box<dyn ProviderRegistry> = Box::new(MockRegistry::new(&[], &[]));
202
+ let coord = Coordinator::for_test(
203
+ WorkspacePath::new(dir.clone()),
204
+ reg,
205
+ Box::new(backend),
206
+ None,
207
+ None,
208
+ );
209
+ (coord, dir, seen)
210
+ }
211
+
212
+ // ── 2(a) tick TOLERATES a has-session TIMEOUT as Err (NOT a definitive miss) — LOCK ───────────────
213
+ #[test]
214
+ fn tick_tolerates_has_session_timeout_as_transport_err_not_session_missing() {
215
+ // A has-session that times out (>5s) surfaces as io::ErrorKind::TimedOut from RealCommandRunner.
216
+ // TmuxBackend::has_session maps the runner io::Error to a TransportError (NOT Ok(false)), and
217
+ // Coordinator::tick() propagates it via `?` as TickError::Transport — a TOLERATED error the
218
+ // daemon backs off on. It must NEVER be read as Ok{stop:true, reason:tmux_session_missing}.
219
+ // LOCK (already GREEN via `?` propagation): guards this from regressing when the 5s timeout seam
220
+ // is added to RealCommandRunner.
221
+ let (coord, _dir, seen) =
222
+ coord_over_staged_tmux("team-spine", vec![RunnerStep::Timeout], RunnerStep::Timeout);
223
+ let err = coord.tick().expect_err(
224
+ "a has-session TIMEOUT is a tolerated transport Err (daemon backs off), NOT a definitive \
225
+ session-missing stop",
226
+ );
227
+ assert!(
228
+ matches!(err, TickError::Transport(_)),
229
+ "a transient has-session timeout must surface as TickError::Transport (tolerated/backoff); got {err:?}"
230
+ );
231
+ let calls = seen.lock().unwrap().clone();
232
+ assert_eq!(
233
+ calls.len(),
234
+ 1,
235
+ "tick must short-circuit at the gate on a has-session error (exactly one probe); got {calls:?}"
236
+ );
237
+ assert_eq!(
238
+ calls[0],
239
+ vec![
240
+ "tmux".to_string(),
241
+ "has-session".to_string(),
242
+ "-t".to_string(),
243
+ "team-spine".to_string(),
244
+ ],
245
+ "the tolerated error must come from the golden `tmux has-session -t <s>` probe"
246
+ );
247
+ }
248
+
249
+ // ── 2(b) a FAST DEFINITIVE has-session miss STILL stops — LOCK (byte-parity) ──────────────────────
250
+ #[test]
251
+ fn tick_genuine_fast_session_miss_still_stops() {
252
+ // lifecycle.py:277-279 — a FAST definitive non-zero has-session (returncode != 0 => session
253
+ // genuinely gone) => {ok:false, stop:true, reason:tmux_session_missing}. The OTHER side of the
254
+ // fork from the timeout case: a definitive miss MUST still stop the daemon. LOCK (already GREEN):
255
+ // guards the genuine-miss stop from being swallowed when the timeout tolerance is added.
256
+ let (coord, _dir, _seen) =
257
+ coord_over_staged_tmux("team-spine", vec![RunnerStep::Exit(false)], RunnerStep::Exit(false));
258
+ let report = coord
259
+ .tick()
260
+ .expect("a definitive miss is a typed stop report, not an Err");
261
+ assert!(!report.ok, "a definitive session miss => ok=false");
262
+ assert!(
263
+ report.stop,
264
+ "a FAST definitive has-session miss still stops the daemon (byte-parity, lifecycle.py:279)"
265
+ );
266
+ assert_eq!(
267
+ report.reason,
268
+ Some(TickStopReason::TmuxSessionMissing),
269
+ "reason=tmux_session_missing"
270
+ );
271
+ }
272
+
273
+ // ── 3. daemon TOLERATES a transient tick Err: backoff + recover, NO exit on the error — LOCK ──────
274
+ #[test]
275
+ fn run_daemon_backs_off_on_transient_tick_err_then_recovers_without_exiting() {
276
+ // __main__.py:60-97 — a tick that RAISES is caught, logged as coordinator.tick_error, and the
277
+ // loop BACKS OFF + retries (it does NOT break/exit on the error); the next clean tick logs
278
+ // coordinator.tick_recovered. Here: tick #1's has-session TIMES OUT (TimedOut -> tick Err ->
279
+ // tolerated), tick #2's has-session is a definitive miss (-> Ok{stop:true} -> the loop breaks on
280
+ // the GENUINE stop, after recovering). The healthy daemon must NOT be torn down by the transient
281
+ // timeout. LOCK (the daemon backoff loop is already wired): guards the tolerate+recover path.
282
+ let (coord, dir, _seen) = coord_over_staged_tmux(
283
+ "team-spine",
284
+ vec![RunnerStep::Timeout, RunnerStep::Exit(false)],
285
+ RunnerStep::Exit(false),
286
+ );
287
+ let args = DaemonArgs {
288
+ workspace: WorkspacePath::new(dir.clone()),
289
+ once: false,
290
+ tick_interval_sec: Some(0.01), // tiny backoff so the test is fast
291
+ };
292
+ let result = run_daemon_with_coordinator(&args, &coord);
293
+ assert!(
294
+ result.is_ok(),
295
+ "a single transient has-session timeout must NOT abort the daemon; got {result:?}"
296
+ );
297
+ let events = read_event_log_dir(&dir);
298
+ let tags: Vec<&str> = events
299
+ .iter()
300
+ .filter_map(|e| e.get("event").and_then(|v| v.as_str()))
301
+ .collect();
302
+ let err_idx = tags
303
+ .iter()
304
+ .position(|t| *t == "coordinator.tick_error")
305
+ .unwrap_or_else(|| panic!("a transient tick Err must log coordinator.tick_error; got {tags:?}"));
306
+ let rec_idx = tags
307
+ .iter()
308
+ .position(|t| *t == "coordinator.tick_recovered")
309
+ .unwrap_or_else(|| panic!("the recovering Ok tick must log coordinator.tick_recovered; got {tags:?}"));
310
+ let exit_idx = tags
311
+ .iter()
312
+ .position(|t| *t == "coordinator.exit")
313
+ .unwrap_or_else(|| panic!("the daemon must log coordinator.exit once it stops; got {tags:?}"));
314
+ assert!(
315
+ err_idx < rec_idx,
316
+ "tick_recovered must FOLLOW tick_error (backoff then recover); got {tags:?}"
317
+ );
318
+ assert!(
319
+ rec_idx < exit_idx,
320
+ "the daemon must NOT exit on the transient error — coordinator.exit appears only AFTER \
321
+ recovery + the genuine stop; got {tags:?}"
322
+ );
323
+ }