@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,767 @@
1
+ //! step 7 · message_store — core message lifecycle over `team.db`.
2
+ //!
3
+ //! Truth source (READ-ONLY): `team-agent-public` @ v0.2.11,
4
+ //! `team_agent/message_store/core.py` + `leader_notification_log.py`.
5
+ //! Builds on step 3 [`crate::db::schema`] (DDL/migration already create all 8
6
+ //! tables incl. `messages` + `leader_notification_log`).
7
+ //!
8
+ //! SCOPE (this slice) = the semantic-tricky core lifecycle:
9
+ //! 1. [`MessageStore::create_message`] — insert a fresh `msg_<hex12>` row,
10
+ //! status `accepted` (`core.py:71-114`).
11
+ //! 2. [`MessageStore::claim_for_delivery`] — atomic single-winner claim; flips
12
+ //! an eligible row to `target_resolved`, bumps `delivery_attempts`, returns
13
+ //! whether THIS caller won (`rowcount == 1`) (`core.py:190-205`).
14
+ //! 3. [`MessageStore::mark`] — status state machine; the only guard is that
15
+ //! `acknowledged` is STICKY against delivery statuses (injected/visible/
16
+ //! submitted/submitted_unverified/delivered) but NOT against others
17
+ //! (e.g. `failed` overwrites) (`core.py:116-138`, the SQL CASE).
18
+ //! 4. [`MessageStore::claim_leader_notification_delivery`] — exactly-once dedup
19
+ //! at the leader-injection boundary. Dedup key = `(result_id, owner_team_id,
20
+ //! owner_epoch)` via the PK + `INSERT OR IGNORE`; **`leader_session_uuid` is
21
+ //! NOT part of the key** (nullable audit metadata only). When `owner_epoch`
22
+ //! is `None` it is derived from the uuid via [`legacy_epoch_from_uuid`]
23
+ //! (`leader_notification_log.py:30-101,145-147`).
24
+ //!
25
+ //! DEFERRED (note, don't build) — follow-on RED slices: scheduled events, token
26
+ //! accounting (incl. the `delivery_tokens` side-effect of `mark`), agent health,
27
+ //! result watchers, results store, `artifact_refs` payloads, busy-retry timing.
28
+ //!
29
+ //! §10: pure-ish lib over SQLite — no panic on malformed input; every path
30
+ //! returns `Result<_, MessageStoreError>`.
31
+
32
+ use std::path::Path;
33
+ use std::sync::atomic::{AtomicU64, Ordering};
34
+ use std::time::{SystemTime, UNIX_EPOCH};
35
+
36
+ use rusqlite::{params, OptionalExtension};
37
+ use thiserror::Error;
38
+
39
+ static MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(0);
40
+
41
+ #[derive(Debug, Error)]
42
+ pub enum MessageStoreError {
43
+ #[error("db: {0}")]
44
+ Db(#[from] crate::db::DbError),
45
+ #[error("sqlite: {0}")]
46
+ Sqlite(#[from] rusqlite::Error),
47
+ #[error("io: {0}")]
48
+ Io(#[from] std::io::Error),
49
+ }
50
+
51
+ /// Outcome of [`MessageStore::claim_leader_notification_delivery`]
52
+ /// (`leader_notification_log.py:73-101`). `status` is `"claimed_by_you"` for the
53
+ /// winner, `"already_notified_by"` for a deduped loser; `notified_message_id` is
54
+ /// always the WINNER's proposed id (a loser sees the first winner's id).
55
+ #[derive(Debug, Clone, PartialEq, Eq)]
56
+ pub struct NotificationClaim {
57
+ pub status: String,
58
+ pub notified_message_id: String,
59
+ }
60
+
61
+ /// Args for [`MessageStore::claim_leader_notification_delivery`]
62
+ /// (`leader_notification_log.py:30-40`).
63
+ #[derive(Debug, Clone)]
64
+ pub struct NotificationClaimParams<'a> {
65
+ pub result_id: &'a str,
66
+ pub owner_team_id: Option<&'a str>,
67
+ /// `None` → derived from `leader_session_uuid` via [`legacy_epoch_from_uuid`].
68
+ pub owner_epoch: Option<i64>,
69
+ pub leader_session_uuid: Option<&'a str>,
70
+ pub proposed_message_id: &'a str,
71
+ pub envelope_hash: &'a str,
72
+ pub pane_id: Option<&'a str>,
73
+ }
74
+
75
+ /// `leader_notification_log._legacy_epoch_from_uuid` (line 145-147):
76
+ /// `int(zlib.crc32(str(uuid or "").encode("utf-8")) & 0x7FFFFFFF)`.
77
+ pub fn legacy_epoch_from_uuid(leader_session_uuid: Option<&str>) -> i64 {
78
+ let mut crc = 0xFFFF_FFFFu32;
79
+ for byte in leader_session_uuid.unwrap_or("").as_bytes() {
80
+ crc ^= u32::from(*byte);
81
+ for _ in 0..8 {
82
+ let mask = 0u32.wrapping_sub(crc & 1);
83
+ crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
84
+ }
85
+ }
86
+ i64::from((!crc) & 0x7FFF_FFFF)
87
+ }
88
+
89
+ /// SQLite-backed message store (`core.py:MessageStore`). `open` mirrors
90
+ /// `__init__`: `runtime_dir(workspace)/team.db`, mkdir parents, init schema.
91
+ pub struct MessageStore {
92
+ #[allow(dead_code)]
93
+ path: std::path::PathBuf,
94
+ }
95
+
96
+ impl MessageStore {
97
+ /// `MessageStore.__init__` (`core.py:51-55`): `workspace/.team/runtime/team.db`,
98
+ /// create parents, `initialize_schema`.
99
+ pub fn open(workspace: &Path) -> Result<Self, MessageStoreError> {
100
+ let runtime_dir = workspace.join(".team").join("runtime");
101
+ std::fs::create_dir_all(&runtime_dir)?;
102
+ let path = runtime_dir.join("team.db");
103
+ let conn = crate::db::schema::open_db(&path)?;
104
+ crate::db::schema::initialize_schema(&conn, Some(&path))?;
105
+ Ok(Self { path })
106
+ }
107
+
108
+ /// Absolute path to the backing `team.db` (test/diagnostic accessor).
109
+ pub fn db_path(&self) -> &Path {
110
+ &self.path
111
+ }
112
+
113
+ /// `create_message` (`core.py:71-114`). Returns `msg_<uuid4 hex[:12]>`; inserts
114
+ /// a row with `status='accepted'`, `requires_ack` as 0/1 int, `artifact_refs`
115
+ /// defaulting to `'[]'`, `delivery_attempts=0`, timestamps = now.
116
+ #[allow(clippy::too_many_arguments)]
117
+ pub fn create_message(
118
+ &self,
119
+ task_id: Option<&str>,
120
+ sender: &str,
121
+ recipient: &str,
122
+ content: &str,
123
+ reply_to: Option<&str>,
124
+ requires_ack: bool,
125
+ owner_team_id: Option<&str>,
126
+ ) -> Result<String, MessageStoreError> {
127
+ let conn = crate::db::schema::open_db(&self.path)?;
128
+ let message_id = next_message_id();
129
+ let now = now_ts();
130
+ conn.execute(
131
+ "insert into messages(
132
+ message_id, owner_team_id, task_id, sender, recipient, reply_to, requires_ack,
133
+ status, content, artifact_refs, created_at, updated_at, delivered_at,
134
+ acknowledged_at, error, delivery_attempts
135
+ ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'accepted', ?8, '[]', ?9, ?9, null, null, null, 0)",
136
+ params![
137
+ message_id,
138
+ owner_team_id,
139
+ task_id,
140
+ sender,
141
+ recipient,
142
+ reply_to,
143
+ if requires_ack { 1 } else { 0 },
144
+ content,
145
+ now,
146
+ ],
147
+ )?;
148
+ Ok(message_id)
149
+ }
150
+
151
+ /// Caller-supplied-id variant of [`create_message`] (CR-015/054 — `--message-id`).
152
+ /// Inserts exactly the given `message_id` instead of generating one. The store
153
+ /// PK is `message_id`, so a repeat with the same id is rejected by SQLite; the
154
+ /// caller is expected to gate via [`message_exists`] first to map collision to
155
+ /// a typed `Duplicate` refusal rather than an opaque sqlite error.
156
+ ///
157
+ /// [`message_exists`]: Self::message_exists
158
+ #[allow(clippy::too_many_arguments)]
159
+ pub fn create_message_with_id(
160
+ &self,
161
+ message_id: &str,
162
+ task_id: Option<&str>,
163
+ sender: &str,
164
+ recipient: &str,
165
+ content: &str,
166
+ reply_to: Option<&str>,
167
+ requires_ack: bool,
168
+ owner_team_id: Option<&str>,
169
+ ) -> Result<String, MessageStoreError> {
170
+ let conn = crate::db::schema::open_db(&self.path)?;
171
+ let now = now_ts();
172
+ conn.execute(
173
+ "insert into messages(
174
+ message_id, owner_team_id, task_id, sender, recipient, reply_to, requires_ack,
175
+ status, content, artifact_refs, created_at, updated_at, delivered_at,
176
+ acknowledged_at, error, delivery_attempts
177
+ ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'accepted', ?8, '[]', ?9, ?9, null, null, null, 0)",
178
+ params![
179
+ message_id,
180
+ owner_team_id,
181
+ task_id,
182
+ sender,
183
+ recipient,
184
+ reply_to,
185
+ if requires_ack { 1 } else { 0 },
186
+ content,
187
+ now,
188
+ ],
189
+ )?;
190
+ Ok(message_id.to_string())
191
+ }
192
+
193
+ /// `true` iff a `messages` row with this `message_id` already exists. Used by
194
+ /// the send path to map a caller-key collision (CR-015/054) to a `Duplicate`
195
+ /// refusal before attempting an insert that would otherwise fail on the PK.
196
+ pub fn message_exists(&self, message_id: &str) -> Result<bool, MessageStoreError> {
197
+ let conn = crate::db::schema::open_db(&self.path)?;
198
+ let row: Option<i64> = conn
199
+ .query_row(
200
+ "select 1 from messages where message_id = ?1",
201
+ params![message_id],
202
+ |row| row.get(0),
203
+ )
204
+ .optional()?;
205
+ Ok(row.is_some())
206
+ }
207
+
208
+ /// `mark` (`core.py:116-138`) — the messages.status state machine (this slice
209
+ /// excludes the `delivery_tokens` side-effect, which is deferred).
210
+ pub fn mark(&self, message_id: &str, status: &str, error: Option<&str>) -> Result<(), MessageStoreError> {
211
+ let conn = crate::db::schema::open_db(&self.path)?;
212
+ let now = now_ts();
213
+ conn.execute(
214
+ "update messages
215
+ set status = case
216
+ when status = 'acknowledged'
217
+ and ?2 in ('injected', 'visible', 'submitted', 'submitted_unverified', 'delivered')
218
+ then status
219
+ else ?2
220
+ end,
221
+ updated_at = ?3,
222
+ delivered_at = case
223
+ when ?2 in ('injected', 'visible', 'submitted', 'submitted_unverified', 'delivered')
224
+ then ?3
225
+ else delivered_at
226
+ end,
227
+ acknowledged_at = case when ?2 = 'acknowledged' then ?3 else acknowledged_at end,
228
+ error = coalesce(?4, error)
229
+ where message_id = ?1",
230
+ params![message_id, status, now, error],
231
+ )?;
232
+ Ok(())
233
+ }
234
+
235
+ /// `claim_for_delivery` (`core.py:190-205`): atomic single-winner claim. Flips an
236
+ /// eligible row (status ∈ pending/accepted/queued_until_idle/queued_until_start/
237
+ /// queued_stopped/queued_pane_missing) to `target_resolved`, `delivery_attempts +=
238
+ /// 1`. Returns `true` iff THIS update matched exactly one row.
239
+ pub fn claim_for_delivery(&self, message_id: &str) -> Result<bool, MessageStoreError> {
240
+ let conn = crate::db::schema::open_db(&self.path)?;
241
+ let rows = conn.execute(
242
+ "update messages
243
+ set status = 'target_resolved',
244
+ delivery_attempts = delivery_attempts + 1,
245
+ updated_at = ?2
246
+ where message_id = ?1
247
+ and status in (
248
+ 'pending', 'accepted', 'queued_until_idle', 'queued_until_start',
249
+ 'queued_stopped', 'queued_pane_missing'
250
+ )",
251
+ params![message_id, now_ts()],
252
+ )?;
253
+ Ok(rows == 1)
254
+ }
255
+
256
+ /// Read inbox rows for an agent. This projection intentionally has no owner-team
257
+ /// filter when the caller does not provide one: legacy/CLI inbox must surface
258
+ /// NULL-owner messages stored for the agent.
259
+ pub fn inbox(
260
+ &self,
261
+ agent_id: &str,
262
+ limit: usize,
263
+ owner_team_id: Option<&str>,
264
+ ) -> Result<Vec<serde_json::Value>, MessageStoreError> {
265
+ let conn = crate::db::schema::open_db(&self.path)?;
266
+ let limit = i64::try_from(limit).unwrap_or(i64::MAX);
267
+ let sql = match owner_team_id {
268
+ Some(_) => {
269
+ "select message_id, owner_team_id, task_id, sender, recipient, reply_to, requires_ack,
270
+ status, content, artifact_refs, created_at, updated_at, delivered_at,
271
+ acknowledged_at, error, delivery_attempts
272
+ from messages
273
+ where (sender = ?1 or recipient = ?1) and owner_team_id = ?3
274
+ order by created_at desc
275
+ limit ?2"
276
+ }
277
+ None => {
278
+ "select message_id, owner_team_id, task_id, sender, recipient, reply_to, requires_ack,
279
+ status, content, artifact_refs, created_at, updated_at, delivered_at,
280
+ acknowledged_at, error, delivery_attempts
281
+ from messages
282
+ where sender = ?1 or recipient = ?1
283
+ order by created_at desc
284
+ limit ?2"
285
+ }
286
+ };
287
+ let rows = match owner_team_id {
288
+ Some(team) => {
289
+ let mut stmt = conn.prepare(sql)?;
290
+ let values = stmt
291
+ .query_map(params![agent_id, limit, team], row_to_message_value)?
292
+ .collect::<Result<Vec<_>, _>>()?;
293
+ values
294
+ }
295
+ None => {
296
+ let mut stmt = conn.prepare(sql)?;
297
+ let values = stmt
298
+ .query_map(params![agent_id, limit], row_to_message_value)?
299
+ .collect::<Result<Vec<_>, _>>()?;
300
+ values
301
+ }
302
+ };
303
+ Ok(rows.into_iter().rev().collect())
304
+ }
305
+
306
+ /// Allow direct peer messages in both directions. Golden stores `(a,b)` and
307
+ /// `(b,a)` so either sender/recipient lookup can use a single ordered key.
308
+ pub fn allow_peer(&self, a: &str, b: &str) -> Result<(), MessageStoreError> {
309
+ let conn = crate::db::schema::open_db(&self.path)?;
310
+ let now = now_ts();
311
+ conn.execute(
312
+ "insert or ignore into peer_allowlist(a, b, created_at) values (?1, ?2, ?3)",
313
+ params![a, b, now.as_str()],
314
+ )?;
315
+ conn.execute(
316
+ "insert or ignore into peer_allowlist(a, b, created_at) values (?1, ?2, ?3)",
317
+ params![b, a, now.as_str()],
318
+ )?;
319
+ Ok(())
320
+ }
321
+
322
+ /// `claim_leader_notification_delivery` (`leader_notification_log.py:30-101`):
323
+ /// `INSERT OR IGNORE` on PK `(result_id, owner_team_id, owner_epoch)`. rowcount==1
324
+ /// → `claimed_by_you`; else read the existing winner row → `already_notified_by`.
325
+ /// `owner_team_id` defaults to `""`; `owner_epoch=None` → [`legacy_epoch_from_uuid`].
326
+ pub fn claim_leader_notification_delivery(
327
+ &self,
328
+ params: NotificationClaimParams<'_>,
329
+ ) -> Result<NotificationClaim, MessageStoreError> {
330
+ let conn = crate::db::schema::open_db(&self.path)?;
331
+ let owner_team_id = params.owner_team_id.unwrap_or("");
332
+ let owner_epoch = match params.owner_epoch {
333
+ Some(epoch) => epoch,
334
+ None => legacy_epoch_from_uuid(params.leader_session_uuid),
335
+ };
336
+ let rows = conn.execute(
337
+ "insert or ignore into leader_notification_log(
338
+ result_id, owner_team_id, owner_epoch, leader_session_uuid, notified_message_id,
339
+ notified_at, leader_pane_id_at_notify, envelope_content_hash
340
+ ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
341
+ params![
342
+ params.result_id,
343
+ owner_team_id,
344
+ owner_epoch,
345
+ params.leader_session_uuid,
346
+ params.proposed_message_id,
347
+ now_ts(),
348
+ params.pane_id,
349
+ params.envelope_hash,
350
+ ],
351
+ )?;
352
+ if rows == 1 {
353
+ return Ok(NotificationClaim {
354
+ status: "claimed_by_you".to_string(),
355
+ notified_message_id: params.proposed_message_id.to_string(),
356
+ });
357
+ }
358
+
359
+ let notified_message_id = conn
360
+ .query_row(
361
+ "select notified_message_id from leader_notification_log
362
+ where result_id = ?1 and owner_team_id = ?2 and owner_epoch = ?3",
363
+ params![params.result_id, owner_team_id, owner_epoch],
364
+ |row| row.get::<_, String>(0),
365
+ )
366
+ .optional()?
367
+ .ok_or(rusqlite::Error::QueryReturnedNoRows)?;
368
+ Ok(NotificationClaim {
369
+ status: "already_notified_by".to_string(),
370
+ notified_message_id,
371
+ })
372
+ }
373
+ }
374
+
375
+ fn row_to_message_value(row: &rusqlite::Row<'_>) -> rusqlite::Result<serde_json::Value> {
376
+ Ok(serde_json::json!({
377
+ "message_id": row.get::<_, String>(0)?,
378
+ "owner_team_id": row.get::<_, Option<String>>(1)?,
379
+ "task_id": row.get::<_, Option<String>>(2)?,
380
+ "sender": row.get::<_, Option<String>>(3)?,
381
+ "recipient": row.get::<_, Option<String>>(4)?,
382
+ "reply_to": row.get::<_, Option<String>>(5)?,
383
+ "requires_ack": row.get::<_, Option<i64>>(6)?,
384
+ "status": row.get::<_, Option<String>>(7)?,
385
+ "content": row.get::<_, Option<String>>(8)?,
386
+ "artifact_refs": row.get::<_, Option<String>>(9)?,
387
+ "created_at": row.get::<_, Option<String>>(10)?,
388
+ "updated_at": row.get::<_, Option<String>>(11)?,
389
+ "delivered_at": row.get::<_, Option<String>>(12)?,
390
+ "acknowledged_at": row.get::<_, Option<String>>(13)?,
391
+ "error": row.get::<_, Option<String>>(14)?,
392
+ "delivery_attempts": row.get::<_, Option<i64>>(15)?,
393
+ }))
394
+ }
395
+
396
+ fn now_ts() -> String {
397
+ chrono::Utc::now().to_rfc3339()
398
+ }
399
+
400
+ fn next_message_id() -> String {
401
+ let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
402
+ Ok(duration) => {
403
+ let low = duration.as_nanos() & u128::from(u64::MAX);
404
+ u64::try_from(low).unwrap_or(0)
405
+ }
406
+ Err(_) => 0,
407
+ };
408
+ let counter = MESSAGE_COUNTER.fetch_add(1, Ordering::Relaxed);
409
+ let pid = u64::from(std::process::id());
410
+ let value = nanos ^ counter.rotate_left(17) ^ pid.rotate_left(32);
411
+ format!("msg_{:012x}", value & 0xFFFF_FFFF_FFFF)
412
+ }
413
+
414
+ #[cfg(test)]
415
+ mod tests {
416
+ #![allow(clippy::unwrap_used)]
417
+ use super::*;
418
+ use crate::db;
419
+ use rusqlite::Connection;
420
+ use std::path::PathBuf;
421
+ use std::sync::atomic::{AtomicU32, Ordering};
422
+
423
+ static COUNTER: AtomicU32 = AtomicU32::new(0);
424
+
425
+ fn temp_workspace() -> PathBuf {
426
+ let n = COUNTER.fetch_add(1, Ordering::Relaxed);
427
+ let ws = std::env::temp_dir().join(format!("ta_rs_msgstore_{}_{}", std::process::id(), n));
428
+ std::fs::create_dir_all(&ws).unwrap();
429
+ ws
430
+ }
431
+
432
+ fn store() -> MessageStore {
433
+ MessageStore::open(&temp_workspace()).unwrap()
434
+ }
435
+
436
+ /// Fresh read connection onto the store's team.db (asserts DB-STATE parity
437
+ /// independently of the store's own connection).
438
+ fn read(store: &MessageStore) -> Connection {
439
+ db::schema::open_db(store.db_path()).unwrap()
440
+ }
441
+
442
+ fn col_str(conn: &Connection, mid: &str, col: &str) -> Option<String> {
443
+ // `col` is a fixed test literal, never user input.
444
+ conn.query_row(
445
+ &format!("select {col} from messages where message_id = ?1"),
446
+ [mid],
447
+ |r| r.get::<_, Option<String>>(0),
448
+ )
449
+ .unwrap()
450
+ }
451
+
452
+ fn col_i64(conn: &Connection, mid: &str, col: &str) -> i64 {
453
+ conn.query_row(
454
+ &format!("select {col} from messages where message_id = ?1"),
455
+ [mid],
456
+ |r| r.get(0),
457
+ )
458
+ .unwrap()
459
+ }
460
+
461
+ fn status_of(conn: &Connection, mid: &str) -> String {
462
+ col_str(conn, mid, "status").unwrap()
463
+ }
464
+
465
+ /// (result_id, owner_team_id, owner_epoch, leader_session_uuid, notified_message_id)
466
+ /// ordered by notified_at — the dedup-relevant projection of leader_notification_log.
467
+ fn notif_rows(conn: &Connection) -> Vec<(String, String, i64, Option<String>, String)> {
468
+ let mut stmt = conn
469
+ .prepare(
470
+ "select result_id, owner_team_id, owner_epoch, leader_session_uuid, notified_message_id \
471
+ from leader_notification_log order by notified_at",
472
+ )
473
+ .unwrap();
474
+ stmt.query_map([], |r| {
475
+ Ok((
476
+ r.get::<_, String>(0)?,
477
+ r.get::<_, String>(1)?,
478
+ r.get::<_, i64>(2)?,
479
+ r.get::<_, Option<String>>(3)?,
480
+ r.get::<_, String>(4)?,
481
+ ))
482
+ })
483
+ .unwrap()
484
+ .collect::<Result<_, _>>()
485
+ .unwrap()
486
+ }
487
+
488
+ // ───────────────────────────── create_message ─────────────────────────────
489
+
490
+ #[test]
491
+ fn create_message_inserts_accepted_row() {
492
+ let s = store();
493
+ let mid = s
494
+ .create_message(Some("task_1"), "leader", "alice", "hello", None, true, Some("team_A"))
495
+ .unwrap();
496
+ assert!(mid.starts_with("msg_"), "id: {mid}");
497
+ assert_eq!(mid.len(), 16, "msg_ + 12 hex");
498
+
499
+ let c = read(&s);
500
+ assert_eq!(status_of(&c, &mid), "accepted");
501
+ assert_eq!(col_str(&c, &mid, "task_id").as_deref(), Some("task_1"));
502
+ assert_eq!(col_str(&c, &mid, "sender").as_deref(), Some("leader"));
503
+ assert_eq!(col_str(&c, &mid, "recipient").as_deref(), Some("alice"));
504
+ assert_eq!(col_str(&c, &mid, "owner_team_id").as_deref(), Some("team_A"));
505
+ assert_eq!(col_str(&c, &mid, "content").as_deref(), Some("hello"));
506
+ assert_eq!(col_str(&c, &mid, "artifact_refs").as_deref(), Some("[]"));
507
+ assert_eq!(col_str(&c, &mid, "reply_to"), None);
508
+ assert_eq!(col_i64(&c, &mid, "requires_ack"), 1);
509
+ assert_eq!(col_i64(&c, &mid, "delivery_attempts"), 0);
510
+ assert_eq!(col_str(&c, &mid, "delivered_at"), None);
511
+ assert_eq!(col_str(&c, &mid, "acknowledged_at"), None);
512
+ assert_eq!(col_str(&c, &mid, "error"), None);
513
+ }
514
+
515
+ #[test]
516
+ fn create_message_no_task_no_owner_ack_false() {
517
+ let s = store();
518
+ let mid = s.create_message(None, "leader", "bob", "hi", None, false, None).unwrap();
519
+ let c = read(&s);
520
+ assert_eq!(col_str(&c, &mid, "task_id"), None);
521
+ assert_eq!(col_str(&c, &mid, "owner_team_id"), None);
522
+ assert_eq!(col_i64(&c, &mid, "requires_ack"), 0);
523
+ assert_eq!(status_of(&c, &mid), "accepted");
524
+ }
525
+
526
+ // ───────────────────────── claim_for_delivery (atomic claim) ─────────────────────────
527
+
528
+ #[test]
529
+ fn claim_for_delivery_first_caller_wins() {
530
+ let s = store();
531
+ let mid = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
532
+
533
+ assert!(s.claim_for_delivery(&mid).unwrap(), "accepted is eligible → claim wins");
534
+ let c = read(&s);
535
+ assert_eq!(status_of(&c, &mid), "target_resolved");
536
+ assert_eq!(col_i64(&c, &mid, "delivery_attempts"), 1);
537
+ }
538
+
539
+ #[test]
540
+ fn claim_for_delivery_second_caller_loses_and_state_unchanged() {
541
+ // Atomic single-winner: once target_resolved, a re-claim returns false and
542
+ // must NOT bump delivery_attempts again.
543
+ let s = store();
544
+ let mid = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
545
+ assert!(s.claim_for_delivery(&mid).unwrap());
546
+ assert!(!s.claim_for_delivery(&mid).unwrap(), "already target_resolved → no second winner");
547
+
548
+ let c = read(&s);
549
+ assert_eq!(status_of(&c, &mid), "target_resolved");
550
+ assert_eq!(col_i64(&c, &mid, "delivery_attempts"), 1);
551
+ }
552
+
553
+ #[test]
554
+ fn claim_for_delivery_nonexistent_is_false() {
555
+ let s = store();
556
+ assert!(!s.claim_for_delivery("msg_doesnotexist").unwrap());
557
+ }
558
+
559
+ #[test]
560
+ fn claim_for_delivery_ineligible_status_is_false() {
561
+ // 'failed' is not in the eligible set → claim returns false, status unchanged.
562
+ let s = store();
563
+ let mid = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
564
+ s.mark(&mid, "failed", Some("boom")).unwrap();
565
+ assert!(!s.claim_for_delivery(&mid).unwrap());
566
+ assert_eq!(status_of(&read(&s), &mid), "failed");
567
+ }
568
+
569
+ // ───────────────────────────── mark state machine ─────────────────────────────
570
+
571
+ #[test]
572
+ fn mark_injected_sets_status_and_delivered_at() {
573
+ let s = store();
574
+ let mid = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
575
+ s.mark(&mid, "injected", None).unwrap();
576
+ let c = read(&s);
577
+ assert_eq!(status_of(&c, &mid), "injected");
578
+ assert!(col_str(&c, &mid, "delivered_at").is_some(), "delivered_at set for injected");
579
+ assert_eq!(col_str(&c, &mid, "acknowledged_at"), None);
580
+ }
581
+
582
+ #[test]
583
+ fn mark_acknowledged_is_sticky_against_delivery_statuses() {
584
+ // CASE guard: once acknowledged, marks to injected/visible/... are ignored.
585
+ let s = store();
586
+ let mid = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
587
+ s.mark(&mid, "acknowledged", None).unwrap();
588
+ assert_eq!(status_of(&read(&s), &mid), "acknowledged");
589
+ assert!(col_str(&read(&s), &mid, "acknowledged_at").is_some());
590
+
591
+ s.mark(&mid, "injected", None).unwrap();
592
+ assert_eq!(status_of(&read(&s), &mid), "acknowledged", "injected ignored after ack");
593
+ s.mark(&mid, "visible", None).unwrap();
594
+ assert_eq!(status_of(&read(&s), &mid), "acknowledged", "visible ignored after ack");
595
+ }
596
+
597
+ #[test]
598
+ fn mark_acknowledged_then_failed_overwrites() {
599
+ // 'failed' is NOT in the guarded delivery set → it overwrites acknowledged.
600
+ let s = store();
601
+ let mid = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
602
+ s.mark(&mid, "acknowledged", None).unwrap();
603
+ s.mark(&mid, "failed", Some("x")).unwrap();
604
+ assert_eq!(status_of(&read(&s), &mid), "failed");
605
+ }
606
+
607
+ #[test]
608
+ fn mark_overwrites_for_non_acknowledged() {
609
+ let s = store();
610
+ let mid = s.create_message(Some("t"), "s", "r", "c", None, true, None).unwrap();
611
+ s.mark(&mid, "injected", None).unwrap();
612
+ s.mark(&mid, "visible", None).unwrap();
613
+ assert_eq!(status_of(&read(&s), &mid), "visible");
614
+ }
615
+
616
+ // ───────────────────── leader_notification_log dedup ─────────────────────
617
+
618
+ fn params<'a>(
619
+ result_id: &'a str,
620
+ owner_team_id: Option<&'a str>,
621
+ owner_epoch: Option<i64>,
622
+ uuid: Option<&'a str>,
623
+ proposed: &'a str,
624
+ ) -> NotificationClaimParams<'a> {
625
+ NotificationClaimParams {
626
+ result_id,
627
+ owner_team_id,
628
+ owner_epoch,
629
+ leader_session_uuid: uuid,
630
+ proposed_message_id: proposed,
631
+ envelope_hash: "h1",
632
+ pane_id: Some("%1"),
633
+ }
634
+ }
635
+
636
+ #[test]
637
+ fn leader_notification_dedup_key_excludes_session_uuid() {
638
+ // SAME (result_id, owner_team_id, owner_epoch) but DIFFERENT leader_session_uuid
639
+ // and proposed id → second is deduped to the first winner.
640
+ let s = store();
641
+ let r1 = s
642
+ .claim_leader_notification_delivery(params("res_1", Some("team_A"), Some(7), Some("uuid-AAA"), "msg_notif_1"))
643
+ .unwrap();
644
+ assert_eq!(r1.status, "claimed_by_you");
645
+ assert_eq!(r1.notified_message_id, "msg_notif_1");
646
+
647
+ let r2 = s
648
+ .claim_leader_notification_delivery(params("res_1", Some("team_A"), Some(7), Some("uuid-BBB"), "msg_notif_2"))
649
+ .unwrap();
650
+ assert_eq!(r2.status, "already_notified_by");
651
+ assert_eq!(r2.notified_message_id, "msg_notif_1", "loser sees first winner's id, not its own");
652
+
653
+ // Exactly ONE row — carrying the FIRST caller's uuid (uuid not in the key).
654
+ assert_eq!(
655
+ notif_rows(&read(&s)),
656
+ vec![(
657
+ "res_1".to_string(),
658
+ "team_A".to_string(),
659
+ 7,
660
+ Some("uuid-AAA".to_string()),
661
+ "msg_notif_1".to_string()
662
+ )]
663
+ );
664
+ }
665
+
666
+ #[test]
667
+ fn leader_notification_different_epoch_is_a_new_claim() {
668
+ let s = store();
669
+ s.claim_leader_notification_delivery(params("res_1", Some("team_A"), Some(7), Some("uuid-AAA"), "msg_notif_1"))
670
+ .unwrap();
671
+ let r3 = s
672
+ .claim_leader_notification_delivery(params("res_1", Some("team_A"), Some(8), Some("uuid-AAA"), "msg_notif_3"))
673
+ .unwrap();
674
+ assert_eq!(r3.status, "claimed_by_you", "different owner_epoch → different PK → new claim");
675
+
676
+ let rows = notif_rows(&read(&s));
677
+ assert_eq!(rows.len(), 2);
678
+ assert_eq!(rows[0].2, 7);
679
+ assert_eq!(rows[1].2, 8);
680
+ assert_eq!(rows[1].4, "msg_notif_3");
681
+ }
682
+
683
+ #[test]
684
+ fn leader_notification_none_epoch_derives_from_uuid() {
685
+ // owner_epoch=None → epoch derived from uuid. Same uuid dedups; different uuid
686
+ // yields a different derived epoch → a new claim.
687
+ let s = store();
688
+ let a1 = s
689
+ .claim_leader_notification_delivery(params("res_2", Some("team_B"), None, Some("uuid-AAA"), "m1"))
690
+ .unwrap();
691
+ assert_eq!(a1.status, "claimed_by_you");
692
+
693
+ let a2 = s
694
+ .claim_leader_notification_delivery(params("res_2", Some("team_B"), None, Some("uuid-AAA"), "m2"))
695
+ .unwrap();
696
+ assert_eq!(a2.status, "already_notified_by");
697
+ assert_eq!(a2.notified_message_id, "m1");
698
+
699
+ let a3 = s
700
+ .claim_leader_notification_delivery(params("res_2", Some("team_B"), None, Some("uuid-BBB"), "m3"))
701
+ .unwrap();
702
+ assert_eq!(a3.status, "claimed_by_you", "different uuid → different derived epoch → new claim");
703
+
704
+ // Stored epochs equal the crc32 derivation of each uuid.
705
+ let rows = notif_rows(&read(&s));
706
+ assert_eq!(rows.len(), 2);
707
+ assert_eq!(rows[0].2, 926068568); // crc32('uuid-AAA') & 0x7FFFFFFF
708
+ assert_eq!(rows[1].2, 122688376); // crc32('uuid-BBB') & 0x7FFFFFFF
709
+ }
710
+
711
+ #[test]
712
+ fn legacy_epoch_from_uuid_crc32_golden() {
713
+ assert_eq!(legacy_epoch_from_uuid(None), 0);
714
+ assert_eq!(legacy_epoch_from_uuid(Some("")), 0);
715
+ assert_eq!(legacy_epoch_from_uuid(Some("uuid-AAA")), 926068568);
716
+ assert_eq!(legacy_epoch_from_uuid(Some("uuid-BBB")), 122688376);
717
+ }
718
+
719
+ #[test]
720
+ fn allow_peer_inserts_bidirectional_rows_idempotently() {
721
+ let s = store();
722
+ s.allow_peer("alice", "bob").unwrap();
723
+ s.allow_peer("alice", "bob").unwrap();
724
+
725
+ let c = read(&s);
726
+ let mut rows = c
727
+ .prepare("select a, b from peer_allowlist order by a, b")
728
+ .unwrap()
729
+ .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
730
+ .unwrap()
731
+ .collect::<Result<Vec<_>, _>>()
732
+ .unwrap();
733
+ rows.sort();
734
+ assert_eq!(
735
+ rows,
736
+ vec![
737
+ ("alice".to_string(), "bob".to_string()),
738
+ ("bob".to_string(), "alice".to_string()),
739
+ ]
740
+ );
741
+ }
742
+
743
+ // ════════════════════════ FIX-LOOP (wave-1) RED test ════════════════════════
744
+ // B1: mark() error column = coalesce(?, error) — a NEW error overwrites, but a
745
+ // mark WITHOUT an error must PRESERVE the existing error (current impl clobbers
746
+ // it to NULL). Golden /tmp/probe_b1.py vs team-agent-public v0.2.11.
747
+
748
+ #[test]
749
+ fn fix_b1_mark_preserves_existing_error_when_none_given() {
750
+ let s = store();
751
+ let mid = s.create_message(Some("t"), "a", "b", "c", None, true, None).unwrap();
752
+
753
+ s.mark(&mid, "failed", Some("boom")).unwrap();
754
+ assert_eq!(col_str(&read(&s), &mid, "error").as_deref(), Some("boom"));
755
+
756
+ // Non-error mark to a delivery status: status/delivered_at advance, but error
757
+ // must remain 'boom' (coalesce(NULL, error)), NOT be clobbered to NULL.
758
+ s.mark(&mid, "injected", None).unwrap();
759
+ let c = read(&s);
760
+ assert_eq!(status_of(&c, &mid), "injected");
761
+ assert_eq!(col_str(&c, &mid, "error").as_deref(), Some("boom"), "existing error must survive a no-error mark");
762
+
763
+ // A NEW error overwrites.
764
+ s.mark(&mid, "failed", Some("second")).unwrap();
765
+ assert_eq!(col_str(&read(&s), &mid, "error").as_deref(), Some("second"));
766
+ }
767
+ }