@team-agent/installer 0.2.11 → 0.3.1

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 +1204 -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 +1207 -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 +557 -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 +1084 -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 +526 -0
  59. package/crates/team-agent/src/leader/rediscover.rs +1101 -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 +237 -0
  64. package/crates/team-agent/src/leader/tests/identity.rs +206 -0
  65. package/crates/team-agent/src/leader/tests/idle.rs +272 -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 +410 -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 +489 -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 +2109 -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 +985 -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 +710 -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 +187 -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 +468 -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 +743 -0
  110. package/crates/team-agent/src/messaging/helpers.rs +209 -0
  111. package/crates/team-agent/src/messaging/leader_receiver.rs +329 -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 +553 -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 +578 -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 +659 -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 +765 -0
  172. package/crates/team-agent/src/tmux_backend.rs +810 -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 +118 -112
  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 -119
  203. package/src/team_agent/coordinator/lifecycle.py +0 -411
  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 -218
  226. package/src/team_agent/idle_takeover.py +0 -59
  227. package/src/team_agent/idle_takeover_wiring.py +0 -114
  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 -262
  255. package/src/team_agent/messaging/delivery.py +0 -504
  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 -503
  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 -91
  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 -1243
  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 -144
  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 -693
  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,1422 @@
1
+ use super::*;
2
+
3
+ // ════════════════════════════════════════════════════════════════════════
4
+ // GROUP E — _fail_leader_delivery: bug-52 fallback-log semantics. ok=True but
5
+ // status=FallbackLog (NOT a real submit). leader.py:394-436.
6
+ // ════════════════════════════════════════════════════════════════════════
7
+
8
+ #[test]
9
+ fn fail_leader_delivery_returns_fallback_log_ok_true_not_submitted() {
10
+ let ws = tmp_ws("faillead");
11
+ let payload = json(serde_json::json!({
12
+ "to": "leader", "content": "hi", "sender": "coordinator"
13
+ }));
14
+ let out = fail_leader_delivery(
15
+ &ws,
16
+ &payload,
17
+ DeliveryRefusal::LeaderNotAttached,
18
+ Some("No direct leader tmux pane is attached. Run team-agent attach-leader."),
19
+ )
20
+ .unwrap();
21
+ // leader.py:423-431 — ok True, status fallback_log, channel fallback_inbox.
22
+ assert!(out.ok);
23
+ assert_eq!(out.status, DeliveryStatus::FallbackLog);
24
+ assert_eq!(out.reason, Some(DeliveryRefusal::LeaderNotAttached));
25
+ // The audit must be distinguishable from a real submit (Delivered).
26
+ assert_ne!(out.status, DeliveryStatus::Delivered);
27
+ }
28
+
29
+ // ════════════════════════════════════════════════════════════════════════
30
+ // GROUP F — session_drift_refusal: None-vs-refused fallthrough chain.
31
+ // session_drift.py:69-91.
32
+ // ════════════════════════════════════════════════════════════════════════
33
+
34
+ #[test]
35
+ fn session_drift_refusal_none_for_no_target_leader_or_broadcast() {
36
+ let ws = tmp_ws("driftnone");
37
+ let log = EventLog::new(&ws);
38
+ let state = json(serde_json::json!({"agents": {}}));
39
+ // target == leader_id → None (no refusal).
40
+ assert!(
41
+ session_drift_refusal(&state, "leader", "leader", "s", None, &log)
42
+ .unwrap()
43
+ .is_none()
44
+ );
45
+ // target == "*" (broadcast) → None.
46
+ assert!(session_drift_refusal(&state, "*", "leader", "s", None, &log)
47
+ .unwrap()
48
+ .is_none());
49
+ }
50
+
51
+ #[test]
52
+ fn session_drift_refusal_none_when_status_not_drift() {
53
+ let ws = tmp_ws("driftok");
54
+ let log = EventLog::new(&ws);
55
+ let state = json(serde_json::json!({"agents": {"w1": {"status": "idle"}}}));
56
+ assert!(session_drift_refusal(&state, "w1", "leader", "s", None, &log)
57
+ .unwrap()
58
+ .is_none());
59
+ }
60
+
61
+ #[test]
62
+ fn session_drift_refusal_refuses_when_agent_in_drift() {
63
+ // session_drift.py:84-91 → ok False, reason session_drift, action reset-agent.
64
+ let ws = tmp_ws("driftrefuse");
65
+ let log = EventLog::new(&ws);
66
+ let state = json(serde_json::json!({
67
+ "agents": {"w1": {"status": "session_drift",
68
+ "session_drift": {"stored_session_id": "S", "actual_thread_id": "A"}}}
69
+ }));
70
+ let out = session_drift_refusal(&state, "w1", "leader", "leader", None, &log)
71
+ .unwrap()
72
+ .expect("drift agent must be refused");
73
+ assert!(!out.ok);
74
+ assert_eq!(out.status, DeliveryStatus::Refused);
75
+ assert_eq!(out.reason, Some(DeliveryRefusal::SessionDrift));
76
+ }
77
+
78
+ // ════════════════════════════════════════════════════════════════════════
79
+ // GROUP G — classify_agent_activity: every branch incl. the uncertain
80
+ // fallthrough iron law. activity_detector.py:90-146 (golden probed).
81
+ // ════════════════════════════════════════════════════════════════════════
82
+
83
+ #[test]
84
+ fn classify_pane_in_mode_is_uncertain_high_confidence() {
85
+ let state = json(serde_json::json!({}));
86
+ let a = classify_agent_activity(&state, "", true, None, None);
87
+ assert_eq!(a.status, ActivityStatus::Uncertain);
88
+ assert_eq!(a.confidence, 0.9);
89
+ }
90
+
91
+ #[test]
92
+ fn classify_idle_prompt_is_idle() {
93
+ // "❯ \n" matches the Claude idle prompt → idle 0.9.
94
+ let state = json(serde_json::json!({}));
95
+ let a = classify_agent_activity(&state, "❯ \n", false, None, None);
96
+ assert_eq!(a.status, ActivityStatus::Idle);
97
+ assert_eq!(a.confidence, 0.9);
98
+ }
99
+
100
+ #[test]
101
+ fn classify_working_indicator_is_working() {
102
+ let state = json(serde_json::json!({}));
103
+ let a = classify_agent_activity(&state, "Working (5s)", false, None, None);
104
+ assert_eq!(a.status, ActivityStatus::Working);
105
+ assert_eq!(a.confidence, 0.9);
106
+ }
107
+
108
+ #[test]
109
+ fn classify_stale_working_is_stuck() {
110
+ // elapsed >= stuck_timeout (300) → stuck 0.85.
111
+ let state = json(serde_json::json!({}));
112
+ let a = classify_agent_activity(&state, "Working (400s)", false, None, None);
113
+ assert_eq!(a.status, ActivityStatus::Stuck);
114
+ assert_eq!(a.confidence, 0.85);
115
+ }
116
+
117
+ #[test]
118
+ fn classify_no_signal_is_uncertain_never_idle() {
119
+ // THE IRON LAW: no decisive prompt/working signal → uncertain 0.5, NOT idle.
120
+ let state = json(serde_json::json!({}));
121
+ let a = classify_agent_activity(&state, "random prose nothing", false, None, None);
122
+ assert_eq!(a.status, ActivityStatus::Uncertain);
123
+ assert_eq!(a.confidence, 0.5);
124
+ assert_ne!(a.status, ActivityStatus::Idle);
125
+ }
126
+
127
+ #[test]
128
+ fn classify_recent_provider_output_is_working_low_confidence() {
129
+ // age <= 120 with provider/no command → working 0.7.
130
+ let state = json(serde_json::json!({}));
131
+ let now = chrono::Utc::now();
132
+ let recent = (now - chrono::Duration::seconds(30)).to_rfc3339();
133
+ let a = classify_agent_activity(&state, "prose", false, None, Some(&recent));
134
+ assert_eq!(a.status, ActivityStatus::Working);
135
+ assert_eq!(a.confidence, 0.7);
136
+ }
137
+
138
+ // STAGE-B REGRESSION RED (dispatch-to-just-launched-agent → deferred_busy never closes the round-trip).
139
+ // golden activity_detector.py (classify_agent_activity): the provider IDLE PROMPT is checked FIRST as a
140
+ // scrollback-position signal (C14, "provider idle prompt is the latest scrollback signal" → idle 0.9),
141
+ // BEFORE the `age<=120 → working 0.7` recent-output branch (:56). Rust (activity.rs:192) fires
142
+ // `recent_rfc3339(last_output_at,120) → Working` BEFORE `latest_prompt_signal` (:200), so a just-launched
143
+ // agent (startup banner = recent output, but pane shows the idle prompt awaiting input) mis-classifies
144
+ // WORKING → sync_agent_health writes agent_health=WORKING → recipient_is_busy → send.deferred_busy.
145
+ // Golden evidence (probe): classify(idle-prompt scrollback, recent last_output_at) = idle 0.9 regardless
146
+ // of active_task. FIX = reorder: latest_prompt_signal (idle/working scrollback-position) BEFORE the
147
+ // last_output_at age block.
148
+ #[test]
149
+ fn classify_idle_prompt_beats_recent_output_for_just_launched_agent() {
150
+ let state = json(serde_json::json!({}));
151
+ let recent = chrono::Utc::now().to_rfc3339();
152
+ let a = classify_agent_activity(&state, "codex ready\n❯ \n", false, Some("codex"), Some(&recent));
153
+ assert_eq!(
154
+ a.status,
155
+ ActivityStatus::Idle,
156
+ "just-launched agent showing the idle prompt must classify IDLE (golden idle-prompt-position is the \
157
+ latest scrollback signal, checked before the age<=120 recent-output branch), not WORKING because \
158
+ the startup banner is recent (activity.rs:192 recent-output fires before latest_prompt_signal:200) \
159
+ — the Stage B dispatch deferred_busy regression. got {a:?}"
160
+ );
161
+ assert_eq!(a.confidence, 0.9, "golden idle-prompt confidence is 0.9; got {a:?}");
162
+ }
163
+
164
+ // ════════════════════════════════════════════════════════════════════════
165
+ // GROUP H — attempt_trust_auto_answer: own-vs-foreign realpath + fail-safe
166
+ // pane-width + opt-in gate + reason byte-locks. leader_panes.py:383-470.
167
+ // ════════════════════════════════════════════════════════════════════════
168
+
169
+ #[test]
170
+ fn trust_auto_answer_pane_id_missing_reason() {
171
+ // leader_panes.py:417-424 — pane_id None → pane_id_missing (after opt-in).
172
+ let ws = tmp_ws("trustpane");
173
+ let log = EventLog::new(&ws);
174
+ let t = NoopTransport;
175
+ let out = attempt_trust_auto_answer(
176
+ &ws,
177
+ &t,
178
+ None,
179
+ "some prompt",
180
+ &PaneWidthQuery::Ok { pane_width: 120 },
181
+ &log,
182
+ )
183
+ .unwrap();
184
+ assert!(!out.ok);
185
+ assert!(!out.answered);
186
+ assert_eq!(out.reason, "pane_id_missing");
187
+ }
188
+
189
+ #[test]
190
+ fn trust_auto_answer_foreign_workspace_refused() {
191
+ // leader_panes.py:430-444 — prompt names a FOREIGN dir → workspace_dir_mismatch,
192
+ // action prompt_leader. (own-vs-foreign realpath gate.)
193
+ let ws = tmp_ws("trustforeign");
194
+ let log = EventLog::new(&ws);
195
+ let t = NoopTransport;
196
+ let pane = PaneId::new("%7");
197
+ let foreign_tail = "Allow Codex to access /some/other/foreign/dir ?";
198
+ let out = attempt_trust_auto_answer(
199
+ &ws,
200
+ &t,
201
+ Some(&pane),
202
+ foreign_tail,
203
+ &PaneWidthQuery::Ok { pane_width: 120 },
204
+ &log,
205
+ )
206
+ .unwrap();
207
+ assert!(!out.answered);
208
+ assert_eq!(out.reason, "workspace_dir_mismatch");
209
+ assert_eq!(out.action.as_deref(), Some("prompt_leader"));
210
+ }
211
+
212
+ #[test]
213
+ fn trust_auto_answer_own_workspace_realpath_equal_answers() {
214
+ // Exact canonical equality of the prompt path with the workspace → answered.
215
+ let ws = tmp_ws("trustown");
216
+ let canonical = std::fs::canonicalize(&ws).unwrap();
217
+ let log = EventLog::new(&ws);
218
+ let t = NoopTransport;
219
+ let pane = PaneId::new("%7");
220
+ let tail = format!("Allow Codex to write to {} ?", canonical.display());
221
+ let out = attempt_trust_auto_answer(
222
+ &canonical,
223
+ &t,
224
+ Some(&pane),
225
+ &tail,
226
+ &PaneWidthQuery::Ok { pane_width: 240 },
227
+ &log,
228
+ )
229
+ .unwrap();
230
+ assert!(out.answered, "own-workspace realpath-equal prompt must auto-answer");
231
+ assert_eq!(out.reason, "trust_auto_answered");
232
+ }
233
+
234
+ // ════════════════════════════════════════════════════════════════════════
235
+ // GROUP I — PaneWidthQuery fail-safe (bug-064/082): Failed NEVER carries a
236
+ // default width; tmux_pane_width returns Failed on any query failure.
237
+ // delivery.py:20-51.
238
+ // ════════════════════════════════════════════════════════════════════════
239
+
240
+ #[test]
241
+ fn pane_width_failed_forces_exact_match_never_default() {
242
+ // GROUP-I fail-safe (bug-064/082, folded from the old structural placeholder):
243
+ // calls the REAL attempt_trust_auto_answer. A FOREIGN path that is merely a
244
+ // truncated PREFIX of this workspace would only match with a width signal that
245
+ // proves right-edge truncation (leader_panes.py:_token_reaches_right_edge). With
246
+ // PaneWidthQuery::Failed there is NO width signal and NO default width to leak,
247
+ // so the matcher MUST fall back to exact canonical equality → the prefix does
248
+ // NOT match → workspace_dir_mismatch / prompt_leader. Probed golden:
249
+ // leader_panes.py:430-444 with pane_width=None (Failed) → workspace_dir_mismatch.
250
+ let ws = tmp_ws("panewidthfailsafe");
251
+ let canonical = std::fs::canonicalize(&ws).unwrap();
252
+ let log = EventLog::new(&ws);
253
+ let t = NoopTransport;
254
+ let pane = PaneId::new("%7");
255
+ // A right-edge-truncated prefix of the real workspace path (drop the last char):
256
+ // would auto-answer IF a width signal proved truncation — but Failed forbids that.
257
+ let canon_str = canonical.to_string_lossy();
258
+ let truncated_prefix = &canon_str[..canon_str.len().saturating_sub(1)];
259
+ let tail = format!("Allow Codex to write to {truncated_prefix}");
260
+ let out = attempt_trust_auto_answer(
261
+ &canonical,
262
+ &t,
263
+ Some(&pane),
264
+ &tail,
265
+ &PaneWidthQuery::Failed {
266
+ error: "tmux_query_failed:CalledProcessError".to_string(),
267
+ },
268
+ &log,
269
+ )
270
+ .unwrap();
271
+ // fail-safe: no width → exact-equality only → truncated prefix refused.
272
+ assert!(!out.answered, "Failed pane-width must NOT enable prefix/truncation matching");
273
+ assert_eq!(out.reason, "workspace_dir_mismatch");
274
+ assert_eq!(out.action.as_deref(), Some("prompt_leader"));
275
+ }
276
+
277
+ #[test]
278
+ fn tmux_pane_width_failure_yields_failed_not_default() {
279
+ // delivery.py:37-50 — any failure path returns Failed (never a guessed width).
280
+ let t = NoopTransport;
281
+ let target = Target::Pane(PaneId::new("%nonexistent"));
282
+ let q = tmux_pane_width(&t, &target);
283
+ assert!(
284
+ matches!(q, PaneWidthQuery::Failed { .. }),
285
+ "query failure must be fail-safe Failed, never a default width"
286
+ );
287
+ }
288
+
289
+ // ════════════════════════════════════════════════════════════════════════
290
+ // GROUP J — trust retry status machine: bounded attempt → exhausted terminal.
291
+ // delivery.py:221-319 (_handle_trust_retry_needed).
292
+ // ════════════════════════════════════════════════════════════════════════
293
+
294
+ #[test]
295
+ fn handle_trust_retry_below_max_schedules_retry() {
296
+ // attempt 1 (< 4) → next_attempt 2 scheduled, status retry_scheduled,
297
+ // stage trust_auto_answer_dismissal_wait. NOT marked terminal-failed.
298
+ let ws = tmp_ws("trustretry1");
299
+ let store = store_for(&ws);
300
+ let log = EventLog::new(&ws);
301
+ let payload = TrustRetryPayload {
302
+ message_id: "m1".to_string(),
303
+ attempt: 1,
304
+ max_attempts: TRUST_RETRY_MAX_ATTEMPTS,
305
+ first_target: PaneId::new("%7"),
306
+ };
307
+ let out = handle_trust_retry_needed(&store, &payload, &log).unwrap();
308
+ assert_eq!(out.status, DeliveryStatus::RetryScheduled);
309
+ assert_eq!(out.stage, Some(DeliveryStage::TrustAutoAnswerDismissalWait));
310
+ assert!(!out.ok);
311
+ }
312
+
313
+ #[test]
314
+ fn handle_trust_retry_at_max_is_exhausted_terminal() {
315
+ // attempt == 4 (== MAX) → next_attempt 5 > MAX → terminal exhausted, marks
316
+ // message failed, emits trust_auto_answer_exhausted. delivery.py:246-266.
317
+ let ws = tmp_ws("trustretry4");
318
+ let store = store_for(&ws);
319
+ let log = EventLog::new(&ws);
320
+ let payload = TrustRetryPayload {
321
+ message_id: "m1".to_string(),
322
+ attempt: TRUST_RETRY_MAX_ATTEMPTS,
323
+ max_attempts: TRUST_RETRY_MAX_ATTEMPTS,
324
+ first_target: PaneId::new("%7"),
325
+ };
326
+ let out = handle_trust_retry_needed(&store, &payload, &log).unwrap();
327
+ // delivery.py:257-259 — terminal exhausted: ok False, status the dedicated
328
+ // trust_auto_answer_exhausted (a bounded-loop termination guarantee, NOT a
329
+ // refusal reason — `reason` stays None at the typed boundary).
330
+ assert_eq!(out.status, DeliveryStatus::TrustAutoAnswerExhausted);
331
+ assert!(!out.ok);
332
+ }
333
+
334
+ // ════════════════════════════════════════════════════════════════════════
335
+ // GROUP K — send_message target resolution / fallback chain (send.py:36-372).
336
+ // RED via unimplemented!(); golden status/reason encoded in assertions.
337
+ // ════════════════════════════════════════════════════════════════════════
338
+
339
+ #[test]
340
+ fn send_message_target_not_in_team_is_refused() {
341
+ // send.py:259-261 — non-leader, non-team target → refused/target_not_in_team.
342
+ let ws = tmp_ws("sendrefuse");
343
+ let opts = SendOptions::default();
344
+ let out = send_message(
345
+ &ws,
346
+ &MessageTarget::Single("ghost".to_string()),
347
+ "hi",
348
+ &opts,
349
+ )
350
+ .unwrap();
351
+ assert_eq!(out.status, DeliveryStatus::Refused);
352
+ assert_eq!(out.reason, Some(DeliveryRefusal::TargetNotInTeam));
353
+ }
354
+
355
+ #[test]
356
+ fn send_message_broadcast_empty_team_skips_no_recipients() {
357
+ // send.py:391-393 — "*" with no team recipients →
358
+ // {"ok": False, "status": "failed", "reason": "no_team_recipients", "to": "*"}.
359
+ // Post-#230 N31/N32 funnel implementation (cr-approved): broadcast now expands the
360
+ // recipient set via `broadcast_recipients(state, sender, team)` and routes each
361
+ // recipient through the SAME primitives as a single send (leader → primitive,
362
+ // peer → send_message). The assertions stay the same: with no agents seeded and
363
+ // sender="leader" (default opts.sender), `broadcast_recipients` returns an empty
364
+ // list — outcome is Failed/no-recipients with channel="*". The "*" channel label
365
+ // is preserved through the new `fanout_send(..., channel_label="*")` parameter so
366
+ // legacy consumers can still tell broadcast (`*`) apart from explicit fanout list.
367
+ let ws = tmp_ws("sendbcast");
368
+ let opts = SendOptions::default();
369
+ let out = send_message(&ws, &MessageTarget::Broadcast, "hi", &opts).unwrap();
370
+ assert!(!out.ok);
371
+ assert_eq!(out.status, DeliveryStatus::Failed);
372
+ assert_eq!(
373
+ out.reason, None,
374
+ "no_team_recipients is a failed-status terminal, not a typed refusal reason"
375
+ );
376
+ assert_eq!(
377
+ out.channel.as_deref(),
378
+ Some("*"),
379
+ "broadcast outcome must carry the '*' channel (send.py to='*'); fanout_send(channel_label=\"*\") preserves this"
380
+ );
381
+ }
382
+
383
+ #[test]
384
+ fn send_message_fanout_empty_recipients_fails() {
385
+ // send.py:456-457 — fanout with no usable recipients → ok False,
386
+ // no_fanout_recipients. (Dedup-then-deliver happy path needs team fixtures.)
387
+ let ws = tmp_ws("sendfanout");
388
+ let opts = SendOptions::default();
389
+ let out = send_message(&ws, &MessageTarget::Fanout(vec![]), "hi", &opts).unwrap();
390
+ assert!(!out.ok);
391
+ assert_eq!(out.status, DeliveryStatus::Failed);
392
+ }
393
+
394
+ // ════════════════════════════════════════════════════════════════════════
395
+ // GROUP L — apply_worker_sender_bypass: owner-gate first-door bypass event.
396
+ // owner_bypass.py:9-26.
397
+ // ════════════════════════════════════════════════════════════════════════
398
+
399
+ #[test]
400
+ fn worker_sender_bypass_false_for_leader_sender() {
401
+ // owner_bypass.py — leader sender never bypasses (worker_sender_bypasses=None).
402
+ let ws = tmp_ws("bypassleader");
403
+ let log = EventLog::new(&ws);
404
+ let state = json(serde_json::json!({"agents": {"w1": {}}}));
405
+ let bypassed = apply_worker_sender_bypass(
406
+ &state,
407
+ Some("leader"),
408
+ &MessageTarget::Single("w1".to_string()),
409
+ None,
410
+ &log,
411
+ )
412
+ .unwrap();
413
+ assert!(!bypassed);
414
+ }
415
+
416
+ #[test]
417
+ #[serial_test::serial(env)]
418
+ fn worker_sender_bypass_true_for_known_worker_sender() {
419
+ // owner_bypass.py:18-26 — worker in agents bypasses, writes
420
+ // send.bypassed_owner_gate_worker_sender.
421
+ // Isolate from ambient TEAM_AGENT_ID: the env identity gate only activates when
422
+ // TEAM_AGENT_ID is SET (and != sender → deny, see p2_owner_bypass_denies_*). Unset
423
+ // here so the agents-membership bypass is tested deterministically regardless of the
424
+ // process env (workers run with TEAM_AGENT_ID set; the leader does not).
425
+ let _g = ENV_LOCK_MSG.lock().unwrap_or_else(|p| p.into_inner());
426
+ let _e = EnvGuardMsg::set("TEAM_AGENT_ID", None);
427
+ let ws = tmp_ws("bypassworker");
428
+ let log = EventLog::new(&ws);
429
+ let state = json(serde_json::json!({"agents": {"w1": {}}}));
430
+ let bypassed = apply_worker_sender_bypass(
431
+ &state,
432
+ Some("w1"),
433
+ &MessageTarget::Single("w2".to_string()),
434
+ None,
435
+ &log,
436
+ )
437
+ .unwrap();
438
+ assert!(bypassed);
439
+ }
440
+
441
+ // ════════════════════════════════════════════════════════════════════════
442
+ // GROUP M — report_result intake (results.py:191-227): validate envelope,
443
+ // queue leader notify (channel coordinator), return ok shape.
444
+ // ════════════════════════════════════════════════════════════════════════
445
+
446
+ #[test]
447
+ fn report_result_valid_envelope_returns_ok_with_result_id() {
448
+ let ws = tmp_ws("report");
449
+ let envelope = json(serde_json::json!({
450
+ "schema_version": "result_envelope_v1",
451
+ "task_id": "t1", "agent_id": "alice", "status": "success",
452
+ "summary": "done", "changes": [], "tests": [], "risks": [],
453
+ "artifacts": [], "next_actions": []
454
+ }));
455
+ let out = report_result(&ws, &envelope).unwrap();
456
+ // results.py:216-227 — ok True with result_id/task_id/agent_id echoed.
457
+ assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true));
458
+ assert_eq!(
459
+ out.get("task_id").and_then(|v| v.as_str()),
460
+ Some("t1")
461
+ );
462
+ assert_eq!(
463
+ out.get("agent_id").and_then(|v| v.as_str()),
464
+ Some("alice")
465
+ );
466
+ assert!(out.get("result_id").and_then(|v| v.as_str()).is_some());
467
+ }
468
+
469
+ #[test]
470
+ fn report_result_funnels_into_leader_delivery_primitive_not_queued_scheduled_event() {
471
+ // #230 N31/N32 funnel (cr verdict §3 I-3 + MUST-8):
472
+ //
473
+ // [OLD assertion] report_result inserted a parallel `scheduled_events(kind='send',
474
+ // target='leader', status='pending')` row + returned `notification_status="queued"`,
475
+ // and the worker-facing tool body claimed success while leader had not yet seen the
476
+ // result. That queued-only path was MUST-8 / I-3 violating — `notification_status=
477
+ // queued` was returned as success but the leader pane never actually got the text.
478
+ //
479
+ // [NEW assertion] report_result now synchronously funnels through the shared leader-
480
+ // delivery primitive (`send_to_leader_receiver`), creating a `messages` row that
481
+ // `deliver_pending_messages` picks up on the same tick (NO `scheduled_events` row).
482
+ // Without a bound leader pane (this fixture has no `leader_receiver.pane_id`), the
483
+ // primitive returns I-4 `rebind_required` (Blocked / ok=false) — the row is persisted
484
+ // as `failed` for audit and the tool body's `notification_status` is `rebind_required`,
485
+ // NOT a misleading `queued` success. The contract grep that bans `queue_report_result_
486
+ // notification` / `notification_status="queued[_only]"` literals in `results.rs` is the
487
+ // direct mechanical counterpart of this assertion.
488
+ let ws = tmp_ws("reportnotify");
489
+ crate::state::persist::save_runtime_state(
490
+ &ws,
491
+ &serde_json::json!({
492
+ "session_name": null,
493
+ "leader": {"id": "leader"},
494
+ "agents": {"worker": {"status": "running"}},
495
+ "tasks": [{"id": "task_1", "status": "running", "assignee": "worker"}]
496
+ }),
497
+ )
498
+ .unwrap();
499
+ let store = store_for(&ws);
500
+ let envelope = json(serde_json::json!({
501
+ "schema_version": "result_envelope_v1",
502
+ "task_id": "task_1",
503
+ "agent_id": "worker",
504
+ "status": "success",
505
+ "summary": "done",
506
+ "changes": [],
507
+ "tests": [{"command": "cargo test", "status": "passed"}],
508
+ "risks": [],
509
+ "artifacts": [],
510
+ "next_actions": []
511
+ }));
512
+
513
+ let out = report_result(&ws, &envelope).unwrap();
514
+ let result_id = out
515
+ .get("result_id")
516
+ .and_then(|v| v.as_str())
517
+ .expect("report_result returns generated result_id");
518
+ assert!(
519
+ result_id.starts_with("res_"),
520
+ "MessageStore.add_result generates res_* ids; got {result_id}"
521
+ );
522
+
523
+ // No `scheduled_events` rows: the queued parallel path is gone.
524
+ let conn = seed_conn(&store);
525
+ let scheduled_count: i64 = conn
526
+ .query_row("select count(*) from scheduled_events", [], |row| row.get(0))
527
+ .unwrap();
528
+ assert_eq!(
529
+ scheduled_count, 0,
530
+ "N31/N32 funnel: report_result must NOT insert a parallel scheduled_events 'send' row; the leader-delivery primitive is the single funnel"
531
+ );
532
+
533
+ // Tool body: no `queued`/`queued_only` notification_status. Without a bound leader
534
+ // pane this fixture surfaces I-4 `rebind_required` (ok=false on the leader delivery,
535
+ // but the result row + audit trail are durable for rebind replay).
536
+ assert_eq!(
537
+ out.get("notification_status").and_then(|v| v.as_str()),
538
+ Some("rebind_required"),
539
+ "I-4: unbound leader pane → rebind_required, never queued/queued_only success"
540
+ );
541
+ assert_eq!(
542
+ out.get("leader_notified").and_then(|v| v.as_bool()),
543
+ Some(false),
544
+ "I-4: leader_notified=false when no leader pane is bound"
545
+ );
546
+ assert!(
547
+ out.get("notification_event_id").is_some_and(|v| v.is_null()),
548
+ "no scheduled_events row → notification_event_id is null"
549
+ );
550
+
551
+ // Audit events: the funnel emits leader_receiver.delivery_blocked (I-4 rebind),
552
+ // and the legacy mcp.report_result_notify_queued audit is gone.
553
+ let events_path = ws.join(".team").join("logs").join("events.jsonl");
554
+ let event_lines = std::fs::read_to_string(events_path)
555
+ .expect("report_result writes events.jsonl");
556
+ assert!(
557
+ event_lines.contains("\"leader_receiver.delivery_blocked\""),
558
+ "I-4 rebind path must emit leader_receiver.delivery_blocked audit; got {event_lines}",
559
+ );
560
+ assert!(
561
+ !event_lines.contains("mcp.report_result_notify_queued"),
562
+ "legacy queued-notification audit must be gone; got {event_lines}",
563
+ );
564
+ assert!(
565
+ event_lines.contains("\"mcp.report_result\""),
566
+ "report_result still emits its own audit event; got {event_lines}",
567
+ );
568
+ }
569
+
570
+ #[test]
571
+ fn report_result_invalid_envelope_errors_validation() {
572
+ // validate_result_envelope raises ValidationError → MessagingError::Validation.
573
+ let ws = tmp_ws("reportbad");
574
+ let envelope = json(serde_json::json!({"schema_version": "result_envelope_v1"}));
575
+ let err = report_result(&ws, &envelope).unwrap_err();
576
+ assert!(
577
+ matches!(err, MessagingError::Validation(_)),
578
+ "missing required fields must surface as Validation, got {err:?}"
579
+ );
580
+ }
581
+
582
+ // ════════════════════════════════════════════════════════════════════════
583
+ // GROUP N — notify_result_watchers dedupe (exactly-once, Gap 32/38).
584
+ // result_delivery.py:38-132. superseded for duplicate watchers same result.
585
+ // ════════════════════════════════════════════════════════════════════════
586
+
587
+ #[test]
588
+ fn notify_result_watchers_no_match_returns_empty() {
589
+ // result_delivery.py:51-52 — no candidate watcher matches → empty list.
590
+ let ws = tmp_ws("notifyempty");
591
+ let log = EventLog::new(&ws);
592
+ let result = json(serde_json::json!({
593
+ "result_id": "r1", "task_id": "t1", "agent_id": "alice"
594
+ }));
595
+ let watchers = vec![json(serde_json::json!({
596
+ "watcher_id": "w-x", "task_id": "OTHER", "agent_id": "alice"
597
+ }))];
598
+ let notices = notify_result_watchers(
599
+ &ws,
600
+ &result,
601
+ &log,
602
+ Some(&watchers),
603
+ None,
604
+ )
605
+ .unwrap();
606
+ assert!(notices.is_empty());
607
+ }
608
+
609
+ #[test]
610
+ fn notify_result_watchers_supersedes_duplicate_watchers() {
611
+ // result_delivery.py:53-78 — two watchers same (task,agent,result): earliest is
612
+ // primary, the other gets superseded (ok False, notice records superseded).
613
+ let ws = tmp_ws("notifysup");
614
+ let log = EventLog::new(&ws);
615
+ let result = json(serde_json::json!({
616
+ "result_id": "r1", "task_id": "t1", "agent_id": "alice"
617
+ }));
618
+ let watchers = vec![
619
+ json(serde_json::json!({
620
+ "watcher_id": "w-early", "task_id": "t1", "agent_id": "alice",
621
+ "created_at": "2026-06-02T10:00:00+00:00"
622
+ })),
623
+ json(serde_json::json!({
624
+ "watcher_id": "w-late", "task_id": "t1", "agent_id": "alice",
625
+ "created_at": "2026-06-02T11:00:00+00:00"
626
+ })),
627
+ ];
628
+ let notices =
629
+ notify_result_watchers(&ws, &result, &log, Some(&watchers), None).unwrap();
630
+ // The late watcher must appear as a superseded (not-ok) notice — exactly-once.
631
+ let superseded = notices
632
+ .iter()
633
+ .find(|n| n.watcher_id == "w-late")
634
+ .expect("late watcher must be reported");
635
+ assert!(!superseded.ok, "duplicate watcher must be superseded, not re-delivered");
636
+ }
637
+
638
+ // ════════════════════════════════════════════════════════════════════════
639
+ // GROUP O — requeue_after_claim_leader: notified_message_id must SURVIVE (Gap
640
+ // 32) — already-notified watchers are NOT requeued. result_delivery.py:428-506.
641
+ // ════════════════════════════════════════════════════════════════════════
642
+
643
+ #[test]
644
+ fn requeue_after_claim_leader_skips_already_notified() {
645
+ // Gap 32 (result_delivery.py:467-471) — SEEDED dedupe gate: two same-team
646
+ // watchers, one already notified (notified_message_id set), one un-notified.
647
+ // requeue must return ONLY the un-notified watcher; the notified one is NOT
648
+ // requeued and its notified_message_id SURVIVES (clearing it would cause a
649
+ // second injection). Probed golden: requeued == [w_un] (result_id null,
650
+ // prior_state "pending"); notified watcher keeps notified_message_id.
651
+ let ws = tmp_ws("requeue");
652
+ let store = store_for(&ws);
653
+ let log = EventLog::new(&ws);
654
+ let team = TeamKey::new("team-a");
655
+ let pane = PaneId::new("%new-leader");
656
+
657
+ let w_un = seed_watcher(&store, "w-unnotified", "team-a", "t1", "alice", "pending", None, None);
658
+ let w_notified = seed_watcher(
659
+ &store, "w-notified", "team-a", "t2", "bob", "pending", None, Some("msg_already"),
660
+ );
661
+
662
+ let requeued =
663
+ requeue_after_claim_leader(&ws, &store, &log, &team, &pane, None).unwrap();
664
+
665
+ // ONLY the un-notified watcher requeues (the notified one is the dedupe gate).
666
+ let ids: Vec<&str> = requeued.iter().map(|n| n.watcher_id.as_str()).collect();
667
+ assert_eq!(ids, vec![w_un.as_str()], "exactly the un-notified watcher requeues");
668
+ assert!(
669
+ !requeued.iter().any(|n| n.watcher_id == w_notified),
670
+ "already-notified watcher must NOT be requeued (Gap 32)"
671
+ );
672
+
673
+ // Gap 32 survival: notified_message_id is preserved on the skipped watcher.
674
+ let (_status, notified) = watcher_state(&store, &w_notified);
675
+ assert_eq!(
676
+ notified.as_deref(),
677
+ Some("msg_already"),
678
+ "notified_message_id MUST survive requeue — clearing it re-injects (Gap 32)"
679
+ );
680
+ }
681
+
682
+ #[test]
683
+ fn requeue_delivery_exhausted_watchers_reopens_only_exhausted() {
684
+ let ws = tmp_ws("requeueexhausted");
685
+ let store = store_for(&ws);
686
+ let log = EventLog::new(&ws);
687
+ let team = TeamKey::new("team-a");
688
+ let pane = PaneId::new("%leader");
689
+
690
+ let rid = seed_result(&store, "res_exhausted", "t1", "alice", "success");
691
+ let exhausted = seed_watcher(
692
+ &store,
693
+ "w-exhausted",
694
+ "team-a",
695
+ "t1",
696
+ "alice",
697
+ "delivery_exhausted",
698
+ Some(&rid),
699
+ None,
700
+ );
701
+ let notified = seed_watcher(
702
+ &store,
703
+ "w-exhausted-notified",
704
+ "team-a",
705
+ "t2",
706
+ "bob",
707
+ "delivery_exhausted",
708
+ Some("res_skip"),
709
+ Some("msg_done"),
710
+ );
711
+ let failed = seed_watcher(
712
+ &store,
713
+ "w-failed",
714
+ "team-a",
715
+ "t3",
716
+ "carol",
717
+ "notify_failed",
718
+ Some("res_failed"),
719
+ None,
720
+ );
721
+
722
+ let requeued =
723
+ requeue_delivery_exhausted_watchers(&ws, &store, &log, &team, &pane).unwrap();
724
+
725
+ assert_eq!(requeued.len(), 1, "only delivery_exhausted unnotified watchers requeue");
726
+ let notice = &requeued[0];
727
+ assert_eq!(notice.watcher_id, exhausted);
728
+ assert_eq!(notice.result_id.as_deref(), Some(rid.as_str()));
729
+ assert_eq!(notice.prior_state.as_deref(), Some("delivery_exhausted"));
730
+ // R8 (golden result_watchers.py:95): attach requeue flips delivery_exhausted -> notify_failed (NOT pending).
731
+ assert_eq!(notice.status.as_deref(), Some("notify_failed"));
732
+
733
+ let (status, _notified_id) = watcher_state(&store, &exhausted);
734
+ // R8 (golden): attach requeue leaves the watcher at notify_failed and DEFERS retry to the coordinator
735
+ // tick — it does NOT immediately re-deliver (only the claim path retries). So the persisted status is
736
+ // notify_failed, not 'notified'.
737
+ assert_eq!(status, "notify_failed", "attach requeue flips to notify_failed and defers retry (golden)");
738
+ let (status, notified_id) = watcher_state(&store, &notified);
739
+ assert_eq!(status, "delivery_exhausted");
740
+ assert_eq!(notified_id.as_deref(), Some("msg_done"));
741
+ let (status, _notified_id) = watcher_state(&store, &failed);
742
+ assert_eq!(status, "notify_failed", "non-exhausted watcher is not selected");
743
+
744
+ // R8 (golden leader/__init__.py:46-50): result_watcher.requeued is the ATTACH form
745
+ // {watcher_id, trigger:"attach_leader", new_pane_id} — NOT the claim-style {prior_state,claimed_pane_id,team_id}.
746
+ let events = log.tail(0).unwrap();
747
+ let ev = events.iter().rev()
748
+ .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("result_watcher.requeued"))
749
+ .expect("result_watcher.requeued event");
750
+ let keys: std::collections::BTreeSet<&str> = ev.as_object().unwrap().keys()
751
+ .map(String::as_str).filter(|k| *k != "ts" && *k != "event").collect();
752
+ let expected: std::collections::BTreeSet<&str> = ["watcher_id", "trigger", "new_pane_id"].into_iter().collect();
753
+ assert_eq!(keys, expected, "result_watcher.requeued must be golden attach form {{watcher_id, trigger, new_pane_id}}");
754
+ assert_eq!(ev.get("watcher_id").and_then(|v| v.as_str()), Some("w-exhausted"));
755
+ assert_eq!(ev.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
756
+ assert_eq!(ev.get("new_pane_id").and_then(|v| v.as_str()), Some("%leader"));
757
+ }
758
+
759
+ // ════════════════════════════════════════════════════════════════════════
760
+ // GROUP P — stuck_cancel owner-gate + invalid alert type refusal.
761
+ // scheduler.py:262-294.
762
+ // ════════════════════════════════════════════════════════════════════════
763
+
764
+ #[test]
765
+ fn stuck_cancel_none_alert_type_expands_to_all() {
766
+ // alert_type None == Python "all" → sorted(_ALERT_TYPES) expansion.
767
+ let ws = tmp_ws("stuckcancel");
768
+ let out = stuck_cancel(&ws, "w1", None, "leader").unwrap();
769
+ // The suppression result must enumerate all three alert types.
770
+ let types = out
771
+ .get("alert_types")
772
+ .and_then(|v| v.as_array())
773
+ .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect::<Vec<_>>());
774
+ assert_eq!(
775
+ types,
776
+ Some(vec![
777
+ "cross_worker_deadlock".to_string(),
778
+ "idle_fallback".to_string(),
779
+ "stuck".to_string()
780
+ ])
781
+ );
782
+ }
783
+
784
+ // ════════════════════════════════════════════════════════════════════════
785
+ // GROUP Q — collect intake (results.py:45-167): valid result advances task,
786
+ // returns collected_results + delivered_messages + results counts shape.
787
+ // ════════════════════════════════════════════════════════════════════════
788
+
789
+ #[test]
790
+ fn collect_without_spec_surfaces_validation_error() {
791
+ // results.py:46-48 — collect() reads spec_path then load_spec(); against a
792
+ // workspace with NO team.spec.yaml, load_spec RAISES ValidationError
793
+ // ("Cannot read <path>: ...", spec.py:18-20) BEFORE any collection. The
794
+ // previous `.unwrap()` (expecting an Ok dict with present-only keys) was wrong:
795
+ // the real Python collect on a bare workspace does not return a dict, it raises.
796
+ // At the typed boundary that surfaces as MessagingError::Validation.
797
+ //
798
+ // The full collected_results-count golden (seed an uncollected result, assert it
799
+ // collects and the task advances) is DEFERRED: it requires a valid on-disk
800
+ // team.spec.yaml + runtime state, whose formats are owned by the spec/state lanes
801
+ // (this file may not edit them). seed_result() exists for the retry path that
802
+ // does NOT need a spec; the collect happy-path needs an integration fixture.
803
+ let ws = tmp_ws("collect");
804
+ let err = collect(&ws, None, false).unwrap_err();
805
+ assert!(
806
+ matches!(err, MessagingError::Validation(_)),
807
+ "collect without a team spec must surface Validation, got {err:?}"
808
+ );
809
+ }
810
+
811
+ #[test]
812
+ fn collect_accepts_message_scoped_result_for_matching_recipient() {
813
+ let ws = tmp_ws("collectmsgok");
814
+ std::fs::write(ws.join("team.spec.yaml"), "version: 1\n").unwrap();
815
+ let store = store_for(&ws);
816
+ let message_id = store
817
+ .create_message(None, "leader", "w1", "please reply", None, false, None)
818
+ .unwrap();
819
+ seed_result(&store, "res_msg_ok", &message_id, "w1", "success");
820
+
821
+ let out = collect(&ws, None, false).unwrap();
822
+ assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true));
823
+ let collected = out
824
+ .get("collected_results")
825
+ .and_then(|v| v.as_array())
826
+ .expect("collected_results");
827
+ assert_eq!(collected.len(), 1);
828
+ assert_eq!(collected[0].get("task_id").and_then(|v| v.as_str()), Some(message_id.as_str()));
829
+ assert_eq!(collected[0].get("agent_id").and_then(|v| v.as_str()), Some("w1"));
830
+ assert_eq!(collected[0].get("scope").and_then(|v| v.as_str()), Some("message"));
831
+ // D3 (leader-adjudicated): golden collected_results entry is EXACTLY the 8-key summary for BOTH
832
+ // scopes; golden's task_status feeds only the `collect.result` EVENT, never the entry. So a
833
+ // message-scope entry carries NO task_status key (the prior `Some("message_scoped")` lock encoded a
834
+ // port divergence — dropped per ruling).
835
+ assert!(
836
+ collected[0].get("task_status").is_none(),
837
+ "collected_results entry must NOT carry task_status (golden 8-key summary; event-only); got {:?}",
838
+ collected[0]
839
+ );
840
+ let keys: Vec<&str> = collected[0].as_object().expect("entry is an object").keys().map(String::as_str).collect();
841
+ assert_eq!(
842
+ keys,
843
+ vec!["result_id", "task_id", "agent_id", "status", "summary", "tests", "created_at", "scope"],
844
+ "message-scope collected_results entry must be EXACTLY the golden 8 keys in order; got {keys:?}"
845
+ );
846
+ }
847
+
848
+ #[test]
849
+ fn collect_rejects_message_scoped_result_without_matching_recipient() {
850
+ let ws = tmp_ws("collectmsgbad");
851
+ std::fs::write(ws.join("team.spec.yaml"), "version: 1\n").unwrap();
852
+ let store = store_for(&ws);
853
+ let message_id = store
854
+ .create_message(None, "leader", "w1", "please reply", None, false, None)
855
+ .unwrap();
856
+ seed_result(&store, "res_msg_bad", &message_id, "w2", "success");
857
+
858
+ let out = collect(&ws, None, false).unwrap();
859
+ assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(false));
860
+ assert!(
861
+ out.get("collected_results")
862
+ .and_then(|v| v.as_array())
863
+ .is_some_and(Vec::is_empty),
864
+ "recipient mismatch must not collect as message-scoped"
865
+ );
866
+ let invalid = out
867
+ .get("invalid_results")
868
+ .and_then(|v| v.as_array())
869
+ .expect("invalid_results");
870
+ assert_eq!(invalid.len(), 1);
871
+ assert_eq!(invalid[0].get("task_id").and_then(|v| v.as_str()), Some(message_id.as_str()));
872
+ assert_eq!(
873
+ invalid[0].get("error").and_then(|v| v.as_str()),
874
+ Some(format!("unknown task id: {message_id}").as_str())
875
+ );
876
+ }
877
+
878
+ #[test]
879
+ fn allow_peer_talk_records_bidirectional_allowlist_and_event() {
880
+ let ws = tmp_ws("allowpeer");
881
+ let out = allow_peer_talk(&ws, "alice", "bob").unwrap();
882
+ assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true));
883
+ assert_eq!(out.get("a").and_then(|v| v.as_str()), Some("alice"));
884
+ assert_eq!(out.get("b").and_then(|v| v.as_str()), Some("bob"));
885
+ assert_eq!(out.get("status").and_then(|v| v.as_str()), Some("compat_noop"));
886
+ assert_eq!(
887
+ out.get("reason").and_then(|v| v.as_str()),
888
+ Some("team_scoped_peer_messages_enabled")
889
+ );
890
+
891
+ let store = store_for(&ws);
892
+ let conn = seed_conn(&store);
893
+ let rows = conn
894
+ .prepare("select a, b from peer_allowlist order by a, b")
895
+ .unwrap()
896
+ .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
897
+ .unwrap()
898
+ .collect::<Result<Vec<_>, _>>()
899
+ .unwrap();
900
+ assert_eq!(
901
+ rows,
902
+ vec![
903
+ ("alice".to_string(), "bob".to_string()),
904
+ ("bob".to_string(), "alice".to_string()),
905
+ ]
906
+ );
907
+
908
+ let events = EventLog::new(&ws).tail(10).unwrap();
909
+ let event = events
910
+ .iter()
911
+ .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("communication.peer_allowed"))
912
+ .expect("communication.peer_allowed event");
913
+ assert_eq!(event.get("a").and_then(|v| v.as_str()), Some("alice"));
914
+ assert_eq!(event.get("b").and_then(|v| v.as_str()), Some("bob"));
915
+ }
916
+
917
+ // ════════════════════════════════════════════════════════════════════════
918
+ // GROUP R — run_comms_selftest: §84 / MUST-NOT-13 zero-provider-SDK gate.
919
+ // diagnose/comms.py:21-47. The whole point: assert zero provider client calls.
920
+ // ════════════════════════════════════════════════════════════════════════
921
+
922
+ #[test]
923
+ fn run_comms_selftest_zero_provider_sdk_passes_and_locks_scope() {
924
+ let ws = tmp_ws("selftestok");
925
+ let driver = ZeroSdkDriver {
926
+ run_id: Some("fixedrunid01".to_string()),
927
+ calls: ProviderSdkCalls::default(),
928
+ };
929
+ let report = run_comms_selftest(&ws, None, &driver).unwrap();
930
+ // diagnose/comms.py:44 scope == "binding_consistency".
931
+ assert_eq!(report.scope, "binding_consistency");
932
+ assert_eq!(report.run_id, "fixedrunid01");
933
+ // The mechanical gate: provider_sdk_calls check is a Pass with all-zero evidence.
934
+ assert_eq!(report.provider_sdk_calls.status, CheckStatus::Pass);
935
+ match &report.provider_sdk_calls.evidence {
936
+ CheckEvidence::ProviderSdkCalls(calls) => assert!(calls.is_zero()),
937
+ other => panic!("expected ProviderSdkCalls evidence, got {other:?}"),
938
+ }
939
+ assert!(report.ok);
940
+ }
941
+
942
+ #[test]
943
+ fn run_comms_selftest_nonzero_provider_sdk_fails_gate() {
944
+ // Any non-zero SDK call count → provider_sdk_calls check FAILS, report not ok.
945
+ let ws = tmp_ws("selftestbad");
946
+ let driver = ZeroSdkDriver {
947
+ run_id: Some("r2".to_string()),
948
+ calls: ProviderSdkCalls {
949
+ anthropic: 1,
950
+ openai: 0,
951
+ httpx: 0,
952
+ },
953
+ };
954
+ let report = run_comms_selftest(&ws, None, &driver).unwrap();
955
+ assert_eq!(report.provider_sdk_calls.status, CheckStatus::Fail);
956
+ assert!(!report.ok);
957
+ }
958
+
959
+ #[test]
960
+ fn run_comms_selftest_contract_suite_is_deferred() {
961
+ // diagnose/comms.py:132-139 — contract_suite is always deferred (test files
962
+ // not shipped) and counts as a pass for the overall gate.
963
+ let ws = tmp_ws("selftestdefer");
964
+ let driver = ZeroSdkDriver {
965
+ run_id: Some("r3".to_string()),
966
+ calls: ProviderSdkCalls::default(),
967
+ };
968
+ let report = run_comms_selftest(&ws, None, &driver).unwrap();
969
+ assert_eq!(report.contract_suite.status, CheckStatus::Deferred);
970
+ }
971
+
972
+ // ════════════════════════════════════════════════════════════════════════
973
+ // GROUP S — evaluate_idle_behavior: claimed_status normalization
974
+ // (IDLE/WORKING/RUNNING → not_challenged). diagnose/comms.py:50-94.
975
+ // ════════════════════════════════════════════════════════════════════════
976
+
977
+ #[test]
978
+ fn evaluate_idle_behavior_recognized_status_is_not_challenged() {
979
+ // diagnose/comms.py:86-94 — claimed_status in {IDLE,WORKING,RUNNING} (case-
980
+ // insensitive) and no driver result → status not_challenged, ok True.
981
+ let ws = tmp_ws("idleeval");
982
+ let driver = ZeroSdkDriver {
983
+ run_id: None,
984
+ calls: ProviderSdkCalls::default(),
985
+ };
986
+ let out = evaluate_idle_behavior(&ws, "w1", "IDLE", None, &driver).unwrap();
987
+ assert_eq!(out.status, CheckStatus::NotChallenged);
988
+ assert!(out.ok);
989
+ assert_eq!(out.agent_id, "w1");
990
+ }
991
+
992
+ // ════════════════════════════════════════════════════════════════════════
993
+ // GROUP T — deliver_pending_message claim atomicity + status machine.
994
+ // delivery.py:63-218. missing message / unknown recipient / already-claimed.
995
+ // ════════════════════════════════════════════════════════════════════════
996
+
997
+ #[test]
998
+ fn deliver_pending_message_missing_message_fails() {
999
+ // delivery.py:73-75 — no such message row → ok False, status failed,
1000
+ // reason message_missing.
1001
+ let ws = tmp_ws("delivermissing");
1002
+ let store = store_for(&ws);
1003
+ let log = EventLog::new(&ws);
1004
+ let t = NoopTransport;
1005
+ let out = deliver_pending_message(&ws, &store, &t, "nope", &log, &serde_json::json!({})).unwrap();
1006
+ assert!(!out.ok);
1007
+ assert_eq!(out.status, DeliveryStatus::Failed);
1008
+ }
1009
+
1010
+ // ════════════════════════════════════════════════════════════════════════
1011
+ // GROUP U — fire_due_scheduled_events: exhaustive ScheduledKind dispatch +
1012
+ // send dedupe. scheduler.py:41-121. Returns fired event-id list.
1013
+ // ════════════════════════════════════════════════════════════════════════
1014
+
1015
+ #[test]
1016
+ fn fire_due_scheduled_events_fires_each_scheduled_kind() {
1017
+ // SEEDED exhaustive-dispatch contract (scheduler.py:41-121): seed one due row
1018
+ // of EACH ScheduledKind (send / health_ping / trust_retry). The dispatch loop
1019
+ // must fire all three (one match arm per kind, no runtime fallback) and return
1020
+ // each fired event id. Probed golden: a due health_ping fires → marked 'done'
1021
+ // with {"ok":true,"status":"logged"} and its id appears in the fired list;
1022
+ // every due row's id is appended regardless of kind (scheduler.py:118).
1023
+ let ws = tmp_ws("scheduler");
1024
+ let store = store_for(&ws);
1025
+ let log = EventLog::new(&ws);
1026
+ let t = NoopTransport;
1027
+
1028
+ let send_id = seed_scheduled_event(
1029
+ &store,
1030
+ ScheduledKind::Send,
1031
+ "%w1",
1032
+ &serde_json::json!({"content": "ping", "attempt": 1, "max_attempts": 1}),
1033
+ );
1034
+ let ping_id =
1035
+ seed_scheduled_event(&store, ScheduledKind::HealthPing, "%w1", &serde_json::json!({}));
1036
+ let trust_id = seed_scheduled_event(
1037
+ &store,
1038
+ ScheduledKind::TrustRetry,
1039
+ "%w1",
1040
+ &serde_json::json!({"message_id": "m1", "attempt": 1, "max_attempts": 4}),
1041
+ );
1042
+
1043
+ let fired = fire_due_scheduled_events(&ws, &store, &t, &log).unwrap();
1044
+
1045
+ // Every seeded due kind must be dispatched and its id returned (exhaustive,
1046
+ // no kind silently dropped via a fallthrough).
1047
+ for id in [send_id, ping_id, trust_id] {
1048
+ assert!(
1049
+ fired.contains(&id),
1050
+ "scheduled event id {id} (each ScheduledKind) must fire; got {fired:?}"
1051
+ );
1052
+ }
1053
+ assert_eq!(fired.len(), 3, "exactly the three seeded due events fire, no extras");
1054
+ }
1055
+
1056
+ // ════════════════════════════════════════════════════════════════════════
1057
+ // GROUP V — retry_result_deliveries: re-route notify_failed watchers with
1058
+ // dedupe_reason rebind_retry. result_delivery.py:19-35.
1059
+ // ════════════════════════════════════════════════════════════════════════
1060
+
1061
+ #[test]
1062
+ fn retry_result_deliveries_retries_notify_failed_watcher() {
1063
+ // SEEDED contract (result_delivery.py:18-34): retry_result_deliveries scans
1064
+ // retryable_result_watchers (status in pending/notify_failed), resolves each
1065
+ // watcher's result via result_by_id, and re-routes through notify_result_watchers
1066
+ // with dedupe_reason="rebind_retry". Seed a notify_failed watcher + its matching
1067
+ // result row → the watcher IS retried and a WatcherNotice for it is returned.
1068
+ // Probed golden: notices == [{watcher_id, result_id, ok, ...}] for the seeded
1069
+ // watcher (delivery ok depends on full team state; the retry-was-attempted
1070
+ // contract is the invariant — an empty store would NOT exercise it).
1071
+ let ws = tmp_ws("retrydeliv");
1072
+ let store = store_for(&ws);
1073
+ let log = EventLog::new(&ws);
1074
+
1075
+ let rid = seed_result(&store, "res_r1", "t1", "alice", "success");
1076
+ let w = seed_watcher(
1077
+ &store, "w-failed", "team-a", "t1", "alice", "notify_failed", Some(&rid), None,
1078
+ );
1079
+
1080
+ let notices = retry_result_deliveries(&ws, &log).unwrap();
1081
+
1082
+ assert_eq!(notices.len(), 1, "the single notify_failed watcher must be retried");
1083
+ let notice = &notices[0];
1084
+ assert_eq!(notice.watcher_id, w, "the retried notice names the seeded watcher");
1085
+ assert_eq!(
1086
+ notice.result_id.as_deref(),
1087
+ Some(rid.as_str()),
1088
+ "retry resolves and carries the watcher's result_id (rebind_retry path)"
1089
+ );
1090
+ }
1091
+
1092
+ // ════════════════════════════════════════════════════════════════════════
1093
+ // GROUP W — collect_results_and_notify_watchers orchestration shape.
1094
+ // results.py:430-447.
1095
+ // ════════════════════════════════════════════════════════════════════════
1096
+
1097
+ #[test]
1098
+ fn collect_results_and_notify_watchers_returns_concrete_ok_shape() {
1099
+ // SEEDED contract (results.py:430-447): with NO uncollected results, collect() is
1100
+ // skipped (the `if store.results(uncollected_only=True)` guard is false), so the
1101
+ // result stays {ok:true, collected_results:[]}; a seeded notify_failed watcher
1102
+ // whose result_id has no matching results row is resolved to None by
1103
+ // retry_result_deliveries → skipped → notified stays []. Probed golden (against
1104
+ // exactly this fixture): {"ok": true, "collected": 0, "notified": []}.
1105
+ // (The previous test asserted only out["ok"].is_some(), trivially passed by
1106
+ // {"ok": false}.)
1107
+ let ws = tmp_ws("collectnotify");
1108
+ let store = store_for(&ws);
1109
+ let log = EventLog::new(&ws);
1110
+
1111
+ seed_watcher(
1112
+ &store, "w-orphan", "team-a", "t1", "alice", "notify_failed", Some("res_missing"), None,
1113
+ );
1114
+
1115
+ let out = collect_results_and_notify_watchers(&ws, &log).unwrap();
1116
+ assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true), "ok==true");
1117
+ assert_eq!(
1118
+ out.get("collected").and_then(|v| v.as_i64()),
1119
+ Some(0),
1120
+ "no uncollected results → collected==0"
1121
+ );
1122
+ assert_eq!(
1123
+ out.get("notified").and_then(|v| v.as_array()).map(|a| a.len()),
1124
+ Some(0),
1125
+ "orphan watcher (missing result) is skipped → notified empty"
1126
+ );
1127
+ }
1128
+
1129
+ // ════════════════════════════════════════════════════════════════════════
1130
+ // GROUP X — delivered_result_message content-level dedupe lookup +
1131
+ // result_id_from_text dual (scheduler send dedupe path). result_delivery.py:394.
1132
+ // ════════════════════════════════════════════════════════════════════════
1133
+
1134
+ #[test]
1135
+ fn delivered_result_message_none_in_fresh_store() {
1136
+ let ws = tmp_ws("delivdedupe");
1137
+ let store = store_for(&ws);
1138
+ let found = delivered_result_message(&store, "r1", None, None).unwrap();
1139
+ assert!(found.is_none());
1140
+ }
1141
+
1142
+ #[test]
1143
+ fn delivered_result_message_empty_result_id_is_none() {
1144
+ // result_delivery.py:401-402 — empty result_id short-circuits to None.
1145
+ let ws = tmp_ws("delivdedupe2");
1146
+ let store = store_for(&ws);
1147
+ let found = delivered_result_message(&store, "", None, None).unwrap();
1148
+ assert!(found.is_none());
1149
+ }
1150
+
1151
+ // ═══════════════════════════════════════════════════════════════════════════
1152
+ // collect #223 — task-scoped collect + send --task validation (RED).
1153
+ // ═══════════════════════════════════════════════════════════════════════════
1154
+
1155
+ // (c) a result whose task_id ∈ state.tasks collects as scope:"task"; the task row advances to
1156
+ // "done" (success → done, runtime.py:1066); results.collected ≥ 1. Proves the collect-READ works
1157
+ // once state.tasks is seeded — so the #223 fix target is the upstream seeding, not collect.
1158
+ #[test]
1159
+ fn collect_task_scoped_result_collects_and_marks_task_done() {
1160
+ let ws = tmp_ws("collecttask223");
1161
+ std::fs::write(ws.join("team.spec.yaml"), "version: 1\n").unwrap();
1162
+ crate::state::persist::save_runtime_state(
1163
+ &ws,
1164
+ &serde_json::json!({
1165
+ "session_name": "team-x",
1166
+ "agents": { "w1": { "provider": "codex" } },
1167
+ "tasks": [ { "id": "t2", "assignee": "w1", "title": "t2", "status": "pending" } ]
1168
+ }),
1169
+ ).unwrap();
1170
+ let store = store_for(&ws);
1171
+ seed_result(&store, "res_t2", "t2", "w1", "success");
1172
+
1173
+ let out = collect(&ws, None, false).unwrap();
1174
+ assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true), "no invalid → ok:true");
1175
+ let cr = out.get("collected_results").and_then(|v| v.as_array()).expect("collected_results");
1176
+ assert_eq!(cr.len(), 1, "the seeded t2 result must collect");
1177
+ assert_eq!(cr[0].get("scope").and_then(|v| v.as_str()), Some("task"), "t2 ∈ state.tasks → scope:task");
1178
+ assert_eq!(cr[0].get("task_id").and_then(|v| v.as_str()), Some("t2"));
1179
+ assert_eq!(cr[0].get("agent_id").and_then(|v| v.as_str()), Some("w1"));
1180
+ assert!(
1181
+ out.get("results").and_then(|r| r.get("collected")).and_then(|v| v.as_i64()).unwrap_or(0) >= 1,
1182
+ "results.collected must be ≥ 1"
1183
+ );
1184
+ let st = crate::state::persist::load_runtime_state(&ws).unwrap();
1185
+ let t2_status = st.get("tasks").and_then(|v| v.as_array())
1186
+ .and_then(|ts| ts.iter().find(|t| t.get("id").and_then(|v| v.as_str()) == Some("t2")))
1187
+ .and_then(|t| t.get("status")).and_then(|v| v.as_str());
1188
+ assert_eq!(t2_status, Some("done"), "success result → task row status 'done' (runtime.py:1066)");
1189
+ }
1190
+
1191
+ // (c-C1) collect OUTPUT shape: collected_results entries are the 8-KEY SUMMARY (NO inlined
1192
+ // envelope; carry summary+tests) and the full envelopes live in a SEPARATE top-level `collected`
1193
+ // list (golden results.py:86/131). Rust inlines `envelope`+`owner_team_id` and emits no
1194
+ // `collected` list → RED.
1195
+ #[test]
1196
+ fn collect_output_matches_golden_collected_shape() {
1197
+ let ws = tmp_ws("collectshape223");
1198
+ std::fs::write(ws.join("team.spec.yaml"), "version: 1\n").unwrap();
1199
+ crate::state::persist::save_runtime_state(
1200
+ &ws,
1201
+ &serde_json::json!({
1202
+ "session_name": "team-x",
1203
+ "agents": { "w1": { "provider": "codex" } },
1204
+ "tasks": [ { "id": "t2", "assignee": "w1", "title": "t2", "status": "pending" } ]
1205
+ }),
1206
+ ).unwrap();
1207
+ let store = store_for(&ws);
1208
+ seed_result(&store, "res_t2s", "t2", "w1", "success");
1209
+
1210
+ let out = collect(&ws, None, false).unwrap();
1211
+ let cr = out.get("collected_results").and_then(|v| v.as_array()).expect("collected_results");
1212
+ let e = &cr[0];
1213
+ // C1: collected_results entry is the 8-key SUMMARY — NO envelope inlined; carries summary+tests.
1214
+ assert!(e.get("envelope").is_none(),
1215
+ "collected_results entry must NOT inline `envelope` (golden 8-key summary); the full envelope belongs in `collected`. got {e:?}");
1216
+ assert!(e.get("summary").is_some() && e.get("tests").is_some(),
1217
+ "collected_results summary entry must carry `summary`+`tests` (golden results.py:131)");
1218
+ // C1: the full envelopes live in a separate top-level `collected` list.
1219
+ let collected = out.get("collected").and_then(|v| v.as_array())
1220
+ .expect("golden collect returns a top-level `collected` list of full envelopes");
1221
+ assert!(
1222
+ collected.first().and_then(|env| env.get("schema_version")).and_then(|v| v.as_str())
1223
+ == Some("result_envelope_v1"),
1224
+ "collected[0] must be the full result_envelope_v1 envelope; got {collected:?}"
1225
+ );
1226
+
1227
+ // ── STRENGTHENED (option-B byte-parity, leader-adjudicated 0700cff review) ──
1228
+ // D3 — task-scope collected_results entry must be EXACTLY the golden 8 keys, in order, NO task_status.
1229
+ let keys: Vec<&str> = e.as_object().expect("entry is an object").keys().map(String::as_str).collect();
1230
+ assert_eq!(
1231
+ keys,
1232
+ vec!["result_id", "task_id", "agent_id", "status", "summary", "tests", "created_at", "scope"],
1233
+ "collected_results entry must be EXACTLY the golden 8 keys in order (results.py:131; no task_status/envelope/owner_team_id); got {keys:?}"
1234
+ );
1235
+ // D1+D2 — collect RETURN top-level key order must match golden EXACTLY: delivered_messages BEFORE
1236
+ // invalid_results, AND a `coordinator` key (mirroring golden _ensure_coordinator_after_collect).
1237
+ let top: Vec<&str> = out.as_object().expect("collect result is an object").keys().map(String::as_str).collect();
1238
+ assert_eq!(
1239
+ top,
1240
+ vec!["ok", "collected", "collected_results", "delivered_messages", "invalid_results", "results", "state_file", "coordinator"],
1241
+ "collect return top-level key order must match golden return shape; got {top:?}"
1242
+ );
1243
+ }
1244
+
1245
+ // (d) send --task <unknown id> must RAISE golden "unknown task id" (runtime.py:1032 _find_task),
1246
+ // not silently create a message. Rust send_message attaches task_id without validating → Ok. RED.
1247
+ // block_until_delivered=false isolates the task-validation from any delivery side-effect.
1248
+ #[test]
1249
+ fn send_with_unknown_task_id_raises_unknown_task() {
1250
+ let ws = tmp_ws("sendunknowntask223");
1251
+ crate::state::persist::save_runtime_state(
1252
+ &ws,
1253
+ &serde_json::json!({
1254
+ "session_name": "team-x",
1255
+ "agents": { "w1": { "provider": "codex" } },
1256
+ "tasks": []
1257
+ }),
1258
+ ).unwrap();
1259
+ let _ = store_for(&ws);
1260
+ let opts = SendOptions {
1261
+ task_id: Some(crate::model::ids::TaskId::new("t2-unknown")),
1262
+ block_until_delivered: false,
1263
+ ..SendOptions::default()
1264
+ };
1265
+ let out = send_message(&ws, &MessageTarget::Single("w1".to_string()), "go", &opts);
1266
+ match out {
1267
+ Err(e) => {
1268
+ // SURFACED error = the CLI `error` field = CliError::from(MessagingError).to_string()
1269
+ // (to_payload uses self.to_string(), types.rs:59). Must EQUAL golden's bare message —
1270
+ // NO "validation:" variant prefix (golden runtime.py:1032 surfaces str(exc)).
1271
+ let surfaced = crate::cli::CliError::from(e).to_string();
1272
+ assert_eq!(
1273
+ surfaced, "unknown task id: t2-unknown",
1274
+ "surfaced CLI error must EQUAL golden's message with NO variant prefix; got {surfaced:?}"
1275
+ );
1276
+ }
1277
+ Ok(o) => panic!(
1278
+ "send --task <unknown id> must RAISE 'unknown task id' (golden runtime.py:1032 _find_task), \
1279
+ not silently create a message; got Ok({o:?})"
1280
+ ),
1281
+ }
1282
+ }
1283
+
1284
+ // ════════════════════════════════════════════════════════════════════════
1285
+ // P0 REGRESSION (0700cff "send 0 bytes, nothing queued" / coordinator never delivers).
1286
+ // golden gates the unknown-task RAISE on route_task_id (send.py:204 `if task_id and route_task_id`);
1287
+ // delivery/fanout/internal sends pass route_task_id=False (internal_delivery.py:44, send.py:412/481)
1288
+ // → the task is a label, NOT validated. 0700cff's UNCONDITIONAL task_exists gate broke every
1289
+ // task-tagged delivery/internal send at CREATION time. The gate the OfflineTransport tests missed.
1290
+ // ════════════════════════════════════════════════════════════════════════
1291
+
1292
+ // (a) [REGRESSION GATE] route_task_id=false + task_id NOT in state.tasks → send SUCCEEDS and the
1293
+ // message is QUEUED (real create path; no transport). Must NOT raise "unknown task id".
1294
+ #[test]
1295
+ fn send_route_task_id_false_skips_task_validation_and_queues() {
1296
+ let ws = tmp_ws("sendroutefalse");
1297
+ crate::state::persist::save_runtime_state(
1298
+ &ws,
1299
+ &serde_json::json!({ "session_name": "team-x", "agents": { "w1": { "provider": "codex" } }, "tasks": [] }),
1300
+ ).unwrap();
1301
+ let _ = store_for(&ws);
1302
+ let opts = SendOptions {
1303
+ task_id: Some(crate::model::ids::TaskId::new("t-not-seeded")),
1304
+ route_task_id: false,
1305
+ block_until_delivered: false,
1306
+ ..SendOptions::default()
1307
+ };
1308
+ let out = send_message(&ws, &MessageTarget::Single("w1".to_string()), "deliver me", &opts)
1309
+ .expect("route_task_id=false must NOT validate the task — golden delivery/internal path queues regardless of state.tasks");
1310
+ assert!(
1311
+ out.message_id.is_some(),
1312
+ "the message must be CREATED (message_id present) on the route_task_id=false path; got {out:?}"
1313
+ );
1314
+ // real queue verification (not an Ok shell): the message landed in w1's inbox.
1315
+ let inbox = store_for(&ws).inbox("w1", 10, None).expect("inbox");
1316
+ assert!(
1317
+ !inbox.is_empty(),
1318
+ "the task-tagged message must be QUEUED for w1 on the delivery/internal path; inbox empty (0 bytes queued = the P0)"
1319
+ );
1320
+ }
1321
+
1322
+ // (c) [LOCK] route_task_id=true + task_id IN state.tasks → send SUCCEEDS (routing happy-path).
1323
+ #[test]
1324
+ fn send_route_task_id_true_known_task_succeeds() {
1325
+ let ws = tmp_ws("sendrouteknown");
1326
+ crate::state::persist::save_runtime_state(
1327
+ &ws,
1328
+ &serde_json::json!({
1329
+ "session_name": "team-x",
1330
+ "agents": { "w1": { "provider": "codex" } },
1331
+ "tasks": [ { "id": "t-known", "assignee": "w1", "title": "t", "status": "pending" } ]
1332
+ }),
1333
+ ).unwrap();
1334
+ let _ = store_for(&ws);
1335
+ let opts = SendOptions {
1336
+ task_id: Some(crate::model::ids::TaskId::new("t-known")),
1337
+ route_task_id: true,
1338
+ block_until_delivered: false,
1339
+ ..SendOptions::default()
1340
+ };
1341
+ let out = send_message(&ws, &MessageTarget::Single("w1".to_string()), "go", &opts)
1342
+ .expect("route_task_id=true with a KNOWN task must succeed");
1343
+ assert!(out.message_id.is_some(), "known-task routing send must create the message; got {out:?}");
1344
+ }
1345
+
1346
+ // ════════════════════════════════════════════════════════════════════════
1347
+ // R8 byte-parity (leader attach requeue, advisor-ruled + e3eac28-reconciled):
1348
+ // drive a watcher to delivery_exhausted via notify_result_watchers (attempts>=MAX) — proving the
1349
+ // requeue input is REAL (non-空过) — then attach-requeue and assert the golden observable contract:
1350
+ // D2 status: delivery_exhausted -> notify_failed (golden result_watchers.py:95), NOT 'pending'.
1351
+ // D1 ✦ team-scoped + unnotified SELECTION (anti cross-team pollution / CP-1) — KEEP.
1352
+ // D3 result_watcher.requeued payload == golden attach form {watcher_id, trigger:"attach_leader", new_pane_id}.
1353
+ // (D4 leader_receiver.requeued_exhausted_watchers + D6 string return are the attach-wrapper/CLI layer —
1354
+ // lease.rs:140 + cli/mod.rs:1088 — flagged for the porter; D5 event-layer is internal/optional.)
1355
+ // ════════════════════════════════════════════════════════════════════════
1356
+ #[test]
1357
+ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
1358
+ let ws = tmp_ws("r8requeue");
1359
+ let store = store_for(&ws);
1360
+ let log = EventLog::new(&ws);
1361
+ let team = TeamKey::new("team-a");
1362
+ let pane = PaneId::new("%leader-new");
1363
+
1364
+ // --- Sub-A: DRIVE w-r8 (team-a) to delivery_exhausted via notify_result_watchers (attempts>=MAX) ---
1365
+ let rid = seed_result(&store, "res_r8", "t1", "alice", "success");
1366
+ seed_watcher(&store, "w-r8", "team-a", "t1", "alice", "pending", Some(&rid), None);
1367
+ // attempts are EVENT-counted (result_watcher.notify_failed/retry_notified) — seed MAX prior failures.
1368
+ for n in 0..u64::from(RESULT_DELIVERY_MAX_ATTEMPTS) {
1369
+ log.write(
1370
+ "result_watcher.notify_failed",
1371
+ json(serde_json::json!({"watcher_id": "w-r8", "result_id": rid.as_str(), "status": "notify_failed", "error": "x", "n": n})),
1372
+ ).unwrap();
1373
+ }
1374
+ let result_env = json(serde_json::json!({"result_id": rid.as_str(), "task_id": "t1", "agent_id": "alice"}));
1375
+ let watcher_view = json(serde_json::json!({
1376
+ "watcher_id": "w-r8", "task_id": "t1", "agent_id": "alice",
1377
+ "created_at": "2026-01-01T00:00:00Z", "owner_team_id": "team-a",
1378
+ "leader_id": "leader", "result_id": rid.as_str()
1379
+ }));
1380
+ notify_result_watchers(&ws, &result_env, &log, Some(&[watcher_view]), None).unwrap();
1381
+ let (driven, _) = watcher_state(&store, "w-r8");
1382
+ assert_eq!(driven, "delivery_exhausted",
1383
+ "PRECONDITION: notify_result_watchers at attempts>=MAX must persist delivery_exhausted (watchers.rs:161-168) — \
1384
+ proves the attach-requeue input is real, not 空过");
1385
+
1386
+ // selection-lock fixtures: cross-team exhausted + notified exhausted (Gap-32) + pending.
1387
+ let team_b = seed_watcher(&store, "w-teamb", "team-b", "t2", "bob", "delivery_exhausted", Some("res_b"), None);
1388
+ let notif = seed_watcher(&store, "w-notified", "team-a", "t3", "carol", "delivery_exhausted", Some("res_c"), Some("msg_done"));
1389
+ seed_watcher(&store, "w-pending", "team-a", "t4", "dave", "pending", Some("res_d"), None);
1390
+
1391
+ // --- Sub-B: attach requeue (golden contract) ---
1392
+ let requeued = requeue_delivery_exhausted_watchers(&ws, &store, &log, &team, &pane).unwrap();
1393
+
1394
+ // D2: team-a exhausted -> notify_failed (NOT pending).
1395
+ let (st_a, _) = watcher_state(&store, "w-r8");
1396
+ assert_eq!(st_a, "notify_failed",
1397
+ "D2: attach requeue must flip delivery_exhausted -> 'notify_failed' (golden result_watchers.py:95), not 'pending'");
1398
+ // D1 ✦ team-scoped: cross-team exhausted watcher must NOT requeue onto team-a's pane.
1399
+ let (st_b, _) = watcher_state(&store, &team_b);
1400
+ assert_eq!(st_b, "delivery_exhausted",
1401
+ "D1 ✦: team-scoped selection — a team-b exhausted watcher must NOT be requeued by a team-a attach (anti cross-team pollution / CP-1)");
1402
+ // Gap-32: a notified watcher is never requeued; its notified_message_id survives.
1403
+ let (st_n, nid) = watcher_state(&store, &notif);
1404
+ assert_eq!(st_n, "delivery_exhausted", "Gap-32: notified watcher not requeued");
1405
+ assert_eq!(nid.as_deref(), Some("msg_done"), "Gap-32: notified_message_id preserved");
1406
+ // only the team-a unnotified exhausted watcher requeues.
1407
+ let ids: Vec<&str> = requeued.iter().map(|n| n.watcher_id.as_str()).collect();
1408
+ assert_eq!(ids, vec!["w-r8"], "only team-a unnotified delivery_exhausted watcher requeues");
1409
+
1410
+ // D3: result_watcher.requeued payload == golden ATTACH form {watcher_id, trigger, new_pane_id}.
1411
+ let events = log.tail(0).unwrap();
1412
+ let ev = events.iter().rev()
1413
+ .find(|e| e.get("event").and_then(|v| v.as_str()) == Some("result_watcher.requeued"))
1414
+ .expect("result_watcher.requeued event");
1415
+ let keys: std::collections::BTreeSet<&str> = ev.as_object().unwrap().keys()
1416
+ .map(String::as_str).filter(|k| *k != "ts" && *k != "event").collect();
1417
+ let expected: std::collections::BTreeSet<&str> = ["watcher_id", "trigger", "new_pane_id"].into_iter().collect();
1418
+ assert_eq!(keys, expected,
1419
+ "D3: result_watcher.requeued must be golden ATTACH form {{watcher_id, trigger, new_pane_id}} (leader/__init__.py:46-50), not claim-style; got {keys:?}");
1420
+ assert_eq!(ev.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
1421
+ assert_eq!(ev.get("new_pane_id").and_then(|v| v.as_str()), Some("%leader-new"));
1422
+ }