@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,989 @@
1
+ use super::*;
2
+
3
+ // ───────────────────────────────────────────────────────────────────────
4
+ // classify_first_send_at — _classify_first_send_at (orchestration.py:404)
5
+ // 严格分类,绝不靠 truthiness。golden 实跑(见任务记录):
6
+ // None -> absent ; "" / 0 / False / "null" / "not-a-date" / 123 / [] / {} -> corrupt
7
+ // "2026-05-27T10:00:00+00:00" / "2026-05-27T10:00:00" -> valid
8
+ // 这是 Route B resume-atomicity 的命门:garbage 必须在 teardown 之前 hard-refuse。
9
+ // ───────────────────────────────────────────────────────────────────────
10
+
11
+ #[test]
12
+ fn classify_first_send_at_none_is_absent_not_corrupt() {
13
+ // None / 缺失 = 从未交互,可丢弃 fresh —— 不能误判成 corrupt。
14
+ assert_eq!(classify_first_send_at(&json!(null)), FirstSendAtState::Absent);
15
+ }
16
+
17
+ #[test]
18
+ fn classify_first_send_at_empty_string_is_corrupt_not_absent() {
19
+ // 陷阱核心:Python truthiness 会把 "" 当 falsey/absent;契约要求 corrupt。
20
+ assert_eq!(classify_first_send_at(&json!("")), FirstSendAtState::Corrupt);
21
+ }
22
+
23
+ #[test]
24
+ fn classify_first_send_at_zero_and_false_are_corrupt() {
25
+ // 0 / False 非 str → corrupt(绝不靠 bool/int truthiness 当 absent)。
26
+ assert_eq!(classify_first_send_at(&json!(0)), FirstSendAtState::Corrupt);
27
+ assert_eq!(classify_first_send_at(&json!(false)), FirstSendAtState::Corrupt);
28
+ assert_eq!(classify_first_send_at(&json!(123)), FirstSendAtState::Corrupt);
29
+ }
30
+
31
+ #[test]
32
+ fn classify_first_send_at_literal_null_string_is_corrupt() {
33
+ // 字面量字符串 "null"(非 ISO)→ corrupt,而 JSON null → absent(上面已测)。
34
+ assert_eq!(classify_first_send_at(&json!("null")), FirstSendAtState::Corrupt);
35
+ assert_eq!(classify_first_send_at(&json!("not-a-date")), FirstSendAtState::Corrupt);
36
+ }
37
+
38
+ #[test]
39
+ fn classify_first_send_at_non_string_containers_are_corrupt() {
40
+ assert_eq!(classify_first_send_at(&json!([])), FirstSendAtState::Corrupt);
41
+ assert_eq!(classify_first_send_at(&json!({})), FirstSendAtState::Corrupt);
42
+ }
43
+
44
+ #[test]
45
+ fn classify_first_send_at_valid_iso_with_and_without_tz() {
46
+ // datetime.fromisoformat 接受带 / 不带时区的 ISO-8601。
47
+ assert_eq!(
48
+ classify_first_send_at(&json!("2026-05-27T10:00:00+00:00")),
49
+ FirstSendAtState::Valid
50
+ );
51
+ assert_eq!(
52
+ classify_first_send_at(&json!("2026-05-27T10:00:00")),
53
+ FirstSendAtState::Valid
54
+ );
55
+ }
56
+
57
+ // ───────────────────────────────────────────────────────────────────────
58
+ // PlanId::parse — sanitize_plan_id (orchestrator/state.py:18)
59
+ // _PLAN_ID_RE = ^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$ (首字符必字母数字;总长 1..=64)
60
+ // golden 实跑:
61
+ // "abc"/"a.b-c_1"/"A"/"1plan"/64×"x" -> OK
62
+ // "_bad"/".dot"/"../etc"/"a/b"/"a b"/""/None/65×"x" -> InvalidPlanId
63
+ // newtype 防路径穿越:无 "/"、无空格。
64
+ // ───────────────────────────────────────────────────────────────────────
65
+
66
+ #[test]
67
+ fn plan_id_accepts_alnum_and_inner_dot_dash_underscore() {
68
+ assert_eq!(PlanId::parse("abc").unwrap().as_str(), "abc");
69
+ assert_eq!(PlanId::parse("a.b-c_1").unwrap().as_str(), "a.b-c_1");
70
+ assert_eq!(PlanId::parse("A").unwrap().as_str(), "A");
71
+ assert_eq!(PlanId::parse("1plan").unwrap().as_str(), "1plan");
72
+ }
73
+
74
+ #[test]
75
+ fn plan_id_rejects_leading_underscore_or_dot() {
76
+ // 首字符必须 [A-Za-z0-9] —— "_bad" / ".dot" 被拒(防 ".." 穿越家族)。
77
+ assert!(matches!(
78
+ PlanId::parse("_bad"),
79
+ Err(LifecycleError::InvalidPlanId(_))
80
+ ));
81
+ assert!(matches!(
82
+ PlanId::parse(".dot"),
83
+ Err(LifecycleError::InvalidPlanId(_))
84
+ ));
85
+ }
86
+
87
+ #[test]
88
+ fn plan_id_rejects_path_traversal_and_separators() {
89
+ for bad in ["../etc", "a/b", "a b", ""] {
90
+ assert!(
91
+ matches!(PlanId::parse(bad), Err(LifecycleError::InvalidPlanId(_))),
92
+ "expected InvalidPlanId for {bad:?}"
93
+ );
94
+ }
95
+ }
96
+
97
+ #[test]
98
+ fn plan_id_length_boundary_64_ok_65_rejected() {
99
+ // {0,63} 量词 + 1 首字符 = 最长 64;65 越界。
100
+ let ok = "x".repeat(64);
101
+ let bad = "x".repeat(65);
102
+ assert_eq!(PlanId::parse(&ok).unwrap().as_str(), ok);
103
+ assert!(matches!(
104
+ PlanId::parse(&bad),
105
+ Err(LifecycleError::InvalidPlanId(_))
106
+ ));
107
+ }
108
+
109
+ #[test]
110
+ fn plan_id_error_message_names_the_offending_value_and_grammar() {
111
+ // exact message 契约:含 repr 后的非法值 + 文法 + "no slashes ... path-traversal"。
112
+ let err = PlanId::parse("a/b").unwrap_err();
113
+ let msg = err.to_string();
114
+ assert!(msg.contains("invalid plan id"), "got: {msg}");
115
+ assert!(msg.contains("a/b"), "must name offending value, got: {msg}");
116
+ assert!(
117
+ msg.contains("no slashes") || msg.contains("path-traversal"),
118
+ "must explain why, got: {msg}"
119
+ );
120
+ }
121
+
122
+ // ───────────────────────────────────────────────────────────────────────
123
+ // PlanCondition::parse — _CONDITION_RE / _is_supported_condition (plan.py:9)
124
+ // _CONDITION_RE = ^\s*report_result\.(\w+)\s*==\s*['"]([^'"]+)['"]\s*$
125
+ // golden 实跑(_is_supported_condition):
126
+ // "any"/"ANY"/" any " -> True (Any)
127
+ // "report_result.foo == 'bar'" -> True (FieldEq foo bar)
128
+ // "report_result.foo=='bar'"(无空格) -> True
129
+ // 'report_result.s == "dq"'(双引号) -> True
130
+ // "report_result. == 'y'"(空 field)/"foo == 'bar'"/""/"report_result.s == bar"(裸值) -> False
131
+ // 封闭文法,越界 → InvalidPlan(不做自由表达式)。
132
+ // ───────────────────────────────────────────────────────────────────────
133
+
134
+ #[test]
135
+ fn plan_condition_any_case_insensitive_and_trimmed() {
136
+ assert_eq!(PlanCondition::parse("any").unwrap(), PlanCondition::Any);
137
+ assert_eq!(PlanCondition::parse("ANY").unwrap(), PlanCondition::Any);
138
+ assert_eq!(PlanCondition::parse(" any ").unwrap(), PlanCondition::Any);
139
+ }
140
+
141
+ #[test]
142
+ fn plan_condition_field_eq_extracts_field_and_value() {
143
+ assert_eq!(
144
+ PlanCondition::parse("report_result.foo == 'bar'").unwrap(),
145
+ PlanCondition::FieldEq {
146
+ field: "foo".to_string(),
147
+ value: "bar".to_string()
148
+ }
149
+ );
150
+ }
151
+
152
+ #[test]
153
+ fn plan_condition_field_eq_tolerates_no_spaces_and_double_quotes() {
154
+ assert_eq!(
155
+ PlanCondition::parse("report_result.foo=='bar'").unwrap(),
156
+ PlanCondition::FieldEq {
157
+ field: "foo".to_string(),
158
+ value: "bar".to_string()
159
+ }
160
+ );
161
+ assert_eq!(
162
+ PlanCondition::parse("report_result.s == \"dq\"").unwrap(),
163
+ PlanCondition::FieldEq {
164
+ field: "s".to_string(),
165
+ value: "dq".to_string()
166
+ }
167
+ );
168
+ }
169
+
170
+ #[test]
171
+ fn plan_condition_rejects_out_of_grammar() {
172
+ // 空 field / 缺 report_result 前缀 / 裸值(无引号) / 空串 → InvalidPlan。
173
+ for bad in [
174
+ "report_result. == 'y'",
175
+ "foo == 'bar'",
176
+ "",
177
+ "report_result.s == bar",
178
+ ] {
179
+ assert!(
180
+ matches!(PlanCondition::parse(bad), Err(LifecycleError::InvalidPlan(_))),
181
+ "expected InvalidPlan for {bad:?}"
182
+ );
183
+ }
184
+ }
185
+
186
+ // ───────────────────────────────────────────────────────────────────────
187
+ // resolve_display_backend — display/backend.py
188
+ // 默认 adaptive;非默认非静默(non_default=true 触发 display.backend_resolved)。
189
+ // ───────────────────────────────────────────────────────────────────────
190
+
191
+ #[test]
192
+ fn resolve_backend_defaults_to_adaptive_when_none_requested() {
193
+ let r = resolve_display_backend(None, None);
194
+ assert_eq!(r.backend, DisplayBackend::Adaptive);
195
+ assert!(!r.non_default, "默认 adaptive 不应标记 non_default");
196
+ }
197
+
198
+ #[test]
199
+ fn resolve_backend_requested_overrides_and_marks_non_default() {
200
+ // 显式 requested 非 adaptive → 采用且 non_default=true(非静默发事件)。
201
+ let r = resolve_display_backend(Some(DisplayBackend::GhosttyWindow), None);
202
+ assert_eq!(r.backend, DisplayBackend::GhosttyWindow);
203
+ assert!(r.non_default);
204
+ }
205
+
206
+ #[test]
207
+ fn resolve_backend_recorded_used_when_no_request() {
208
+ // 无 requested 但 state 有 recorded → 复用 recorded(restart 一致性)。
209
+ let r = resolve_display_backend(None, Some(DisplayBackend::GhosttyWorkspace));
210
+ assert_eq!(r.backend, DisplayBackend::GhosttyWorkspace);
211
+ assert!(r.non_default);
212
+ }
213
+
214
+ // ───────────────────────────────────────────────────────────────────────
215
+ // probe_display_capabilities — display/adaptive.py:31 (C13)
216
+ // 分支只看 probe 结果,NOT cfg!(target_os)。golden 实跑:
217
+ // linux no-tmux : in_tmux=false, adaptive_status=leader_not_in_tmux, reason=leader_not_in_tmux
218
+ // linux in-tmux : in_tmux=true, adaptive_status=available(opened), reason=None, caps both true
219
+ // windows/wsl : in_tmux=false, adaptive_status=not_implemented_this_platform, caps both false
220
+ // blocked 是 typed outcome(DisplayStatus::Blocked + reason),NOT error。
221
+ // 注:RUST 入口签名为 probe_display_capabilities(workspace) —— 它内部读环境/平台;
222
+ // 本测试在干净 CI workspace 上跑,只断言"不是 Err(平台降级是 typed outcome)"
223
+ // 以及 reason 必属封闭集(若有)。
224
+ // ───────────────────────────────────────────────────────────────────────
225
+
226
+ #[test]
227
+ fn probe_never_errors_platform_degradation_is_typed_outcome() {
228
+ // C13:能力性降级绝不走 LifecycleError —— 它是 DisplayStatus::Blocked + reason。
229
+ let ws = temp_ws();
230
+ let probe = probe_display_capabilities(&ws).expect("probe 平台降级必须是 typed outcome,不是 Err");
231
+ // 若 blocked,reason 必属 AdaptiveBlockReason 封闭集且与 status 一致。
232
+ match probe.adaptive_status {
233
+ DisplayStatus::Blocked => assert!(
234
+ probe.reason.is_some(),
235
+ "blocked 必带 reason(C16 封闭集)"
236
+ ),
237
+ DisplayStatus::Opened => assert!(
238
+ probe.reason.is_none(),
239
+ "opened 不应带 block reason"
240
+ ),
241
+ DisplayStatus::Stopped => {}
242
+ }
243
+ }
244
+
245
+ #[test]
246
+ fn probe_caps_consistent_with_in_tmux() {
247
+ // caps.adaptive_display == (in_tmux && 平台支持);不在 tmux → caps 全 false。
248
+ let ws = temp_ws();
249
+ let probe = probe_display_capabilities(&ws).expect("probe 不应 Err");
250
+ if !probe.in_tmux {
251
+ assert!(!probe.caps.adaptive_display);
252
+ assert!(!probe.caps.tmux_append_windows);
253
+ }
254
+ }
255
+
256
+ // ───────────────────────────────────────────────────────────────────────
257
+ // AdaptiveBlockReason 封闭集 — ADAPTIVE_BLOCK_REASONS (adaptive.py:21)
258
+ // golden 实跑(6 个,无更多无更少):
259
+ // aggregator_rebuild_failed, leader_not_in_tmux, not_implemented_this_platform,
260
+ // split_failed, window_create_failed, worker_session_missing
261
+ // serde rename = snake_case,JSON 名与 Python 字符串一致。
262
+ // ───────────────────────────────────────────────────────────────────────
263
+
264
+ /// 封闭集 ALL — 一处穷举所有 AdaptiveBlockReason 变体(新增第 7 个变体会编译错此 match,
265
+ /// 把 cardinality 锁成编译期事实)。golden ADAPTIVE_BLOCK_REASONS 恰 6 个(adaptive.py:21)。
266
+ const ALL_BLOCK_REASONS: [AdaptiveBlockReason; 6] = [
267
+ AdaptiveBlockReason::LeaderNotInTmux,
268
+ AdaptiveBlockReason::SplitFailed,
269
+ AdaptiveBlockReason::WindowCreateFailed,
270
+ AdaptiveBlockReason::WorkerSessionMissing,
271
+ AdaptiveBlockReason::NotImplementedThisPlatform,
272
+ AdaptiveBlockReason::AggregatorRebuildFailed,
273
+ ];
274
+
275
+ #[test]
276
+ fn adaptive_block_reason_serde_names_match_python_and_set_is_exactly_six() {
277
+ // (a) 每个变体 wire-name 与 Python 字符串一致。
278
+ let expected = [
279
+ "\"leader_not_in_tmux\"",
280
+ "\"split_failed\"",
281
+ "\"window_create_failed\"",
282
+ "\"worker_session_missing\"",
283
+ "\"not_implemented_this_platform\"",
284
+ "\"aggregator_rebuild_failed\"",
285
+ ];
286
+ for (variant, want) in ALL_BLOCK_REASONS.iter().zip(expected.iter()) {
287
+ assert_eq!(&serde_json::to_string(variant).unwrap(), want);
288
+ }
289
+ // (b) 封闭集 CARDINALITY == 6(无多无少;rogue 第 7 变体使 ALL_BLOCK_REASONS match 编译失败)。
290
+ let mut names: Vec<String> = ALL_BLOCK_REASONS
291
+ .iter()
292
+ .map(|r| serde_json::to_string(r).unwrap())
293
+ .collect();
294
+ names.sort();
295
+ names.dedup();
296
+ assert_eq!(names.len(), 6, "ADAPTIVE_BLOCK_REASONS 恰 6 个,无重复无遗漏");
297
+ }
298
+
299
+ #[test]
300
+ fn adaptive_blocked_out_of_set_reason_bottoms_to_aggregator_rebuild_failed() {
301
+ // adaptive.py 兜底:越界 / aggregator 重建失败 → AggregatorRebuildFailed(documented overflow)。
302
+ // RED:经真实路径 —— probe 在 blocked 平台 open_worker_displays,worker display 必属封闭集;
303
+ // 这里用 probe.reason 越界场景驱动 open_worker_displays 而非静态枚举,捕获 reason 发射回归。
304
+ let ws = temp_ws();
305
+ let probe = DisplayProbe {
306
+ in_tmux: true,
307
+ platform: "linux".to_string(),
308
+ leader_session: Some(sess("leader")),
309
+ leader_pane: None,
310
+ caps: CapsFlags {
311
+ tmux_append_windows: true,
312
+ adaptive_display: true,
313
+ },
314
+ // 模拟 aggregator 重建失败封闭:open 必把 worker display 兜底成 AggregatorRebuildFailed。
315
+ adaptive_status: DisplayStatus::Blocked,
316
+ reason: Some(AdaptiveBlockReason::AggregatorRebuildFailed),
317
+ };
318
+ let rep = open_worker_displays(&ws, &sess("team-a"), DisplayBackend::Adaptive, &probe)
319
+ .expect("C14:显示失败不阻塞 readiness");
320
+ for (id, d) in rep.displays.iter() {
321
+ match d {
322
+ WorkerDisplay::Blocked { reason } => assert!(
323
+ ALL_BLOCK_REASONS.contains(reason),
324
+ "worker {id} 的 block reason 必属封闭集:{reason:?}"
325
+ ),
326
+ other => panic!("blocked probe 下 worker {id} 应 Blocked:{other:?}"),
327
+ }
328
+ }
329
+ }
330
+
331
+ #[test]
332
+ fn start_mode_serde_names_match_python_start_mode_strings() {
333
+ // 低价值 wire-format 守卫:start_mode ∈ {"resumed","fresh","fresh_after_missing_rollout","noop"}。
334
+ assert_eq!(serde_json::to_string(&StartMode::Resumed).unwrap(), "\"resumed\"");
335
+ assert_eq!(serde_json::to_string(&StartMode::Fresh).unwrap(), "\"fresh\"");
336
+ assert_eq!(
337
+ serde_json::to_string(&StartMode::FreshAfterMissingRollout).unwrap(),
338
+ "\"fresh_after_missing_rollout\""
339
+ );
340
+ assert_eq!(serde_json::to_string(&StartMode::Noop).unwrap(), "\"noop\"");
341
+ }
342
+
343
+ // ───────────────────────────────────────────────────────────────────────
344
+ // decide_start_mode — bug-085 四象限 (start.py:66-69 + 179-190)
345
+ // golden 实跑(PYTHONPATH=… python3 /tmp/x.py,_resume_rollout_missing + start_mode 逻辑):
346
+ // codex sess rollout-present any-fresh -> resumed
347
+ // codex sess rollout-MISSING !allow_fresh -> resumed (随后真实 resume 失败)
348
+ // codex sess rollout-MISSING allow_fresh -> fresh_after_missing_rollout ← bug-085 唯一臂
349
+ // codex no-sess any -> fresh
350
+ // claude(非codex) sess rollout-missing fresh -> resumed (非 codex 永不"缺 rollout")
351
+ // claude no-sess -> fresh
352
+ // 这是 bug-085 把 start_mode 分类从 start_agent 的 lock+spawn 全路径剥离出来的命门。
353
+ // ───────────────────────────────────────────────────────────────────────
354
+
355
+ fn sid(s: &str) -> SessionId {
356
+ SessionId::new(s)
357
+ }
358
+ fn rp(p: &str) -> RolloutPath {
359
+ RolloutPath::new(p)
360
+ }
361
+
362
+ #[test]
363
+ fn decide_start_mode_codex_missing_rollout_with_allow_fresh_is_fresh_after_missing() {
364
+ // bug-085 唯一 FreshAfterMissingRollout 臂:codex + 有 session_id + rollout 缺 + allow_fresh。
365
+ assert_eq!(
366
+ decide_start_mode("codex", Some(&sid("s1")), None, false, true),
367
+ StartMode::FreshAfterMissingRollout
368
+ );
369
+ // rollout 路径存在但文件已不在,同样命中。
370
+ assert_eq!(
371
+ decide_start_mode("codex", Some(&sid("s1")), Some(&rp("/gone.jsonl")), false, true),
372
+ StartMode::FreshAfterMissingRollout
373
+ );
374
+ }
375
+
376
+ #[test]
377
+ fn decide_start_mode_codex_missing_rollout_without_allow_fresh_stays_resumed() {
378
+ // 关键陷阱:rollout 缺但 !allow_fresh → 仍 Resumed(start.py 不擅自丢 context)。
379
+ assert_eq!(
380
+ decide_start_mode("codex", Some(&sid("s1")), None, false, false),
381
+ StartMode::Resumed
382
+ );
383
+ }
384
+
385
+ #[test]
386
+ fn decide_start_mode_codex_rollout_present_is_resumed_regardless_of_fresh() {
387
+ assert_eq!(
388
+ decide_start_mode("codex", Some(&sid("s1")), Some(&rp("/r.jsonl")), true, false),
389
+ StartMode::Resumed
390
+ );
391
+ assert_eq!(
392
+ decide_start_mode("codex", Some(&sid("s1")), Some(&rp("/r.jsonl")), true, true),
393
+ StartMode::Resumed
394
+ );
395
+ }
396
+
397
+ #[test]
398
+ fn decide_start_mode_no_session_is_fresh() {
399
+ assert_eq!(
400
+ decide_start_mode("codex", None, None, false, true),
401
+ StartMode::Fresh
402
+ );
403
+ assert_eq!(
404
+ decide_start_mode("codex", None, None, false, false),
405
+ StartMode::Fresh
406
+ );
407
+ }
408
+
409
+ #[test]
410
+ fn decide_start_mode_non_codex_never_fresh_after_missing_rollout() {
411
+ // 非 codex provider:rollout 概念不适用,_resume_rollout_missing 恒 false。
412
+ assert_eq!(
413
+ decide_start_mode("claude", Some(&sid("s1")), None, false, true),
414
+ StartMode::Resumed
415
+ );
416
+ assert_eq!(
417
+ decide_start_mode("claude", None, None, false, true),
418
+ StartMode::Fresh
419
+ );
420
+ }
421
+
422
+ #[test]
423
+ fn resume_decision_serde_names_match_python() {
424
+ // 低价值 wire-format 守卫:_emit_resume_decisions: "resume"|"fresh_start"|"refuse"。
425
+ assert_eq!(serde_json::to_string(&ResumeDecision::Resume).unwrap(), "\"resume\"");
426
+ assert_eq!(
427
+ serde_json::to_string(&ResumeDecision::FreshStart).unwrap(),
428
+ "\"fresh_start\""
429
+ );
430
+ assert_eq!(serde_json::to_string(&ResumeDecision::Refuse).unwrap(), "\"refuse\"");
431
+ }
432
+
433
+ // ───────────────────────────────────────────────────────────────────────
434
+ // classify_restart_plan — Route B 全量验证 (orchestration.py:430/467/498-538)
435
+ // golden 决策矩阵(_emit_resume_decisions):
436
+ // resumable -> Resume
437
+ // !resumable && !interacted(first_send_at absent) -> FreshStart
438
+ // !resumable && interacted && allow_fresh -> FreshStart
439
+ // !resumable && interacted && !allow_fresh -> Refuse
440
+ // Refuse 的 worker reason: 无 session_id -> "no_persisted_session_id" ; 有但不可 resume -> "session_unresumable"
441
+ // 这是把 "每非 paused worker 发一条 resume_decision + Refuse 是唯一 atomic_refusal 触发"
442
+ // 从 restart() 整条 teardown 路径剥离出来的纯验证面(gate gap)。
443
+ // ───────────────────────────────────────────────────────────────────────
444
+
445
+ #[test]
446
+ fn classify_restart_plan_interacted_unresumable_no_allow_fresh_yields_refuse() {
447
+ // 种子 state:worker w1 有 first_send_at(已交互)但无可 resume 的 session_id。
448
+ // !allow_fresh → decision=Refuse,且进 unresumable,reason=no_persisted_session_id。
449
+ let state = json!({
450
+ "session_name": "team-a",
451
+ "agents": {
452
+ "w1": {
453
+ "provider": "claude",
454
+ "first_send_at": "2026-05-27T10:00:00+00:00",
455
+ "session_id": null
456
+ }
457
+ }
458
+ });
459
+ let plan = classify_restart_plan(&state, false)
460
+ .expect("纯验证不应 Err(资源型失败才走 LifecycleError)");
461
+ assert!(plan.corrupt_entries.is_empty(), "valid ISO 不应判 corrupt");
462
+ // 恰一条决策(每非 paused worker 一条),且为 Refuse。
463
+ assert_eq!(plan.decisions.len(), 1, "每非 paused worker 恰一条 resume_decision");
464
+ assert_eq!(plan.decisions[0].agent_id, aid("w1"));
465
+ assert_eq!(plan.decisions[0].decision, ResumeDecision::Refuse);
466
+ // unresumable 收口该 worker,reason 精确。
467
+ assert_eq!(plan.unresumable.len(), 1);
468
+ assert_eq!(plan.unresumable[0].agent_id, aid("w1"));
469
+ assert_eq!(plan.unresumable[0].reason, "no_persisted_session_id");
470
+ }
471
+
472
+ #[test]
473
+ fn classify_restart_plan_interacted_unresumable_with_allow_fresh_yields_fresh_start_not_refuse() {
474
+ // 同一 worker,allow_fresh=true → FreshStart(可丢 context),unresumable 为空(无 atomic refusal)。
475
+ let state = json!({
476
+ "agents": {
477
+ "w1": {
478
+ "provider": "claude",
479
+ "first_send_at": "2026-05-27T10:00:00+00:00",
480
+ "session_id": null
481
+ }
482
+ }
483
+ });
484
+ let plan = classify_restart_plan(&state, true).expect("纯验证不应 Err");
485
+ assert_eq!(plan.decisions.len(), 1);
486
+ assert_eq!(plan.decisions[0].decision, ResumeDecision::FreshStart);
487
+ assert!(
488
+ plan.unresumable.is_empty(),
489
+ "allow_fresh 下 interacted-unresumable 不触发 atomic_refusal"
490
+ );
491
+ }
492
+
493
+ #[test]
494
+ fn classify_restart_plan_never_interacted_yields_fresh_start() {
495
+ // first_send_at absent(从未交互)→ FreshStart,即使 !allow_fresh(无 context 可丢)。
496
+ let state = json!({
497
+ "agents": { "w1": { "provider": "claude", "session_id": null } }
498
+ });
499
+ let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
500
+ assert_eq!(plan.decisions.len(), 1);
501
+ assert_eq!(plan.decisions[0].decision, ResumeDecision::FreshStart);
502
+ assert!(plan.unresumable.is_empty());
503
+ }
504
+
505
+ // ───────────────────────────────────────────────────────────────────────
506
+ // reset_agent — operations.py:102/104
507
+ // 未传 discard_session=true → Refused{ DiscardSessionRequired }
508
+ // (Python: {"ok":False,"status":"refused","reason":"discard_session_required"})
509
+ // 这是不丢上下文的误用保护,且在 owner-gate / 重起之前(纯输入门)。
510
+ // ───────────────────────────────────────────────────────────────────────
511
+
512
+ #[test]
513
+ fn reset_agent_without_discard_session_is_refused() {
514
+ let ws = temp_ws();
515
+ let got = reset_agent(&ws, &aid("w1"), false, false, None)
516
+ .expect("discard_session=false 是 typed Refused,不是 Err");
517
+ assert_eq!(
518
+ got,
519
+ ResetAgentOutcome::Refused {
520
+ reason: ResetRefusal::DiscardSessionRequired
521
+ }
522
+ );
523
+ }
524
+
525
+ // ───────────────────────────────────────────────────────────────────────
526
+ // owner-gate first-door — 每个单 worker 动作的第一道门(check_team_owner)
527
+ // 空 / 无主 workspace:无 owner 记录 → foreign-owner gate 应 refuse 成 OwnerRefused,
528
+ // 或在更上游因缺 state 而 typed-refuse。本测试锁:start_agent/stop_agent 在没有
529
+ // 合法 owner 的 workspace 上 NOT 返回 Ok(Running/Stopped) —— 即门确实存在。
530
+ // (RED:现 unimplemented!() panic;porter 实现后须命中此分支。)
531
+ // ───────────────────────────────────────────────────────────────────────
532
+
533
+ #[test]
534
+ fn start_agent_on_unowned_workspace_does_not_silently_run() {
535
+ let ws = temp_ws(); // 空 workspace,无 state/spec/owner
536
+ match start_agent(&ws, &aid("w1"), false, false, false, None) {
537
+ // 允许:owner 门拒 / 缺 spec 等 requirement 门 / team 选择失败。
538
+ Err(LifecycleError::OwnerRefused(_))
539
+ | Err(LifecycleError::RequirementUnmet(_))
540
+ | Err(LifecycleError::TeamSelect(_))
541
+ | Err(LifecycleError::Compile(_)) => {}
542
+ // 绝不允许:在没有合法 owner 的空 workspace 上声称 Running。
543
+ Ok(StartAgentOutcome::Running { .. }) => {
544
+ panic!("start_agent 在无主空 workspace 上不得 Running —— owner-gate 漏门")
545
+ }
546
+ other => panic!("意外结果(porter 实现后应命中门):{other:?}"),
547
+ }
548
+ }
549
+
550
+ #[test]
551
+ fn stop_agent_on_unowned_workspace_does_not_silently_stop() {
552
+ let ws = temp_ws();
553
+ match stop_agent(&ws, &aid("w1"), None) {
554
+ Err(LifecycleError::OwnerRefused(_))
555
+ | Err(LifecycleError::RequirementUnmet(_))
556
+ | Err(LifecycleError::TeamSelect(_))
557
+ | Err(LifecycleError::Transport(_)) => {}
558
+ Ok(rep) => panic!("stop_agent 在无主空 workspace 上不得成功:{rep:?}"),
559
+ other => panic!("意外结果:{other:?}"),
560
+ }
561
+ }
562
+
563
+ // ───────────────────────────────────────────────────────────────────────
564
+ // remove_agent — agents.py:54/56
565
+ // 未传 from_spec 确认 → RefusedFromSpecConfirm;运行中未传 force → RefusedForceRequired。
566
+ // (typed refusal,不是 Err。) _RemoveRollback 字节级回滚见 Removed.agent_health_deleted。
567
+ // ───────────────────────────────────────────────────────────────────────
568
+
569
+ #[test]
570
+ fn remove_agent_without_from_spec_is_refused_confirm() {
571
+ let ws = temp_ws();
572
+ // from_spec=false:Python agents.py:54 拒绝(需显式确认从 spec 摘除)。
573
+ match remove_agent(&ws, &aid("w1"), false, false, None) {
574
+ Ok(RemoveAgentOutcome::RefusedFromSpecConfirm { agent_id }) => {
575
+ assert_eq!(agent_id, aid("w1"));
576
+ }
577
+ // 若先撞 owner / 缺 state 门也可接受(门在 confirm 之前)。
578
+ Err(LifecycleError::OwnerRefused(_)) | Err(LifecycleError::TeamSelect(_)) => {}
579
+ other => panic!("from_spec=false 应 RefusedFromSpecConfirm 或更上游门:{other:?}"),
580
+ }
581
+ }
582
+
583
+ // ───────────────────────────────────────────────────────────────────────
584
+ // restart — orchestration.py (Route B resume-atomicity)
585
+ // corrupt first_send_at → RefusedInvalidFirstSendAt,且在任何破坏性 teardown 之前
586
+ // (hard refuse BEFORE teardown)。无主空 workspace 至少不得 Restarted。
587
+ // CorruptFirstSendAt payload 必带 raw + python type name(orchestration.py:443)。
588
+ // ───────────────────────────────────────────────────────────────────────
589
+
590
+ #[test]
591
+ fn restart_on_unowned_workspace_does_not_restart() {
592
+ let ws = temp_ws();
593
+ match restart(&ws, false, None) {
594
+ Err(LifecycleError::OwnerRefused(_))
595
+ | Err(LifecycleError::TeamSelect(_))
596
+ | Err(LifecycleError::SessionConflict(_)) => {}
597
+ Ok(RestartReport::Restarted { .. }) => {
598
+ panic!("restart 在无主空 workspace 上不得 Restarted")
599
+ }
600
+ // 校验阶段的 typed refusal 也可接受(refuse 早于 teardown,nothing created)。
601
+ Ok(RestartReport::RefusedResumeAtomicity { .. })
602
+ | Ok(RestartReport::RefusedInvalidFirstSendAt { .. }) => {}
603
+ other => panic!("意外结果:{other:?}"),
604
+ }
605
+ }
606
+
607
+ #[test]
608
+ fn python_type_name_maps_to_python_names_not_serde_names() {
609
+ // orchestration.py:446 — type(raw).__name__。golden 实跑(/tmp/x.py):
610
+ // null->NoneType, ""/"null"/"x"->str, 0/123->int, false->bool, []->list, {}->dict, 1.5->float
611
+ // 必须是 Python 名,绝不是 serde 的 null/string/number/boolean/array/object。
612
+ assert_eq!(python_type_name(&json!(null)), "NoneType");
613
+ assert_eq!(python_type_name(&json!("")), "str");
614
+ assert_eq!(python_type_name(&json!("null")), "str");
615
+ assert_eq!(python_type_name(&json!(0)), "int");
616
+ assert_eq!(python_type_name(&json!(123)), "int");
617
+ assert_eq!(python_type_name(&json!(false)), "bool");
618
+ assert_eq!(python_type_name(&json!([])), "list");
619
+ assert_eq!(python_type_name(&json!({})), "dict");
620
+ assert_eq!(python_type_name(&json!(1.5)), "float");
621
+ }
622
+
623
+ #[test]
624
+ fn classify_restart_plan_produces_corrupt_entries_with_python_type_names() {
625
+ // 真驱动:种子 state 含 3 个 corrupt first_send_at(""/0/[]),classify_restart_plan
626
+ // 必发对应 CorruptFirstSendAt,raw 原值保留,type 为 python type().__name__
627
+ // ("str"/"int"/"list") —— 锁跨语言 type-name 映射,不是手设字段。
628
+ let state = json!({
629
+ "agents": {
630
+ "w_str": { "provider": "claude", "first_send_at": "" },
631
+ "w_int": { "provider": "claude", "first_send_at": 0 },
632
+ "w_list": { "provider": "claude", "first_send_at": [] }
633
+ }
634
+ });
635
+ // corrupt 非空 → restart 在 teardown 之前 hard refuse(决策前)。
636
+ let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
637
+ let by_id: BTreeMap<String, &CorruptFirstSendAt> = plan
638
+ .corrupt_entries
639
+ .iter()
640
+ .map(|e| (e.worker_id.as_str().to_string(), e))
641
+ .collect();
642
+ assert_eq!(by_id.len(), 3, "3 个 corrupt worker 各一条 entry");
643
+ let s = by_id.get("w_str").expect("w_str corrupt entry");
644
+ assert_eq!(s.raw_first_send_at, json!(""));
645
+ assert_eq!(s.raw_first_send_at_type, "str");
646
+ let i = by_id.get("w_int").expect("w_int corrupt entry");
647
+ assert_eq!(i.raw_first_send_at, json!(0));
648
+ assert_eq!(i.raw_first_send_at_type, "int");
649
+ let l = by_id.get("w_list").expect("w_list corrupt entry");
650
+ assert_eq!(l.raw_first_send_at, json!([]));
651
+ assert_eq!(l.raw_first_send_at_type, "list");
652
+ // 自洽:每个 raw 经 classify 必判 Corrupt(hard refuse 前提)。
653
+ for e in &plan.corrupt_entries {
654
+ assert_eq!(
655
+ classify_first_send_at(&e.raw_first_send_at),
656
+ FirstSendAtState::Corrupt
657
+ );
658
+ }
659
+ }
660
+
661
+ // ───────────────────────────────────────────────────────────────────────
662
+ // select_restart_state — selection.py:49
663
+ // 空 workspace:无候选 → 回退 load_runtime_state(空态)或 TeamSelect not-found;
664
+ // 显式 team 未找到 → TeamSelect。锁:歧义/未找到走 TeamSelect 而非 panic。
665
+ // ───────────────────────────────────────────────────────────────────────
666
+
667
+ #[test]
668
+ fn select_restart_state_unknown_team_is_team_select_error() {
669
+ let ws = temp_ws();
670
+ match select_restart_state(&ws, Some("ghost-team")) {
671
+ Err(LifecycleError::TeamSelect(msg)) => {
672
+ // Python: "restart team 'ghost-team' not found. ..."
673
+ assert!(
674
+ msg.contains("ghost-team") || msg.contains("not found"),
675
+ "TeamSelect 文案应指名缺失 team:{msg}"
676
+ );
677
+ }
678
+ other => panic!("未知 team 应 TeamSelect:{other:?}"),
679
+ }
680
+ }
681
+
682
+ #[test]
683
+ fn restart_candidates_empty_workspace_is_empty_list() {
684
+ // selection.py:12 — 无 snapshot 无 active state → 空 vec(不是 Err)。
685
+ let ws = temp_ws();
686
+ let got = restart_candidates(&ws).expect("空 workspace 应 Ok(空 vec)");
687
+ assert!(got.is_empty(), "空 workspace 不应有候选:{got:?}");
688
+ }
689
+
690
+ // ───────────────────────────────────────────────────────────────────────
691
+ // save_team_runtime_snapshot — snapshot.py:17 (bug-084)
692
+ // session_name 缺失 → Python 返回 None;Rust 入口签名为 Result<PathBuf,_>。
693
+ // 锁:有 session_name 的 state 原子写出 .../runtime/teams/<safe>/state.json。
694
+ // safe_snapshot_name: 非 [A-Za-z0-9_.-] → "_",再 strip "._-"。
695
+ // ───────────────────────────────────────────────────────────────────────
696
+
697
+ #[test]
698
+ fn save_snapshot_writes_atomic_state_json_under_teams_dir() {
699
+ let ws = temp_ws();
700
+ let state = json!({"session_name": "team-alpha", "agents": {}});
701
+ let path = save_team_runtime_snapshot(&ws, &state)
702
+ .expect("bug-084:写路径返 Result,正常态须 Ok");
703
+ // 末段必为 state.json,且落在 runtime/teams/<safe session> 下。
704
+ assert_eq!(path.file_name().and_then(|s| s.to_str()), Some("state.json"));
705
+ let s = path.to_string_lossy();
706
+ assert!(
707
+ s.contains("teams") && s.contains("team-alpha"),
708
+ "快照应落在 runtime/teams/<session>/:{s}"
709
+ );
710
+ assert!(path.exists(), "os.replace 后目标文件应存在");
711
+ }
712
+
713
+ // ───────────────────────────────────────────────────────────────────────
714
+ // quick_start — diagnose/quick_start.py:18
715
+ // 空 agents_dir + fresh=false:无 runtime → 走 launch(可能 PreflightBlocked);
716
+ // 锁:返回 QuickStartReport(typed outcome,不 panic),且 Ready 时 next_actions 非空。
717
+ // ───────────────────────────────────────────────────────────────────────
718
+
719
+ #[test]
720
+ fn quick_start_empty_dir_returns_typed_report_not_error_path_only() {
721
+ let ws = temp_ws();
722
+ // 空目录无可编译 team:应是 typed 阻塞 / 编译错,绝不 Ready。
723
+ match quick_start(&ws, None, false, false, None) {
724
+ Ok(QuickStartReport::Ready { .. }) => {
725
+ panic!("空 agents_dir 不应 Ready —— 无 team 可编译")
726
+ }
727
+ Ok(QuickStartReport::PreflightBlocked { blockers, .. }) => {
728
+ assert!(!blockers.is_empty(), "PreflightBlocked 必列 blockers");
729
+ }
730
+ Ok(QuickStartReport::ExistingRuntime { .. }) => {}
731
+ Err(LifecycleError::Compile(_)) | Err(LifecycleError::RequirementUnmet(_)) => {}
732
+ other => panic!("意外结果:{other:?}"),
733
+ }
734
+ }
735
+
736
+ // ───────────────────────────────────────────────────────────────────────
737
+ // detect_dangerous_approval — launch/config.py
738
+ // 默认进程(无 --dangerously-* 祖先)→ enabled=false / source=Disabled / inherited=false。
739
+ // launch 在 inherited=false 且无 --yes 时 raise DangerousApprovalRequired(core.py:120)。
740
+ // ───────────────────────────────────────────────────────────────────────
741
+
742
+ #[test]
743
+ #[serial_test::serial(env)]
744
+ fn detect_dangerous_approval_clean_process_is_disabled() {
745
+ // Explicit mock ancestry keeps the test independent from the real Codex/CI
746
+ // process tree that runs cargo.
747
+ let _ancestry = EnvVarGuard::set("TEAM_AGENT_TEST_PROCESS_ANCESTRY_ARGV_JSON", "[]");
748
+ let got = detect_dangerous_approval().expect("探测祖先链应 Ok");
749
+ assert!(!got.enabled, "干净进程不应启用危险审批");
750
+ assert_eq!(got.source, DangerousApprovalSource::Disabled);
751
+ assert!(!got.inherited);
752
+ }
753
+
754
+ struct EnvVarGuard {
755
+ key: &'static str,
756
+ previous: Option<String>,
757
+ }
758
+
759
+ impl EnvVarGuard {
760
+ fn set(key: &'static str, value: &str) -> Self {
761
+ let previous = std::env::var(key).ok();
762
+ unsafe {
763
+ std::env::set_var(key, value);
764
+ }
765
+ Self { key, previous }
766
+ }
767
+ }
768
+
769
+ impl Drop for EnvVarGuard {
770
+ fn drop(&mut self) {
771
+ unsafe {
772
+ if let Some(value) = self.previous.take() {
773
+ std::env::set_var(self.key, value);
774
+ } else {
775
+ std::env::remove_var(self.key);
776
+ }
777
+ }
778
+ }
779
+ }
780
+
781
+ // ───────────────────────────────────────────────────────────────────────
782
+ // open_worker_displays — worker_window.py (C14)
783
+ // 显示失败不阻塞 readiness:probe 为 blocked 平台时,returns typed Blocked displays,
784
+ // 绝不 Err。这里用 backend=None(无 worker views)验证至少不 panic 成 Err。
785
+ // ───────────────────────────────────────────────────────────────────────
786
+
787
+ #[test]
788
+ fn open_worker_displays_blocked_probe_yields_typed_blocked_not_error() {
789
+ let ws = temp_ws();
790
+ let probe = DisplayProbe {
791
+ in_tmux: false,
792
+ platform: "windows".to_string(),
793
+ leader_session: None,
794
+ leader_pane: None,
795
+ caps: CapsFlags {
796
+ tmux_append_windows: false,
797
+ adaptive_display: false,
798
+ },
799
+ adaptive_status: DisplayStatus::Blocked,
800
+ reason: Some(AdaptiveBlockReason::NotImplementedThisPlatform),
801
+ };
802
+ let rep = open_worker_displays(&ws, &sess("team-a"), DisplayBackend::Adaptive, &probe)
803
+ .expect("C14:显示失败不阻塞 readiness —— 不得 Err");
804
+ // 每个 worker 的 display 在 blocked 平台上应是 Blocked 变体(若有 worker)。
805
+ for (id, d) in rep.displays.iter() {
806
+ assert!(
807
+ matches!(d, WorkerDisplay::Blocked { reason: AdaptiveBlockReason::NotImplementedThisPlatform }),
808
+ "worker {id} 在 windows 平台应 Blocked(not_implemented):{d:?}"
809
+ );
810
+ }
811
+ }
812
+
813
+ // ───────────────────────────────────────────────────────────────────────
814
+ // close_team_display_backends — display/close.py (C9 close-by-recorded-backend)
815
+ // 空 workspace 无 recorded backend 无 session → 空 closed / 空 orphans(不 Err)。
816
+ // adaptive 只删带 team-tag 的窗口(C2 leader pane 安全)。
817
+ // ───────────────────────────────────────────────────────────────────────
818
+
819
+ #[test]
820
+ fn close_team_display_empty_workspace_closes_nothing_not_error() {
821
+ let ws = temp_ws();
822
+ let rep = close_team_display_backends(&ws, &sess("team-a"))
823
+ .expect("C9:无 recorded backend 应 Ok(空报告),不是 Err");
824
+ assert!(rep.closed.is_empty(), "无 recorded backend 不应关任何东西:{:?}", rep.closed);
825
+ assert!(
826
+ rep.orphans_cleaned.is_empty(),
827
+ "空 workspace 无 orphan:{:?}",
828
+ rep.orphans_cleaned
829
+ );
830
+ }
831
+
832
+ // ───────────────────────────────────────────────────────────────────────
833
+ // fork_agent — operations.py:284 (native session fork eligibility)
834
+ // 资格门(adapter.supports_session_fork = auth_mode != "compatible_api"):
835
+ // - compatible_api 的 agent → 不支持 fork → RuntimeError("<provider> does not support
836
+ // native session fork") → Rust Provider error。
837
+ // - 源 session_id 缺失 → RuntimeError("cannot fork <id>: source session_id is missing")。
838
+ // 空无主 workspace:owner-gate / 缺 spec 门优先。失败臂须 adapter.cleanup_mcp + 回滚 spec。
839
+ // ───────────────────────────────────────────────────────────────────────
840
+
841
+ #[test]
842
+ fn fork_agent_on_unowned_workspace_does_not_silently_fork() {
843
+ let ws = temp_ws();
844
+ match fork_agent(&ws, &aid("src"), &aid("dst"), false, None) {
845
+ // 允许:owner 门 / team 选择 / 缺 spec(provider 命令构造前的上游门)。
846
+ Err(LifecycleError::OwnerRefused(_))
847
+ | Err(LifecycleError::TeamSelect(_))
848
+ | Err(LifecycleError::Compile(_))
849
+ | Err(LifecycleError::RequirementUnmet(_))
850
+ // 资格 / 源 session 缺失 → Provider error(native fork 不可用)。
851
+ | Err(LifecycleError::Provider(_)) => {}
852
+ Ok(rep) => panic!("无主空 workspace 不得成功 fork:{rep:?}"),
853
+ other => panic!("意外结果(porter 实现后应命中门):{other:?}"),
854
+ }
855
+ }
856
+
857
+ // ───────────────────────────────────────────────────────────────────────
858
+ // add_agent — operations.py:143 (字节级回滚 ORDER, Gap 15.11)
859
+ // 前向 step 顺序(_step_done 发 lifecycle.add_step_completed):
860
+ // role_file -> compile_role_doc -> spec_yaml -> team_state_md -> start_agent -> workspace_state
861
+ // 回滚顺序(失败时,发 lifecycle.add_step_rolled_back,operations.py:223-259):
862
+ // spec_yaml -> workspace_state -> team_state_md -> role_file
863
+ // 事件名常量已在 event_names 锁定。无主空 workspace:owner/缺 spec 门优先于任何写。
864
+ // ───────────────────────────────────────────────────────────────────────
865
+
866
+ #[test]
867
+ fn add_agent_event_name_constants_match_python_lifecycle_strings() {
868
+ // 锁死发射事件名(顺序被 porter 实现后的事件流锁死;此处锁名)。
869
+ assert_eq!(event_names::ADD_STEP_COMPLETED, "lifecycle.add_step_completed");
870
+ assert_eq!(event_names::ADD_STEP_ROLLED_BACK, "lifecycle.add_step_rolled_back");
871
+ assert_eq!(event_names::ADD_FAILED, "lifecycle.add_failed");
872
+ }
873
+
874
+ #[test]
875
+ fn add_agent_on_unowned_workspace_does_not_silently_add() {
876
+ let ws = temp_ws();
877
+ // 缺 role file / 缺 owner / 缺 spec → 上游门拒,绝不返回 Ok(env running)。
878
+ match add_agent(&ws, &aid("w1"), &ws.join("role.md"), false, None) {
879
+ Err(LifecycleError::OwnerRefused(_))
880
+ | Err(LifecycleError::TeamSelect(_))
881
+ | Err(LifecycleError::Compile(_))
882
+ | Err(LifecycleError::RequirementUnmet(_))
883
+ | Err(LifecycleError::RollbackFailed { .. }) => {}
884
+ Ok(rep) => panic!("无主空 workspace 不得成功 add:{rep:?}"),
885
+ other => panic!("意外结果:{other:?}"),
886
+ }
887
+ }
888
+
889
+ // ───────────────────────────────────────────────────────────────────────
890
+ // start_plan / handle_report_result / halt_plan / plan_status
891
+ // (orchestrator/__init__.py:26/79/152/177)
892
+ // golden:
893
+ // start_plan 无 stage / 空 plan 路径 -> {"ok":False,"error":"plan has no stages"} 或 InvalidPlan
894
+ // handle_report_result 无匹配 stage -> {"ok":True,"status":"no_match","matched":False} -> NoMatch
895
+ // stage advance_on 命中 -> current_stage += 1;> len -> Completed
896
+ // halt_plan 未找到 -> {"ok":False,"error":"plan not found"} ; 非 running -> already_terminal 幂等
897
+ // plan_status 未找到 -> {"ok":False,"error":"plan not found"} -> Err / typed
898
+ // current_stage 1-based。
899
+ // ───────────────────────────────────────────────────────────────────────
900
+
901
+ #[test]
902
+ fn start_plan_missing_file_is_invalid_plan_not_panic() {
903
+ let ws = temp_ws();
904
+ let missing = ws.join("nope.plan.yaml");
905
+ // 不存在 / 无 stage 的 plan → InvalidPlan(typed),不 panic 也不 Ok(Running)。
906
+ match start_plan(&ws, &missing, true) {
907
+ Err(LifecycleError::InvalidPlan(_)) | Err(LifecycleError::InvalidPlanId(_)) => {}
908
+ Ok(PlanProgress::Running { .. }) | Ok(PlanProgress::Completed { .. }) => {
909
+ panic!("缺失 / 无 stage 的 plan 不得 Running/Completed")
910
+ }
911
+ other => panic!("意外结果:{other:?}"),
912
+ }
913
+ }
914
+
915
+ #[test]
916
+ fn handle_report_result_no_running_plan_is_no_match() {
917
+ let ws = temp_ws();
918
+ // 无任何 plan state → 任何 report_result 都不匹配 → NoMatch(no_match / matched:false)。
919
+ let envelope = json!({"report_result": {"status": "done"}});
920
+ let got = handle_report_result(&ws, &envelope).expect("no_match 是 typed outcome,不是 Err");
921
+ assert_eq!(got, PlanProgress::NoMatch);
922
+ }
923
+
924
+ #[test]
925
+ fn halt_plan_unknown_id_is_not_found_error() {
926
+ let ws = temp_ws();
927
+ let pid = PlanId::parse("ghost-plan").expect("合法 plan id");
928
+ // 未持久化的 plan → "plan not found"(typed/Err),不幂等成 Halted。
929
+ match halt_plan(&ws, &pid, "user_requested") {
930
+ Err(LifecycleError::InvalidPlan(msg)) | Err(LifecycleError::TeamSelect(msg)) => {
931
+ assert!(msg.contains("not found") || msg.contains("ghost-plan"), "got: {msg}");
932
+ }
933
+ Ok(PlanProgress::Halted { .. }) => {
934
+ panic!("未找到的 plan 不得返回 Halted(应 not-found)")
935
+ }
936
+ other => panic!("意外结果:{other:?}"),
937
+ }
938
+ }
939
+
940
+ #[test]
941
+ fn plan_status_unknown_id_is_not_found() {
942
+ let ws = temp_ws();
943
+ let pid = PlanId::parse("ghost-plan").expect("合法 plan id");
944
+ // 读未持久化 plan → not-found error(Rust 入口签名 Result<PlanState,_>)。
945
+ match plan_status(&ws, &pid) {
946
+ Err(LifecycleError::InvalidPlan(msg)) | Err(LifecycleError::TeamSelect(msg)) => {
947
+ assert!(msg.contains("not found") || msg.contains("ghost-plan"), "got: {msg}");
948
+ }
949
+ Ok(st) => panic!("未持久化 plan 不得返回 PlanState:{st:?}"),
950
+ other => panic!("意外结果:{other:?}"),
951
+ }
952
+ }
953
+
954
+ // ═══════════════ P2 FIX-LOOP RED (复绿即对抗 cross-model finding) ═══════════════
955
+ // P1 — classify_first_send_at must accept the breadth of datetime.fromisoformat
956
+ // (restart/orchestration.py:404-426): space/'t'/'_' separators, date-only, fractional
957
+ // seconds, HH:MM, compact ±HHMM offset, basic YYYYMMDDTHHMMSS. The current dual-parser
958
+ // (rfc3339 OR "%Y-%m-%dT%H:%M:%S") marks these Corrupt, flipping restart() into a hard
959
+ // refuse where Python proceeds. Golden re-probed via /tmp/probe_p2b_iso.py (all → 'valid').
960
+ #[test]
961
+ fn p2_classify_first_send_at_accepts_broad_iso_like_python() {
962
+ for s in [
963
+ "2026-05-27 10:00:00", // space separator
964
+ "2026-05-27", // date-only
965
+ "2026-05-27T10:00:00.123456", // fractional seconds
966
+ "2026-05-27T10:00", // HH:MM
967
+ "2026-05-27T10:00:00+0000", // compact ±HHMM offset
968
+ "20260527T100000", // basic ISO
969
+ "2026-05-27t10:00:00", // lowercase 't' separator
970
+ "2026-05-27_10:00:00", // underscore separator
971
+ ] {
972
+ assert_eq!(
973
+ classify_first_send_at(&serde_json::json!(s)),
974
+ FirstSendAtState::Valid,
975
+ "Python datetime.fromisoformat accepts {s:?}",
976
+ );
977
+ }
978
+ }
979
+
980
+ // ═════════════════════════════════════════════════════════════════════════
981
+ // LIFECYCLE ENTRY POINTS — RED integration: each user-facing action must drive its REAL
982
+ // in-process chain (crate::compiler spec-compile + crate::message_store db init +
983
+ // crate::state::persist seed) with only the OS edge (tmux spawn) mocked / asserted-as-plan.
984
+ // Today they are early-return STUBS — quick_start (launch.rs:36) returns a hardcoded
985
+ // PreflightBlocked{"no role docs found"}; launch / start_agent / restart / add_agent /
986
+ // fork_agent return a hardcoded Err — so these FAIL and green once the porter wires them.
987
+ // Golden: diagnose/quick_start.py, launch/core.py, lifecycle/start.py, restart/orchestration.py,
988
+ // lifecycle/operations.py (team-agent-public @ v0.2.11).
989
+ // ═════════════════════════════════════════════════════════════════════════