@zerwiz/ymir 0.1.4 → 0.1.5

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 (850) hide show
  1. package/.agents/README.md +291 -0
  2. package/.agents/agents/bragi-marketer.md +111 -0
  3. package/.agents/agents/brokk.md +352 -0
  4. package/.agents/agents/forseti-reviewer.md +93 -0
  5. package/.agents/agents/frigg-consent.md +77 -0
  6. package/.agents/agents/groa-update.md +77 -0
  7. package/.agents/agents/hnoss-designer.md +69 -0
  8. package/.agents/agents/huginn-researcher.md +99 -0
  9. package/.agents/agents/jord-projects.md +78 -0
  10. package/.agents/agents/kvasir-scout.md +91 -0
  11. package/.agents/agents/mimir-planner.md +92 -0
  12. package/.agents/agents/muninn-stow.md +77 -0
  13. package/.agents/agents/saga-bearings.md +78 -0
  14. package/.agents/agents/sindri-developer.md +114 -0
  15. package/.agents/agents/snotra-documenter.md +105 -0
  16. package/.agents/agents/syn-recovery.md +78 -0
  17. package/.agents/agents/tyr-check.md +78 -0
  18. package/.agents/agents/urdh-hold.md +77 -0
  19. package/.agents/agents/volundr-smith.md +80 -0
  20. package/.agents/agents/vor-diagnostics.md +77 -0
  21. package/.agents/assets/agents/naming.md +50 -0
  22. package/.agents/assets/agents/registry.md +93 -0
  23. package/.agents/assets/agents/runtime.md +36 -0
  24. package/.agents/assets/agents/toon-tasks-cli.md +42 -0
  25. package/.agents/assets/templates/company_entity.template.md +37 -0
  26. package/.agents/backend/backends/cmux.sh +649 -0
  27. package/.agents/backend/backends/herdr-eventwait.py +157 -0
  28. package/.agents/backend/backends/herdr-workspace-move.py +114 -0
  29. package/.agents/backend/backends/herdr.sh +3386 -0
  30. package/.agents/backend/backends/orca.sh +290 -0
  31. package/.agents/backend/backends/tmux.sh +357 -0
  32. package/.agents/backend/backends/zellij.sh +700 -0
  33. package/.agents/backend/fm-afk-launch.sh +654 -0
  34. package/.agents/backend/fm-afk-return.sh +241 -0
  35. package/.agents/backend/fm-afk-start.sh +172 -0
  36. package/.agents/backend/fm-arm-command-policy.mjs +960 -0
  37. package/.agents/backend/fm-arm-pretool-check.sh +200 -0
  38. package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
  39. package/.agents/backend/fm-backend.sh +981 -0
  40. package/.agents/backend/fm-backlog-handoff.sh +909 -0
  41. package/.agents/backend/fm-backlog-receive.sh +187 -0
  42. package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
  43. package/.agents/backend/fm-bearings-board.sh +204 -0
  44. package/.agents/backend/fm-bearings-snapshot.sh +546 -0
  45. package/.agents/backend/fm-bootstrap.sh +1568 -0
  46. package/.agents/backend/fm-branch-outcome.sh +203 -0
  47. package/.agents/backend/fm-branch-prompt.sh +101 -0
  48. package/.agents/backend/fm-brief.sh +452 -0
  49. package/.agents/backend/fm-busy-event.sh +234 -0
  50. package/.agents/backend/fm-busy-lib.sh +986 -0
  51. package/.agents/backend/fm-captain-hold.sh +1001 -0
  52. package/.agents/backend/fm-cd-command-policy.mjs +153 -0
  53. package/.agents/backend/fm-cd-pretool-check.sh +190 -0
  54. package/.agents/backend/fm-check-lib.sh +74 -0
  55. package/.agents/backend/fm-check-register.sh +42 -0
  56. package/.agents/backend/fm-check-unregister.sh +52 -0
  57. package/.agents/backend/fm-classify-lib.sh +1770 -0
  58. package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
  59. package/.agents/backend/fm-composer-lib.sh +1416 -0
  60. package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
  61. package/.agents/backend/fm-config-push.sh +254 -0
  62. package/.agents/backend/fm-control-lib.sh +251 -0
  63. package/.agents/backend/fm-control.sh +878 -0
  64. package/.agents/backend/fm-crew-state.sh +625 -0
  65. package/.agents/backend/fm-cursor-lib.sh +243 -0
  66. package/.agents/backend/fm-decision-hold.sh +232 -0
  67. package/.agents/backend/fm-doc-audience-check.sh +269 -0
  68. package/.agents/backend/fm-dod-lib.sh +67 -0
  69. package/.agents/backend/fm-ensure-agents-md.sh +253 -0
  70. package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
  71. package/.agents/backend/fm-extension.mjs +2577 -0
  72. package/.agents/backend/fm-extension.sh +16 -0
  73. package/.agents/backend/fm-ff-lib.sh +420 -0
  74. package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
  75. package/.agents/backend/fm-fleet-sync.sh +458 -0
  76. package/.agents/backend/fm-fleet-view.sh +96 -0
  77. package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
  78. package/.agents/backend/fm-guard.sh +242 -0
  79. package/.agents/backend/fm-harness.sh +195 -0
  80. package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
  81. package/.agents/backend/fm-herdr-lab.sh +346 -0
  82. package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
  83. package/.agents/backend/fm-home-seed.sh +984 -0
  84. package/.agents/backend/fm-home-summary-refresh.sh +255 -0
  85. package/.agents/backend/fm-hook-host-lib.sh +36 -0
  86. package/.agents/backend/fm-inactive-reconcile.sh +531 -0
  87. package/.agents/backend/fm-inbox.sh +399 -0
  88. package/.agents/backend/fm-install-actionlint.sh +84 -0
  89. package/.agents/backend/fm-install-herdr.sh +96 -0
  90. package/.agents/backend/fm-install-shellcheck.sh +84 -0
  91. package/.agents/backend/fm-install-treehouse.sh +95 -0
  92. package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
  93. package/.agents/backend/fm-lease-lib.sh +218 -0
  94. package/.agents/backend/fm-lease.sh +189 -0
  95. package/.agents/backend/fm-line-cap-lib.sh +51 -0
  96. package/.agents/backend/fm-lint-workflows.sh +137 -0
  97. package/.agents/backend/fm-lint.sh +591 -0
  98. package/.agents/backend/fm-lock-lib.sh +104 -0
  99. package/.agents/backend/fm-lock.sh +107 -0
  100. package/.agents/backend/fm-marker-lib.sh +12 -0
  101. package/.agents/backend/fm-merge-local.sh +74 -0
  102. package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
  103. package/.agents/backend/fm-nm-run-lib.sh +124 -0
  104. package/.agents/backend/fm-on.sh +125 -0
  105. package/.agents/backend/fm-operational-input.sh +253 -0
  106. package/.agents/backend/fm-peek.sh +46 -0
  107. package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
  108. package/.agents/backend/fm-pr-check.sh +135 -0
  109. package/.agents/backend/fm-pr-lib.sh +1016 -0
  110. package/.agents/backend/fm-pr-merge.sh +707 -0
  111. package/.agents/backend/fm-pr-poll.sh +110 -0
  112. package/.agents/backend/fm-primary-scope-lib.sh +33 -0
  113. package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
  114. package/.agents/backend/fm-procevent-lavish.sh +628 -0
  115. package/.agents/backend/fm-procevent-lib.sh +892 -0
  116. package/.agents/backend/fm-procevent-quota.sh +290 -0
  117. package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
  118. package/.agents/backend/fm-procevent-when.sh +504 -0
  119. package/.agents/backend/fm-procevent.sh +1466 -0
  120. package/.agents/backend/fm-project-mode.sh +92 -0
  121. package/.agents/backend/fm-project-origin-lib.sh +180 -0
  122. package/.agents/backend/fm-promote.sh +282 -0
  123. package/.agents/backend/fm-public-followup-emit.sh +260 -0
  124. package/.agents/backend/fm-public-followup-lib.sh +398 -0
  125. package/.agents/backend/fm-public-followup.sh +1330 -0
  126. package/.agents/backend/fm-push-transition-lib.sh +168 -0
  127. package/.agents/backend/fm-quota-axi-lib.sh +93 -0
  128. package/.agents/backend/fm-quota-choose.sh +384 -0
  129. package/.agents/backend/fm-remote-delta-read.sh +189 -0
  130. package/.agents/backend/fm-remote-doctor.sh +800 -0
  131. package/.agents/backend/fm-remote-entrypoint.sh +193 -0
  132. package/.agents/backend/fm-remote-file.sh +234 -0
  133. package/.agents/backend/fm-remote-home-provision.sh +259 -0
  134. package/.agents/backend/fm-remote-home-seed.sh +259 -0
  135. package/.agents/backend/fm-remote-inherit-push.sh +90 -0
  136. package/.agents/backend/fm-remote-inherit.sh +179 -0
  137. package/.agents/backend/fm-remote-job-lib.sh +1241 -0
  138. package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
  139. package/.agents/backend/fm-remote-job-worker.sh +1124 -0
  140. package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
  141. package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
  142. package/.agents/backend/fm-review-diff.sh +158 -0
  143. package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
  144. package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
  145. package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
  146. package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
  147. package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
  148. package/.agents/backend/fm-secondmate-report.sh +88 -0
  149. package/.agents/backend/fm-send.sh +1059 -0
  150. package/.agents/backend/fm-session-lock-lib.sh +176 -0
  151. package/.agents/backend/fm-session-start.sh +970 -0
  152. package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
  153. package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
  154. package/.agents/backend/fm-sessionstart-run.sh +145 -0
  155. package/.agents/backend/fm-spawn.sh +3153 -0
  156. package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
  157. package/.agents/backend/fm-startup-memory-budget.sh +94 -0
  158. package/.agents/backend/fm-startup-network.sh +642 -0
  159. package/.agents/backend/fm-stow-cascade.sh +251 -0
  160. package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
  161. package/.agents/backend/fm-supervise-daemon.sh +1732 -0
  162. package/.agents/backend/fm-supervision-instructions.sh +215 -0
  163. package/.agents/backend/fm-supervision-lib.sh +90 -0
  164. package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
  165. package/.agents/backend/fm-tangle-lib.sh +53 -0
  166. package/.agents/backend/fm-task-inbox-lib.sh +402 -0
  167. package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
  168. package/.agents/backend/fm-teardown.sh +2922 -0
  169. package/.agents/backend/fm-test-isolation-proof.sh +588 -0
  170. package/.agents/backend/fm-test-run.sh +2191 -0
  171. package/.agents/backend/fm-timeout-lib.sh +141 -0
  172. package/.agents/backend/fm-timing-lib.sh +179 -0
  173. package/.agents/backend/fm-tmux-lib.sh +291 -0
  174. package/.agents/backend/fm-tool-update-check.sh +898 -0
  175. package/.agents/backend/fm-trace-context-lib.sh +227 -0
  176. package/.agents/backend/fm-transition-lib.sh +103 -0
  177. package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
  178. package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
  179. package/.agents/backend/fm-turnend-guard.sh +432 -0
  180. package/.agents/backend/fm-update.sh +110 -0
  181. package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
  182. package/.agents/backend/fm-voice-client.py +1373 -0
  183. package/.agents/backend/fm-voice-relay.py +1256 -0
  184. package/.agents/backend/fm-wake-drain.sh +592 -0
  185. package/.agents/backend/fm-wake-grant.sh +127 -0
  186. package/.agents/backend/fm-wake-lib.sh +1849 -0
  187. package/.agents/backend/fm-watch-arm.sh +602 -0
  188. package/.agents/backend/fm-watch-checkpoint.sh +109 -0
  189. package/.agents/backend/fm-watch.sh +1962 -0
  190. package/.agents/backend/fm-x-dismiss.sh +116 -0
  191. package/.agents/backend/fm-x-followup.sh +286 -0
  192. package/.agents/backend/fm-x-lib.sh +1000 -0
  193. package/.agents/backend/fm-x-link.sh +234 -0
  194. package/.agents/backend/fm-x-poll.sh +185 -0
  195. package/.agents/backend/fm-x-reply.sh +385 -0
  196. package/.agents/backend/fm_voice_frame.py +166 -0
  197. package/.agents/backend/fm_voice_records.py +574 -0
  198. package/.agents/backend/model-bridge.py +290 -0
  199. package/.agents/backend/opencode-go-bridge.py +30 -0
  200. package/.agents/bus/README.md +12 -0
  201. package/.agents/bus/messages.json +1 -0
  202. package/.agents/config/agents.machine.example.yaml +16 -0
  203. package/.agents/config/agents.yaml +50 -0
  204. package/.agents/config/agents.yaml.example +72 -0
  205. package/.agents/config/crew-dispatch.json +22 -0
  206. package/.agents/config/cron.yaml +9 -0
  207. package/.agents/config/eindri-dispatch.json +67 -0
  208. package/.agents/config/eindri-harness +1 -0
  209. package/.agents/config/startup-memory-budget +1 -0
  210. package/.agents/config/tailscale-sync.yaml +27 -0
  211. package/.agents/filebrowser/.filebrowser.json +6 -0
  212. package/.agents/filebrowser/README.md +11 -0
  213. package/.agents/gateway/README.md +12 -0
  214. package/.agents/github/README.md +15 -0
  215. package/.agents/github/webhooks/.gitkeep +0 -0
  216. package/.agents/github/workflows/.gitkeep +0 -0
  217. package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
  218. package/.agents/harness/opencode/plugins/package.json +4 -0
  219. package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
  220. package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
  221. package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
  222. package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
  223. package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
  224. package/.agents/memory/README.md +46 -0
  225. package/.agents/memory/kaia.engram +0 -0
  226. package/.agents/memory/kaia.engram-shm +0 -0
  227. package/.agents/memory/kaia.engram-wal +0 -0
  228. package/.agents/memory/runes_audit.md +5 -0
  229. package/.agents/memory/well/episodes.jsonl +367 -0
  230. package/.agents/memory/well/workspace.jsonl +27 -0
  231. package/.agents/migrations/0001-hodd-layout.sh +38 -0
  232. package/.agents/migrations/0002-a2a-mcp.sh +27 -0
  233. package/.agents/migrations/0003-private-data-separation.sh +111 -0
  234. package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
  235. package/.agents/sandbox/Dockerfile.utgard +19 -0
  236. package/.agents/sandbox/README.md +14 -0
  237. package/.agents/sandbox/utgard.config.json +14 -0
  238. package/.agents/skills/README.md +59 -0
  239. package/.agents/skills/bragi-marketing/README.md +3 -0
  240. package/.agents/skills/bragi-marketing/SKILL.md +82 -0
  241. package/.agents/skills/eindri-homes/README.md +3 -0
  242. package/.agents/skills/eindri-homes/SKILL.md +291 -0
  243. package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
  244. package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
  245. package/.agents/skills/frigg-consent/README.md +3 -0
  246. package/.agents/skills/frigg-consent/SKILL.md +56 -0
  247. package/.agents/skills/galdr-ymirsystem/README.md +3 -0
  248. package/.agents/skills/galdr-ymirsystem/SKILL.md +158 -0
  249. package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
  250. package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
  251. package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
  252. package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
  253. package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
  254. package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
  255. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +673 -0
  256. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
  257. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
  258. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
  259. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
  260. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
  261. package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +496 -0
  262. package/.agents/skills/galdr-ymirsystem/assets/installation.md +571 -0
  263. package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
  264. package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
  265. package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +161 -0
  266. package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +377 -0
  267. package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
  268. package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +129 -0
  269. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
  270. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
  271. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +96 -0
  272. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
  273. package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
  274. package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
  275. package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
  276. package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
  277. package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
  278. package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
  279. package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
  280. package/.agents/skills/galdr-ymirsystem/assets/smidja.md +175 -0
  281. package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
  282. package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
  283. package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
  284. package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +360 -0
  285. package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
  286. package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
  287. package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
  288. package/.agents/skills/gjallarhorn-relay/README.md +3 -0
  289. package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
  290. package/.agents/skills/groa-update/SKILL.md +70 -0
  291. package/.agents/skills/hamr-adapters/README.md +3 -0
  292. package/.agents/skills/hamr-adapters/SKILL.md +123 -0
  293. package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
  294. package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
  295. package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
  296. package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
  297. package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
  298. package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
  299. package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
  300. package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
  301. package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
  302. package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
  303. package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
  304. package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
  305. package/.agents/skills/herdr-panes/README.md +3 -0
  306. package/.agents/skills/herdr-panes/SKILL.md +92 -0
  307. package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
  308. package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
  309. package/.agents/skills/hnoss-design/DESIGN.md +19 -0
  310. package/.agents/skills/hnoss-design/README.md +3 -0
  311. package/.agents/skills/hnoss-design/SKILL.md +83 -0
  312. package/.agents/skills/hvild-afk/README.md +3 -0
  313. package/.agents/skills/hvild-afk/SKILL.md +241 -0
  314. package/.agents/skills/jord-projects/README.md +3 -0
  315. package/.agents/skills/jord-projects/SKILL.md +92 -0
  316. package/.agents/skills/lifecycle/SKILL.md +56 -0
  317. package/.agents/skills/lifecycle/smoke_test.sh +52 -0
  318. package/.agents/skills/lifecycle/start.sh +21 -0
  319. package/.agents/skills/lifecycle/status.sh +35 -0
  320. package/.agents/skills/lifecycle/stop.sh +16 -0
  321. package/.agents/skills/muninn-stow/README.md +3 -0
  322. package/.agents/skills/muninn-stow/SKILL.md +309 -0
  323. package/.agents/skills/no-mistakes/SKILL.md +379 -0
  324. package/.agents/skills/nornir-schedule/README.md +3 -0
  325. package/.agents/skills/nornir-schedule/SKILL.md +25 -0
  326. package/.agents/skills/nornir-schedule/assets/events.md +137 -0
  327. package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
  328. package/.agents/skills/nsr-compliance/README.md +3 -0
  329. package/.agents/skills/nsr-compliance/SKILL.md +29 -0
  330. package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
  331. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
  332. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
  333. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
  334. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
  335. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
  336. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
  337. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
  338. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
  339. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
  340. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
  341. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
  342. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
  343. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
  344. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
  345. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
  346. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
  347. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
  348. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
  349. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
  350. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
  351. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
  352. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
  353. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  354. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
  355. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
  356. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
  357. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
  358. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
  359. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
  360. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
  361. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
  362. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
  363. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
  364. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
  365. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
  366. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
  367. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
  368. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
  369. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
  370. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
  371. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
  372. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
  373. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
  374. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
  375. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
  376. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
  377. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
  378. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
  379. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
  380. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
  381. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
  382. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
  383. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
  384. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
  385. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
  386. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
  387. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
  388. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
  389. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
  390. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
  391. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
  392. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
  393. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
  394. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
  395. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
  396. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
  397. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
  398. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
  399. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
  400. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
  401. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
  402. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
  403. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
  404. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
  405. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
  406. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
  407. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
  408. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
  409. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
  410. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
  411. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
  412. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
  413. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
  414. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
  415. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
  416. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
  417. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
  418. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
  419. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
  420. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
  421. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
  422. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
  423. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
  424. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
  425. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
  426. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
  427. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
  428. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
  429. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
  430. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
  431. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
  432. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
  433. package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
  434. package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
  435. package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
  436. package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
  437. package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
  438. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
  439. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
  440. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
  441. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
  442. package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
  443. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
  444. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
  445. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
  446. package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
  447. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
  448. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
  449. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
  450. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
  451. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
  452. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
  453. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
  454. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  455. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
  456. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
  457. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
  458. package/.agents/skills/pr-ops/README.md +3 -0
  459. package/.agents/skills/pr-ops/SKILL.md +109 -0
  460. package/.agents/skills/ratatoskr-a2a/README.md +3 -0
  461. package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
  462. package/.agents/skills/rules-check-drift/SKILL.md +81 -0
  463. package/.agents/skills/saga-bearings/README.md +3 -0
  464. package/.agents/skills/saga-bearings/SKILL.md +26 -0
  465. package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
  466. package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
  467. package/.agents/skills/saga-bearings/assets/recap.md +56 -0
  468. package/.agents/skills/syn-recovery/README.md +3 -0
  469. package/.agents/skills/syn-recovery/SKILL.md +54 -0
  470. package/.agents/skills/tyr-check/README.md +3 -0
  471. package/.agents/skills/tyr-check/SKILL.md +218 -0
  472. package/.agents/skills/urdh-hold/README.md +3 -0
  473. package/.agents/skills/urdh-hold/SKILL.md +25 -0
  474. package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
  475. package/.agents/skills/urdh-hold/assets/hold.md +54 -0
  476. package/.agents/skills/vor-diagnostics/README.md +3 -0
  477. package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
  478. package/.agents/skills/ymir-host/README.md +3 -0
  479. package/.agents/skills/ymir-host/SKILL.md +34 -0
  480. package/.agents/skills/ymir-host/assets/desktop.md +36 -0
  481. package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
  482. package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
  483. package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
  484. package/.agents/skills/ymir-host/assets/update.md +64 -0
  485. package/.agents/tests/assets/board-render-harness.mjs +117 -0
  486. package/.agents/tests/cmux-test-safety.sh +51 -0
  487. package/.agents/tests/fixtures.sh +291 -0
  488. package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
  489. package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
  490. package/.agents/tests/fm-afk-launch.test.sh +961 -0
  491. package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
  492. package/.agents/tests/fm-afk-return.test.sh +283 -0
  493. package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
  494. package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
  495. package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
  496. package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
  497. package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
  498. package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
  499. package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
  500. package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
  501. package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
  502. package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
  503. package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
  504. package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
  505. package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
  506. package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
  507. package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
  508. package/.agents/tests/fm-backend-orca.test.sh +1357 -0
  509. package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
  510. package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
  511. package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
  512. package/.agents/tests/fm-backend.test.sh +1142 -0
  513. package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
  514. package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
  515. package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
  516. package/.agents/tests/fm-bearings-board.test.sh +414 -0
  517. package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
  518. package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
  519. package/.agents/tests/fm-bootstrap.test.sh +1178 -0
  520. package/.agents/tests/fm-branch-supervision.test.sh +552 -0
  521. package/.agents/tests/fm-brief.test.sh +785 -0
  522. package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
  523. package/.agents/tests/fm-busy-state.test.sh +465 -0
  524. package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
  525. package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
  526. package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
  527. package/.agents/tests/fm-check-unregister.test.sh +198 -0
  528. package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
  529. package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
  530. package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
  531. package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
  532. package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
  533. package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
  534. package/.agents/tests/fm-composer-ghost.test.sh +716 -0
  535. package/.agents/tests/fm-composer-lib.test.sh +666 -0
  536. package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
  537. package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
  538. package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
  539. package/.agents/tests/fm-control.test.sh +910 -0
  540. package/.agents/tests/fm-crew-state.test.sh +1610 -0
  541. package/.agents/tests/fm-cursor-harness.test.sh +404 -0
  542. package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
  543. package/.agents/tests/fm-cursor-primary.test.sh +708 -0
  544. package/.agents/tests/fm-daemon.test.sh +2740 -0
  545. package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
  546. package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
  547. package/.agents/tests/fm-extension-binding.test.sh +2187 -0
  548. package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
  549. package/.agents/tests/fm-fleet-sync.test.sh +721 -0
  550. package/.agents/tests/fm-gate-refuse.test.sh +356 -0
  551. package/.agents/tests/fm-gitignore-config.test.sh +89 -0
  552. package/.agents/tests/fm-gotmp.test.sh +230 -0
  553. package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
  554. package/.agents/tests/fm-grok-harness.test.sh +117 -0
  555. package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
  556. package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
  557. package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
  558. package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
  559. package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
  560. package/.agents/tests/fm-herdr-lab.test.sh +243 -0
  561. package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
  562. package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
  563. package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
  564. package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
  565. package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
  566. package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
  567. package/.agents/tests/fm-kimi-harness.test.sh +687 -0
  568. package/.agents/tests/fm-lint-workflows.test.sh +532 -0
  569. package/.agents/tests/fm-lint.test.sh +1023 -0
  570. package/.agents/tests/fm-muse-harness.test.sh +932 -0
  571. package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
  572. package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
  573. package/.agents/tests/fm-on.test.sh +507 -0
  574. package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
  575. package/.agents/tests/fm-operational-input.test.sh +160 -0
  576. package/.agents/tests/fm-peek-remote.test.sh +110 -0
  577. package/.agents/tests/fm-pending-reply.test.sh +1288 -0
  578. package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
  579. package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
  580. package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
  581. package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
  582. package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
  583. package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
  584. package/.agents/tests/fm-pr-merge.test.sh +2135 -0
  585. package/.agents/tests/fm-procevent-quota.test.sh +225 -0
  586. package/.agents/tests/fm-procevent-when.test.sh +406 -0
  587. package/.agents/tests/fm-procevent.test.sh +1745 -0
  588. package/.agents/tests/fm-project-origin.test.sh +110 -0
  589. package/.agents/tests/fm-public-followup.test.sh +2272 -0
  590. package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
  591. package/.agents/tests/fm-quota-choose.test.sh +626 -0
  592. package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
  593. package/.agents/tests/fm-remote-doctor.test.sh +625 -0
  594. package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
  595. package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
  596. package/.agents/tests/fm-remote-job.test.sh +768 -0
  597. package/.agents/tests/fm-remote-reply.test.sh +520 -0
  598. package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
  599. package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
  600. package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
  601. package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
  602. package/.agents/tests/fm-review-diff.test.sh +176 -0
  603. package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
  604. package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
  605. package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
  606. package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
  607. package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
  608. package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
  609. package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
  610. package/.agents/tests/fm-send-inbox.test.sh +352 -0
  611. package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
  612. package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
  613. package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
  614. package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
  615. package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
  616. package/.agents/tests/fm-send-settle.test.sh +147 -0
  617. package/.agents/tests/fm-send-strict.test.sh +241 -0
  618. package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
  619. package/.agents/tests/fm-session-start.test.sh +2515 -0
  620. package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
  621. package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
  622. package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
  623. package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
  624. package/.agents/tests/fm-spawn-batch.test.sh +148 -0
  625. package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
  626. package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
  627. package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
  628. package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
  629. package/.agents/tests/fm-startup-network.test.sh +712 -0
  630. package/.agents/tests/fm-stow-cascade.test.sh +370 -0
  631. package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
  632. package/.agents/tests/fm-supervision-events.test.sh +156 -0
  633. package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
  634. package/.agents/tests/fm-tangle-guard.test.sh +277 -0
  635. package/.agents/tests/fm-task-delivery.test.sh +420 -0
  636. package/.agents/tests/fm-task-inbox.test.sh +513 -0
  637. package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
  638. package/.agents/tests/fm-teardown.test.sh +2665 -0
  639. package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
  640. package/.agents/tests/fm-test-fixtures.test.sh +132 -0
  641. package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
  642. package/.agents/tests/fm-test-run.test.sh +1196 -0
  643. package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
  644. package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
  645. package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
  646. package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
  647. package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
  648. package/.agents/tests/fm-transition-lib.test.sh +54 -0
  649. package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
  650. package/.agents/tests/fm-update.test.sh +304 -0
  651. package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
  652. package/.agents/tests/fm-voice-relay.test.sh +4741 -0
  653. package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
  654. package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
  655. package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
  656. package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
  657. package/.agents/tests/fm-wake-queue.test.sh +1224 -0
  658. package/.agents/tests/fm-watch-arm.test.sh +821 -0
  659. package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
  660. package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
  661. package/.agents/tests/fm-watch-triage.test.sh +3889 -0
  662. package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
  663. package/.agents/tests/fm-x-mode.test.sh +3042 -0
  664. package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
  665. package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
  666. package/.agents/tests/herdr-test-safety.sh +42 -0
  667. package/.agents/tests/lib.sh +315 -0
  668. package/.agents/tests/remote-herdr-fixture.sh +125 -0
  669. package/.agents/tests/secondmate-helpers.sh +193 -0
  670. package/.agents/tests/smoke.test.sh +93 -0
  671. package/.agents/tests/wake-helpers.sh +338 -0
  672. package/.agents/tests/zellij-test-safety.sh +60 -0
  673. package/.agents/tools/README.md +10 -0
  674. package/.agents/tools/bin/tasks-cli +267 -0
  675. package/.agents/tools/tasks-cli.ts +216 -0
  676. package/AGENTS.md +454 -0
  677. package/CHANGELOG.md +881 -0
  678. package/LICENSE +1 -1
  679. package/NOTICE +63 -0
  680. package/README.md +113 -59
  681. package/RULES/01-domains.md +51 -0
  682. package/RULES/02-agents.md +78 -0
  683. package/RULES/03-houses.md +61 -0
  684. package/RULES/04-hoard.md +65 -0
  685. package/RULES/05-platforms.md +67 -0
  686. package/RULES/06-append-only.md +58 -0
  687. package/RULES/07-config.md +45 -0
  688. package/RULES/08-delivery-gate.md +75 -0
  689. package/RULES/README.md +20 -0
  690. package/bin/README.md +107 -0
  691. package/bin/a2a-mcp.sh +98 -0
  692. package/bin/a2a-serve.py +103 -0
  693. package/bin/a2a-serve.sh +8 -0
  694. package/bin/a2a-talk.sh +50 -0
  695. package/bin/a2abridge-ensure.sh +108 -0
  696. package/bin/agent-run.sh +43 -0
  697. package/bin/agents-config.sh +233 -0
  698. package/bin/apodex-smoke-test.sh +92 -0
  699. package/bin/bifrost-bridge.sh +112 -0
  700. package/bin/bifrost-ingress.sh +77 -0
  701. package/bin/bootstrap-macos.sh +107 -0
  702. package/bin/bootstrap-windows.ps1 +112 -0
  703. package/bin/branch-guard.sh +64 -0
  704. package/bin/brokk +73 -0
  705. package/bin/brokk-classify-lib.sh +1770 -0
  706. package/bin/brokk-lease-lib.sh +222 -0
  707. package/bin/brokk-lease.sh +193 -0
  708. package/bin/brokk-lint.sh +65 -0
  709. package/bin/brokk-send.sh +38 -0
  710. package/bin/brokk-update.sh +9 -0
  711. package/bin/brokk-wake-grant.sh +127 -0
  712. package/bin/brokk-wake-lib.sh +1849 -0
  713. package/bin/changelog-guard.sh +165 -0
  714. package/bin/crash-sense.sh +160 -0
  715. package/bin/desktop-place.sh +224 -0
  716. package/bin/docs-guard.sh +39 -0
  717. package/bin/editor-place.sh +207 -0
  718. package/bin/eindri-acclaim.sh +34 -0
  719. package/bin/eindri-control.sh +28 -0
  720. package/bin/eindri-role.sh +83 -0
  721. package/bin/eindri-seen.sh +41 -0
  722. package/bin/eindri-send.sh +15 -0
  723. package/bin/eindri-start.sh +85 -0
  724. package/bin/eindri-watch.sh +57 -0
  725. package/bin/einherjar-spawn.sh +576 -0
  726. package/bin/eir-doctor.sh +112 -0
  727. package/bin/erindi-brief.sh +271 -0
  728. package/bin/fleet-apply.sh +68 -0
  729. package/bin/github-deploy.sh +81 -0
  730. package/bin/gjallarhorn-expose.sh +102 -0
  731. package/bin/gjallarhorn-notify.sh +58 -0
  732. package/bin/gjallarhorn-purge.sh +54 -0
  733. package/bin/gjallarhorn-tunnel.sh +72 -0
  734. package/bin/gleipnir-lock-lib.sh +284 -0
  735. package/bin/groa-update.sh +122 -0
  736. package/bin/hall-snapshot.sh +92 -0
  737. package/bin/hamr-harness.sh +246 -0
  738. package/bin/heimdall-ssh-keys.sh +374 -0
  739. package/bin/herdr-agents.py +44 -0
  740. package/bin/herdr-ensure.sh +104 -0
  741. package/bin/herdr-run.sh +431 -0
  742. package/bin/hermes-ensure.sh +88 -0
  743. package/bin/hoard-lib.sh +25 -0
  744. package/bin/hodd.sh +78 -0
  745. package/bin/host-sense.sh +199 -0
  746. package/bin/huginn-research-worker.sh +108 -0
  747. package/bin/local-model-lock.sh +51 -0
  748. package/bin/mimir-bridge.py +226 -0
  749. package/bin/mimir-bridge.sh +87 -0
  750. package/bin/mimir-ingest.sh +131 -0
  751. package/bin/mimir.sh +113 -0
  752. package/bin/mjollnir-webhook.sh +59 -0
  753. package/bin/mjollnir.sh +85 -0
  754. package/bin/model-hardware.sh +82 -0
  755. package/bin/model-resolve.sh +147 -0
  756. package/bin/models-detect.sh +178 -0
  757. package/bin/nornir-cron-start.sh +162 -0
  758. package/bin/nornir-job-daily-briefing.sh +191 -0
  759. package/bin/nornir-job-git-sync.sh +152 -0
  760. package/bin/nornir-job-memory-housekeeping.sh +114 -0
  761. package/bin/nornir-job-observer.sh +166 -0
  762. package/bin/nornir-job-skillopt-sleep.sh +76 -0
  763. package/bin/omarchy-hook-install.sh +60 -0
  764. package/bin/omarchy-install.sh +148 -0
  765. package/bin/omarchy-plugins.sh +174 -0
  766. package/bin/omarchy-sense.sh +149 -0
  767. package/bin/perm-guard.sh +30 -0
  768. package/bin/pi-agent.sh +68 -0
  769. package/bin/pi-ensure.sh +77 -0
  770. package/bin/pi-local.sh +37 -0
  771. package/bin/pi-seat.sh +63 -0
  772. package/bin/prereq-ensure.sh +166 -0
  773. package/bin/private-guard.sh +38 -0
  774. package/bin/project-git.sh +55 -0
  775. package/bin/public-guard.sh +70 -0
  776. package/bin/ratatoskr.sh +49 -0
  777. package/bin/realm-lib.sh +32 -0
  778. package/bin/repo-scrub.sh +62 -0
  779. package/bin/rodd-operational-input.sh +250 -0
  780. package/bin/runes-append.sh +198 -0
  781. package/bin/saga-session-start.sh +172 -0
  782. package/bin/saga-sessionstart-run.sh +79 -0
  783. package/bin/saga-wake-drain.sh +41 -0
  784. package/bin/secret-guard.sh +63 -0
  785. package/bin/sessrumnir-ensure.sh +100 -0
  786. package/bin/sessrumnir-sync.sh +237 -0
  787. package/bin/sessrumnir.sh +91 -0
  788. package/bin/skillopt-setup.sh +76 -0
  789. package/bin/skuld-branch-outcome.sh +203 -0
  790. package/bin/skuld-branch-prompt.sh +101 -0
  791. package/bin/smidja-bootstrap.sh +90 -0
  792. package/bin/smidja-observe.sh +86 -0
  793. package/bin/syn-arm-pretool-check.sh +57 -0
  794. package/bin/syn-asset-pretool-check.sh +76 -0
  795. package/bin/syn-cd-pretool-check.sh +28 -0
  796. package/bin/syn-guard-pretool-check.sh +83 -0
  797. package/bin/syn-turnend-guard.sh +36 -0
  798. package/bin/syn-watch-arm.sh +99 -0
  799. package/bin/tailscale-sync.sh +190 -0
  800. package/bin/telegram-bot.sh +69 -0
  801. package/bin/toolchain.sh +74 -0
  802. package/bin/utgard.sh +111 -0
  803. package/bin/valhalla.sh +115 -0
  804. package/bin/valknut-load.sh +260 -0
  805. package/bin/vor-crew-state.sh +124 -0
  806. package/bin/wedge-notify.sh +47 -0
  807. package/bin/workspace-provision.sh +80 -0
  808. package/bin/workspace-rag.sh +150 -0
  809. package/bin/wyrd-db.sh +47 -0
  810. package/bin/yggdrasil.sh +152 -0
  811. package/bin/ymir-edit.sh +145 -0
  812. package/bin/ymir-install.sh +641 -0
  813. package/bin/ymir-invite.sh +93 -0
  814. package/bin/ymir-isolation.sh +88 -0
  815. package/bin/ymir-migrate.sh +61 -0
  816. package/bin/ymir-platform.sh +348 -0
  817. package/bin/ymir-say.sh +97 -0
  818. package/bin/ymir-setup-auth.sh +108 -0
  819. package/bin/ymir-validate.sh +173 -0
  820. package/bin/ymir.js +74 -16
  821. package/docs/Architecture.md +339 -0
  822. package/docs/design.md +351 -0
  823. package/docs/installations/README.md +63 -0
  824. package/docs/installations/ymir-home.md +93 -0
  825. package/docs/integration.md +105 -0
  826. package/docs/lore.md +1056 -0
  827. package/docs/research/agent-harnesses.md +290 -0
  828. package/docs/runbooks/README.md +19 -0
  829. package/docs/runbooks/a2a.md +91 -0
  830. package/docs/runbooks/agent-permissions.md +99 -0
  831. package/docs/runbooks/agents.md +57 -0
  832. package/docs/runbooks/models.md +69 -0
  833. package/docs/runbooks/realm-onboarding.md +60 -0
  834. package/docs/runbooks/secrets-and-hoard.md +60 -0
  835. package/docs/runbooks/updates-and-migrations.md +110 -0
  836. package/docs/runbooks/worktrees-isolation.md +72 -0
  837. package/docs/session-start.md +105 -0
  838. package/docs/skillopt-integration.md +321 -0
  839. package/docs/sync-system.md +66 -0
  840. package/docs/workspaces.md +104 -0
  841. package/docs/ymir-rut.md +260 -0
  842. package/package.json +44 -14
  843. package/packaging/build.sh +74 -0
  844. package/packaging/macos/Ymir Installer.command +43 -0
  845. package/packaging/windows/ymir-setup.nsi +74 -0
  846. package/scripts/README.md +22 -0
  847. package/scripts/electron.sh +210 -0
  848. package/scripts/start.sh +167 -0
  849. package/scripts/stop.sh +113 -0
  850. package/install.sh +0 -35
@@ -0,0 +1,2665 @@
1
+ #!/usr/bin/env bash
2
+ # Tests for bin/fm-teardown.sh's landed-work safety and stale-lock recovery.
3
+ #
4
+ # The check refuses to tear down a worktree whose work has not LANDED, because
5
+ # treehouse return hard-resets the worktree. "Landed" means reachable from a remote
6
+ # OR - for a normal ship task whose commits are not so reachable - its PR is merged
7
+ # and GitHub reports a PR head that contains the current local work, or its content
8
+ # is already in the up-to-date default branch.
9
+ #
10
+ # Covers three fixes:
11
+ # - local-only fork-remote: a fork IS a remote, so fork-pushed upstream-
12
+ # contribution PRs are teardown-eligible (the pre-fix code false-refused them).
13
+ # - squash-merge-then-delete-branch: the branch's own commits live nowhere on a
14
+ # remote after a squash merge deletes the head branch, yet the change is fully in
15
+ # main. Reachability alone false-refused this common GitHub flow; the check now
16
+ # recognizes a merged PR head containing the local work (or the content already
17
+ # in main) as landed.
18
+ # - teardown-lock-race: a killed crew process can leave a transient worktree
19
+ # git index.lock that blocks teardown. The return path retries on the lock
20
+ # error signature (even if the lock self-clears mid-check), then only removes a
21
+ # provably stale lock before re-running safety checks.
22
+ #
23
+ # Matrix:
24
+ # (a) local-only + HEAD on a fork remote-tracking branch -> ALLOW (fork fix)
25
+ # (b) local-only + truly unpushed work (no remote, not main) -> REFUSE (safety)
26
+ # (c) local-only + merged into local main, no remote -> ALLOW (no regression)
27
+ # (d) no-mistakes + HEAD on origin remote-tracking branch -> ALLOW (no regression)
28
+ # (e) no-mistakes + unpushed, no PR, content not in default -> REFUSE (safety)
29
+ # (f) local-only + truly unpushed + --force -> ALLOW (escape hatch)
30
+ # (g) no-mistakes + squash-merged PR, exact PR head -> ALLOW (squash fix)
31
+ # (h) no-mistakes + no PR but content already in default -> ALLOW (content fallback)
32
+ # (i) no-mistakes + dirty worktree, even when work landed -> REFUSE (dirty wins)
33
+ # (j) no-mistakes + gh lookup errors + content not in default -> REFUSE (fail-safe)
34
+ # (k) no-mistakes + merged PR but HEAD moved afterward -> REFUSE (stale PR)
35
+ # (l) no-mistakes + stale origin/main but fetched content -> ALLOW (fresh fetch)
36
+ # (m) no-mistakes + local HEAD ancestor of merged PR head -> ALLOW (lagging local)
37
+ # (n) no-mistakes + replayed unpushed patch in merged PR head -> ALLOW (replayed local)
38
+ # (o) fm-pr-check rerun after HEAD moved -> no stale pr_head
39
+ # (p) fm-pr-check when local HEAD lags -> record remote PR head
40
+ # (q) no-mistakes + NO pr= recorded, PR discovered by branch -> ALLOW (yolo/no-CI merge)
41
+ #
42
+ # Also covers backlog teardown-lock-race: a git index.lock left in the worktree by a
43
+ # killed crew process (bin/fm-teardown.sh's teardown_treehouse_return).
44
+ # (r) provably-stale index.lock (old mtime, no live holder) -> lock removed, ALLOW
45
+ # (s) index.lock with a live holder, any age -> lock kept, REFUSE
46
+ # (t) lsof error while checking index.lock -> lock kept, REFUSE
47
+ # (u) dirty worktree after stale lock cleanup -> lock removed, REFUSE
48
+ # (v) non-linked repo index.lock -> lock removed, ALLOW
49
+ # (w) index.lock mtime read failure -> lock kept, REFUSE
50
+ # (x) transient lock cleared after first failed return -> retry ALLOW
51
+ # (y) persistent lock (never clears, not provably stale) -> REFUSE loudly
52
+ set -u
53
+
54
+ # shellcheck source=tests/lib.sh disable=SC1091
55
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
56
+ fm_git_identity fmtest fmtest@example.invalid
57
+
58
+ TEARDOWN="$ROOT/bin/fm-teardown.sh"
59
+ PR_CHECK="$ROOT/bin/fm-pr-check.sh"
60
+ TMP_ROOT=$(fm_test_tmproot fm-teardown-tests)
61
+ REAL_GIT_FOR_TEST=$(command -v git)
62
+ export REAL_GIT_FOR_TEST
63
+ REAL_PS_FOR_TEST=$(command -v ps)
64
+ export REAL_PS_FOR_TEST
65
+ REAL_LSOF_FOR_TEST=$(command -v lsof)
66
+ export REAL_LSOF_FOR_TEST
67
+
68
+ # Build a fresh sandbox for one test case. Sets up:
69
+ # $CASE/state/ - firstmate state dir (with a fresh watcher beacon)
70
+ # $CASE/fakebin/ - mocks for treehouse, tmux (PATH-prepended by caller)
71
+ # $CASE/origin.git/ - bare upstream repo (so the project clone has origin)
72
+ # $CASE/project/ - clone of origin; acts as the firstmate project dir
73
+ # $CASE/wt/ - a worktree of the project (the task worktree)
74
+ # Echoes the case dir.
75
+ make_case() {
76
+ local name=$1 case_dir fakebin
77
+ case_dir="$TMP_ROOT/$name"
78
+ fakebin="$case_dir/fakebin"
79
+ mkdir -p "$case_dir/state" "$case_dir/config" "$case_dir/data" "$fakebin"
80
+
81
+ # Mocks for the post-check teardown steps. Refuse logic exits before these
82
+ # run; the ALLOW cases need them so the script can complete cleanly.
83
+ cat > "$fakebin/treehouse" <<'SH'
84
+ #!/usr/bin/env bash
85
+ # `treehouse return --force <wt>`: succeed silently.
86
+ exit 0
87
+ SH
88
+ cat > "$fakebin/tmux" <<'SH'
89
+ #!/usr/bin/env bash
90
+ # tmux kill-window etc.: succeed silently.
91
+ exit 0
92
+ SH
93
+ # Default gh-axi mock: no PR is associated with the branch, and viewing any PR
94
+ # number fails. This keeps the landed-work check hermetic (never reaching the real
95
+ # gh-axi) and represents the common "no GitHub PR" baseline. Tests that need a
96
+ # merged PR or a lookup error override this file with the helpers below.
97
+ cat > "$fakebin/gh-axi" <<'SH'
98
+ #!/usr/bin/env bash
99
+ case "${1:-} ${2:-}" in
100
+ "pr list") printf '%s\n' "count: 0 (showing first 0)" "pull_requests[]: []" ; exit 0 ;;
101
+ "pr view") echo "error: pull request not found" >&2 ; exit 1 ;;
102
+ esac
103
+ exit 0
104
+ SH
105
+ cat > "$fakebin/gh" <<'SH'
106
+ #!/usr/bin/env bash
107
+ case "${1:-} ${2:-}" in
108
+ "pr view") echo "error: pull request not found" >&2 ; exit 1 ;;
109
+ esac
110
+ exit 0
111
+ SH
112
+ # Default hermetic no-mistakes stub: `axi status` answers FM_FAKE_AXI_STATUS
113
+ # verbatim (empty by default, i.e. no active run - the pre-teardown run-abort
114
+ # step is then a no-op), and `axi abort` appends one line to
115
+ # FM_FAKE_NM_ABORT_LOG when set. This keeps every case hermetic - without it,
116
+ # `command -v no-mistakes` would fall through to whatever real binary
117
+ # happens to be on the test runner's own PATH. Tests exercising the run-abort
118
+ # path override FM_FAKE_AXI_STATUS/FM_FAKE_NM_ABORT_LOG before run_teardown.
119
+ cat > "$fakebin/no-mistakes" <<'SH'
120
+ #!/usr/bin/env bash
121
+ case "${1:-}" in
122
+ axi)
123
+ shift
124
+ case "${1:-}" in
125
+ status)
126
+ shift
127
+ run_id=""
128
+ if [ "${1:-}" = --run ]; then run_id=${2:-}; fi
129
+ if [ -n "${FM_FAKE_NM_ABORT_LOG:-}" ] \
130
+ && grep -Fxq "abort --run $run_id" "$FM_FAKE_NM_ABORT_LOG" 2>/dev/null \
131
+ && [ "${FM_FAKE_NM_ABORT_NOOP:-0}" != 1 ]; then
132
+ if [ "${FM_FAKE_NM_NOT_FOUND_AFTER_ABORT:-0}" = 1 ]; then
133
+ printf 'error: "run \\"%s\\" not found"\n' "$run_id" >&2
134
+ exit 1
135
+ elif [ "${FM_FAKE_NM_EMPTY_AFTER_ABORT:-0}" = 1 ]; then
136
+ exit 0
137
+ elif [ -n "${FM_FAKE_AXI_STATUS_AFTER_ABORT:-}" ]; then
138
+ printf '%s\n' "$FM_FAKE_AXI_STATUS_AFTER_ABORT"
139
+ else
140
+ printf 'run:\n id: "%s"\n outcome: cancelled\n' "$run_id"
141
+ fi
142
+ else
143
+ printf '%s\n' "${FM_FAKE_AXI_STATUS:-}"
144
+ fi
145
+ ;;
146
+ abort)
147
+ shift
148
+ [ -z "${FM_FAKE_NM_ABORT_LOG:-}" ] || printf 'abort %s\n' "$*" >> "$FM_FAKE_NM_ABORT_LOG"
149
+ exit 0 ;;
150
+ esac
151
+ ;;
152
+ esac
153
+ exit 0
154
+ SH
155
+ chmod +x "$fakebin/treehouse" "$fakebin/tmux" "$fakebin/gh-axi" "$fakebin/gh" "$fakebin/no-mistakes"
156
+
157
+ # Bare origin so the clone has an `origin` remote and origin/HEAD.
158
+ git init -q --bare "$case_dir/origin.git"
159
+ git -C "$case_dir/origin.git" symbolic-ref HEAD refs/heads/main
160
+ # Seed origin with one commit BEFORE cloning so the clone is not empty.
161
+ git clone -q "$case_dir/origin.git" "$case_dir/_seed" 2>/dev/null
162
+ git -C "$case_dir/_seed" -c user.email=t@t -c user.name=t \
163
+ commit -q --allow-empty -m "origin baseline"
164
+ git -C "$case_dir/_seed" push -q origin main
165
+ rm -rf "$case_dir/_seed"
166
+ # Clone as the project; give it a `main` branch and an origin/HEAD.
167
+ git clone -q "$case_dir/origin.git" "$case_dir/project"
168
+ git -C "$case_dir/project" remote set-head origin main 2>/dev/null || true
169
+ # Add a worktree on a fresh task branch; that branch is where the crewmate commits.
170
+ git -C "$case_dir/project" worktree add -q -b fm/task-x1 "$case_dir/wt" main
171
+
172
+ # Fresh watcher beacon so fm-guard stays quiet.
173
+ touch "$case_dir/state/.last-watcher-beat"
174
+
175
+ printf '%s\n' "$case_dir"
176
+ }
177
+
178
+ # Write a meta file for the task. Args: case_dir mode kind
179
+ write_meta() {
180
+ local case_dir=$1 mode=$2 kind=$3
181
+ fm_write_meta "$case_dir/state/task-x1.meta" \
182
+ "window=firstmate:fm-task-x1" \
183
+ "endpoint_task_id=task-x1" \
184
+ "worktree=$case_dir/wt" \
185
+ "project=$case_dir/project" \
186
+ "kind=$kind" \
187
+ "mode=$mode" \
188
+ "spawn_gen=teardown-test-task-x1"
189
+ }
190
+
191
+ # Commit something on the worktree's task branch. Args: case_dir [message]
192
+ wt_commit() {
193
+ local case_dir=$1 msg=${2:-wt work}
194
+ git -C "$case_dir/wt" -c user.email=t@t -c user.name=t \
195
+ commit -q --allow-empty -m "$msg"
196
+ }
197
+
198
+ # Add a fork bare repo and register it as a remote on the project, then push
199
+ # the worktree's task branch to it and fetch into the project so the worktree
200
+ # sees the remote-tracking ref. Args: case_dir
201
+ add_fork_with_pushed_branch() {
202
+ local case_dir=$1
203
+ git init -q --bare "$case_dir/fork.git"
204
+ git -C "$case_dir/project" remote add fork "$case_dir/fork.git"
205
+ # Push the task branch from the worktree to the fork, then fetch into project
206
+ # so refs/remotes/fork/fm-task-x1 is visible from the worktree (shared object db).
207
+ git -C "$case_dir/wt" push -q fork fm/task-x1
208
+ git -C "$case_dir/project" fetch -q fork
209
+ }
210
+
211
+ # Commit a real file change on the worktree's task branch (unlike wt_commit, which
212
+ # makes an empty commit). A non-empty tree is what the content-in-default check
213
+ # inspects. Args: case_dir file content [message]
214
+ wt_commit_file() {
215
+ local case_dir=$1 file=$2 content=$3 msg=${4:-add $2}
216
+ printf '%s\n' "$content" > "$case_dir/wt/$file"
217
+ git -C "$case_dir/wt" add -- "$file"
218
+ git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "$msg"
219
+ }
220
+
221
+ # Land <file>=<content> as a single commit on origin's default branch, simulating a
222
+ # squash merge whose net change matches the task branch but whose commit differs.
223
+ # After this, the branch's content is in origin/main even though the branch's own
224
+ # commits are not reachable from it. Args: case_dir file content
225
+ land_on_origin_main() {
226
+ local case_dir=$1 file=$2 content=$3 tmp
227
+ tmp="$case_dir/_land"
228
+ git clone -q "$case_dir/origin.git" "$tmp"
229
+ printf '%s\n' "$content" > "$tmp/$file"
230
+ git -C "$tmp" add -- "$file"
231
+ git -C "$tmp" -c user.email=t@t -c user.name=t commit -q -m "squash $file"
232
+ git -C "$tmp" push -q origin HEAD:main
233
+ rm -rf "$tmp"
234
+ }
235
+
236
+ # Override GitHub lookups to report PR 7 as merged with the supplied head.
237
+ add_gh_pr_merged_for_head() {
238
+ local case_dir=$1 head=$2
239
+ cat > "$case_dir/fakebin/gh-axi" <<'SH'
240
+ #!/usr/bin/env bash
241
+ case "${1:-} ${2:-}" in
242
+ "pr list")
243
+ printf '%s\n' "count: 1 (showing first 1)" "pull_requests[1]{number,state}:" " 7,merged" ; exit 0 ;;
244
+ "pr view")
245
+ printf '%s\n' "pull_request:" " number: 7" " state: merged" ' merged: "2026-06-26T00:00:00Z"' ; exit 0 ;;
246
+ esac
247
+ exit 0
248
+ SH
249
+ cat > "$case_dir/fakebin/gh" <<SH
250
+ #!/usr/bin/env bash
251
+ case "\${1:-} \${2:-}" in
252
+ "pr view")
253
+ case " \$* " in
254
+ *"state,headRefOid,url"*) printf '%s\t%s\t%s\n' 'MERGED' '$head' 'https://github.com/example/repo/pull/7' ; exit 0 ;;
255
+ *"headRefOid"*) printf '%s\n' '$head' ; exit 0 ;;
256
+ esac
257
+ ;;
258
+ esac
259
+ echo "error: pull request not found" >&2
260
+ exit 1
261
+ SH
262
+ chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh"
263
+ }
264
+
265
+ append_pr_meta_for_current_head() {
266
+ local case_dir=$1 head
267
+ head=$(git -C "$case_dir/wt" rev-parse HEAD)
268
+ printf '%s\n' \
269
+ 'pr=https://github.com/example/repo/pull/7' \
270
+ "pr_head=$head" >> "$case_dir/state/task-x1.meta"
271
+ }
272
+
273
+ append_pr_meta_url() {
274
+ local case_dir=$1
275
+ printf '%s\n' 'pr=https://github.com/example/repo/pull/7' >> "$case_dir/state/task-x1.meta"
276
+ }
277
+
278
+ commit_tree_from_wt_head() {
279
+ local case_dir=$1 parent=$2 msg=$3 tree
280
+ tree=$(git -C "$case_dir/wt" rev-parse "$parent^{tree}") || return 1
281
+ printf '%s\n' "$msg" | git -C "$case_dir/wt" commit-tree "$tree" -p "$parent"
282
+ }
283
+
284
+ land_equivalent_patch_on_origin_branch() {
285
+ local case_dir=$1 branch=$2 file=$3 content=$4 msg=$5 tmp
286
+ tmp="$case_dir/_equiv"
287
+ git clone -q "$case_dir/origin.git" "$tmp"
288
+ printf '%s\n' "$content" > "$tmp/$file"
289
+ git -C "$tmp" add -- "$file"
290
+ git -C "$tmp" -c user.email=t@t -c user.name=t commit -q -m "$msg"
291
+ git -C "$tmp" push -q origin "HEAD:refs/heads/$branch"
292
+ git -C "$case_dir/project" fetch -q origin "$branch"
293
+ rm -rf "$tmp"
294
+ git -C "$case_dir/project" rev-parse "refs/remotes/origin/$branch"
295
+ }
296
+
297
+ # Override gh-axi so every call fails, simulating an API/network error.
298
+ add_gh_axi_error() {
299
+ local case_dir=$1
300
+ cat > "$case_dir/fakebin/gh-axi" <<'SH'
301
+ #!/usr/bin/env bash
302
+ echo "error: gh-axi unavailable" >&2
303
+ exit 1
304
+ SH
305
+ cat > "$case_dir/fakebin/gh" <<'SH'
306
+ #!/usr/bin/env bash
307
+ echo "error: gh unavailable" >&2
308
+ exit 1
309
+ SH
310
+ chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh"
311
+ }
312
+
313
+ # Override fakebin/treehouse so `treehouse return --force <wt>` fails with a
314
+ # git "file exists" lock error whenever the worktree's real index.lock is
315
+ # present, and succeeds once it is gone. This drives the lock through
316
+ # fm-teardown.sh's own retry-then-stale-cleanup logic (teardown_treehouse_return
317
+ # in bin/fm-teardown.sh) rather than hand-simulating that logic in the test.
318
+ add_lock_aware_treehouse() {
319
+ local case_dir=$1
320
+ cat > "$case_dir/fakebin/treehouse" <<'SH'
321
+ #!/usr/bin/env bash
322
+ if [ "${1:-}" = return ]; then
323
+ shift
324
+ wt=""
325
+ for a in "$@"; do
326
+ case "$a" in
327
+ --force) ;;
328
+ *) wt=$a ;;
329
+ esac
330
+ done
331
+ lock=$(git -C "$wt" rev-parse --git-path index.lock 2>/dev/null || true)
332
+ case "$lock" in
333
+ /*|'') ;;
334
+ *) lock="$wt/$lock" ;;
335
+ esac
336
+ if [ -n "$lock" ] && [ -e "$lock" ]; then
337
+ echo "fatal: Unable to create '$lock': File exists." >&2
338
+ exit 128
339
+ fi
340
+ exit 0
341
+ fi
342
+ exit 0
343
+ SH
344
+ chmod +x "$case_dir/fakebin/treehouse"
345
+ }
346
+
347
+ # treehouse return fails once with the index.lock signature, then clears the lock
348
+ # (simulating a dying crew git process finishing) so the next retry succeeds.
349
+ # The first failure always reports the lock path even if the file is removed in
350
+ # the same attempt - matching the production race where the lock self-clears
351
+ # between the failed return and the supervisor's existence check.
352
+ add_transient_lock_treehouse() {
353
+ local case_dir=$1
354
+ cat > "$case_dir/fakebin/treehouse" <<'SH'
355
+ #!/usr/bin/env bash
356
+ if [ "${1:-}" = return ]; then
357
+ shift
358
+ wt=""
359
+ for a in "$@"; do
360
+ case "$a" in
361
+ --force) ;;
362
+ *) wt=$a ;;
363
+ esac
364
+ done
365
+ lock=$(git -C "$wt" rev-parse --git-path index.lock 2>/dev/null || true)
366
+ case "$lock" in
367
+ /*|'') ;;
368
+ *) lock="$wt/$lock" ;;
369
+ esac
370
+ count_file="${TREEHOUSE_ATTEMPT_FILE:?}"
371
+ count=0
372
+ if [ -f "$count_file" ]; then
373
+ count=$(cat "$count_file")
374
+ fi
375
+ count=$(( count + 1 ))
376
+ printf '%s\n' "$count" > "$count_file"
377
+ if [ "$count" -eq 1 ]; then
378
+ # Emit the real git signature, then drop the lock so a lock-existence-only
379
+ # recovery path would wrongly abort without retrying.
380
+ if [ -n "$lock" ]; then
381
+ echo "fatal: Unable to create '$lock': File exists." >&2
382
+ rm -f "$lock"
383
+ else
384
+ echo "fatal: Unable to create 'index.lock': File exists." >&2
385
+ fi
386
+ exit 128
387
+ fi
388
+ exit 0
389
+ fi
390
+ exit 0
391
+ SH
392
+ chmod +x "$case_dir/fakebin/treehouse"
393
+ }
394
+
395
+ # treehouse return always fails with the lock signature while the lock file
396
+ # remains; used to assert exhausted retries still refuse loudly.
397
+ add_persistent_lock_treehouse() {
398
+ local case_dir=$1
399
+ cat > "$case_dir/fakebin/treehouse" <<'SH'
400
+ #!/usr/bin/env bash
401
+ if [ "${1:-}" = return ]; then
402
+ shift
403
+ wt=""
404
+ for a in "$@"; do
405
+ case "$a" in
406
+ --force) ;;
407
+ *) wt=$a ;;
408
+ esac
409
+ done
410
+ lock=$(git -C "$wt" rev-parse --git-path index.lock 2>/dev/null || true)
411
+ case "$lock" in
412
+ /*|'') ;;
413
+ *) lock="$wt/$lock" ;;
414
+ esac
415
+ if [ -z "$lock" ]; then
416
+ lock="index.lock"
417
+ fi
418
+ echo "fatal: Unable to create '$lock': File exists." >&2
419
+ exit 128
420
+ fi
421
+ exit 0
422
+ SH
423
+ chmod +x "$case_dir/fakebin/treehouse"
424
+ }
425
+
426
+ git_index_lock_path() {
427
+ local dir=$1 lock abs_dir
428
+ lock=$(git -C "$dir" rev-parse --git-path index.lock)
429
+ case "$lock" in
430
+ /*) printf '%s\n' "$lock" ;;
431
+ *)
432
+ abs_dir=$(cd "$dir" && pwd -P)
433
+ printf '%s/%s\n' "$abs_dir" "$lock"
434
+ ;;
435
+ esac
436
+ }
437
+
438
+ # fakebin/lsof stub: no process ever holds anything open (lsof's not-found exit
439
+ # code), so a lock's staleness is decided by age alone. The cwd scan is a
440
+ # separate successful empty query.
441
+ add_lsof_no_holder() {
442
+ local case_dir=$1
443
+ cat > "$case_dir/fakebin/lsof" <<'SH'
444
+ #!/usr/bin/env bash
445
+ case " $* " in
446
+ *" -d cwd "*) exit 0 ;;
447
+ esac
448
+ exit 1
449
+ SH
450
+ chmod +x "$case_dir/fakebin/lsof"
451
+ }
452
+
453
+ # fakebin/lsof stub: a live process holds every queried path open, so a lock is
454
+ # never judged stale regardless of its age.
455
+ add_lsof_live_holder() {
456
+ local case_dir=$1
457
+ cat > "$case_dir/fakebin/lsof" <<'SH'
458
+ #!/usr/bin/env bash
459
+ exit 0
460
+ SH
461
+ chmod +x "$case_dir/fakebin/lsof"
462
+ }
463
+
464
+ add_lsof_error() {
465
+ local case_dir=$1
466
+ cat > "$case_dir/fakebin/lsof" <<'SH'
467
+ #!/usr/bin/env bash
468
+ echo "lsof: simulated failure for ${1:-unknown}" >&2
469
+ exit 2
470
+ SH
471
+ chmod +x "$case_dir/fakebin/lsof"
472
+ }
473
+
474
+ add_stat_error() {
475
+ local case_dir=$1
476
+ cat > "$case_dir/fakebin/stat" <<'SH'
477
+ #!/usr/bin/env bash
478
+ echo "stat: simulated failure" >&2
479
+ exit 1
480
+ SH
481
+ chmod +x "$case_dir/fakebin/stat"
482
+ }
483
+
484
+ add_git_status_lock_failure() {
485
+ local case_dir=$1
486
+ cat > "$case_dir/fakebin/git" <<'SH'
487
+ #!/usr/bin/env bash
488
+ real=${REAL_GIT_FOR_TEST:?}
489
+ dir=
490
+ args=()
491
+ while [ "$#" -gt 0 ]; do
492
+ case "$1" in
493
+ -C)
494
+ dir=$2
495
+ args+=("$1" "$2")
496
+ shift 2
497
+ ;;
498
+ *)
499
+ args+=("$1")
500
+ shift
501
+ ;;
502
+ esac
503
+ done
504
+ if [ -n "$dir" ] && [ "${args[2]:-}" = status ] && [ "${args[3]:-}" = --porcelain ]; then
505
+ lock=$("$real" -C "$dir" rev-parse --git-path index.lock 2>/dev/null || true)
506
+ case "$lock" in
507
+ /*|'') ;;
508
+ *) lock="$dir/$lock" ;;
509
+ esac
510
+ if [ -n "$lock" ] && [ -e "$lock" ]; then
511
+ echo "fatal: Unable to create '$lock': File exists." >&2
512
+ exit 128
513
+ fi
514
+ fi
515
+ exec "$real" "${args[@]}"
516
+ SH
517
+ chmod +x "$case_dir/fakebin/git"
518
+ }
519
+
520
+ # Run teardown with PATH mocking. Args: case_dir [extra args...]
521
+ run_teardown() {
522
+ local case_dir=$1; shift
523
+ # FM_DATA_OVERRIDE is pinned to the case dir because teardown closes this
524
+ # home's backlog item itself; without it $DATA would resolve to the real
525
+ # repo's own home and a test could mutate live records.
526
+ FM_ROOT_OVERRIDE="$ROOT" \
527
+ FM_STATE_OVERRIDE="$case_dir/state" \
528
+ FM_DATA_OVERRIDE="$case_dir/data" \
529
+ FM_CONFIG_OVERRIDE="$case_dir/config" \
530
+ PATH="$case_dir/fakebin:${FM_TEARDOWN_TEST_PATH:-$PATH}" \
531
+ "$TEARDOWN" task-x1 "$@"
532
+ }
533
+
534
+ # Seed a real backlog carrying task-x1 as In flight, so a teardown in this case
535
+ # has a row to close. Uses the real tasks-axi (the fixture's default fakebin has
536
+ # no tasks-axi stub, so PATH resolves the installed one).
537
+ seed_backlog_in_flight() {
538
+ local case_dir=$1 kind=${2:-ship}
539
+ mkdir -p "$case_dir/data"
540
+ printf '%s\n' '# Backlog' '' '## In flight' '' '## Queued' '' '## Done' \
541
+ > "$case_dir/data/backlog.md"
542
+ tasks-axi add task-x1 "teardown fixture task" --kind "$kind" \
543
+ --file "$case_dir/data/backlog.md" >/dev/null
544
+ tasks-axi start task-x1 --file "$case_dir/data/backlog.md" >/dev/null
545
+ }
546
+
547
+ backlog_row_state() {
548
+ local case_dir=$1
549
+ tasks-axi show task-x1 --file "$case_dir/data/backlog.md" 2>/dev/null |
550
+ sed -n 's/^ state: *//p' | head -1
551
+ }
552
+
553
+ # Build the teardown test's executable search path without lsof, regardless of
554
+ # whether the host installs it in /usr/bin, /usr/sbin, or a package-manager bin.
555
+ make_path_without_lsof() { # <case-dir>
556
+ local case_dir=$1 path_dir="$1/path-without-lsof" cmd resolved
557
+ mkdir -p "$path_dir"
558
+ for cmd in awk bash basename cat chmod cp cut date dirname env find git grep head hostname id ln \
559
+ mkdir mktemp mv perl ps readlink realpath rm sed sh sleep sort stat tail timeout tr uname wc xargs; do
560
+ resolved=$(command -v "$cmd" 2>/dev/null) || continue
561
+ case "$resolved" in /*) ln -sf "$resolved" "$path_dir/$cmd" ;; esac
562
+ done
563
+ printf '%s\n' "$path_dir"
564
+ }
565
+
566
+ test_local_only_fork_remote_allows() {
567
+ local case_dir rc
568
+ case_dir=$(make_case fork-allow)
569
+ write_meta "$case_dir" local-only ship
570
+ wt_commit "$case_dir" "fix the thing"
571
+ add_fork_with_pushed_branch "$case_dir"
572
+
573
+ set +e
574
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
575
+ rc=$?
576
+ set -e
577
+
578
+ expect_code 0 "$rc" "fork-allow: teardown should succeed when HEAD is on a fork remote"
579
+ ! grep -q REFUSED "$case_dir/stderr" || fail "fork-allow: teardown printed a REFUSED line"
580
+ jq -e --arg id task-x1 '
581
+ .schema == "fm-secondmate-home-summary.v1"
582
+ and all(.endpoints[]; .id != $id)
583
+ ' "$case_dir/state/home-summary.json" >/dev/null \
584
+ || fail "successful task teardown did not publish the task's removal from the home summary ledger"
585
+ pass "local-only worktree with HEAD on a fork remote is torn down and the home summary is refreshed"
586
+ }
587
+
588
+ test_teardown_closes_the_backlog_item_itself() {
589
+ local case_dir out
590
+ case_dir=$(make_case tasks-axi-close)
591
+ write_meta "$case_dir" no-mistakes ship
592
+ printf '%s\n' 'pr=https://github.com/example/repo/pull/7' >> "$case_dir/state/task-x1.meta"
593
+ seed_backlog_in_flight "$case_dir"
594
+
595
+ out=$(run_teardown "$case_dir") || fail "teardown failed with a real backlog"
596
+ [ "$(backlog_row_state "$case_dir")" = "done" ] \
597
+ || fail "teardown returned success while its backlog item was still open: $(backlog_row_state "$case_dir")"
598
+ assert_grep 'https://github.com/example/repo/pull/7' "$case_dir/data/backlog.md" \
599
+ "closed backlog item did not record the task's PR"
600
+ assert_absent "$case_dir/state/task-x1.backlog-close" \
601
+ "a landed close left its pending-close record behind"
602
+ printf '%s\n' "$out" | grep -F 'tasks-axi ready' >/dev/null \
603
+ || fail "teardown dropped the dependency-cleared follow-up: $out"
604
+ printf '%s\n' "$out" | grep -F 'check date gates' >/dev/null \
605
+ || fail "teardown did not preserve date-gate check: $out"
606
+ printf '%s\n' "$out" | grep -F 'Run tasks-axi done' >/dev/null \
607
+ && fail "teardown still asked a later turn to close the item it already closed: $out"
608
+ pass "teardown closes its own backlog item before reporting success"
609
+ }
610
+
611
+ test_teardown_manual_backend_leaves_the_backlog_to_the_operator() {
612
+ local case_dir out backlog_path
613
+ case_dir=$(make_case tasks-axi-manual-optout)
614
+ write_meta "$case_dir" no-mistakes ship
615
+ printf '%s\n' 'pr=https://github.com/example/repo/pull/7' >> "$case_dir/state/task-x1.meta"
616
+ printf '%s\n' manual > "$case_dir/config/backlog-backend"
617
+ seed_backlog_in_flight "$case_dir"
618
+
619
+ out=$(run_teardown "$case_dir") || fail "teardown failed with manual backlog backend"
620
+ [ "$(backlog_row_state "$case_dir")" = in_flight ] \
621
+ || fail "manual backlog backend was mutated by teardown anyway"
622
+ backlog_path=$(cd "$case_dir/data" && pwd -P)/backlog.md
623
+ printf '%s\n' "$out" | grep -F "Update $backlog_path - move task-x1 to Done" >/dev/null \
624
+ || fail "teardown did not prompt manual backlog update under opt-out: $out"
625
+ pass "teardown honors config/backlog-backend=manual and still finishes cleanly"
626
+ }
627
+
628
+ test_local_only_truly_unpushed_refuses() {
629
+ local case_dir rc
630
+ case_dir=$(make_case truly-unpushed)
631
+ write_meta "$case_dir" local-only ship
632
+ wt_commit "$case_dir" "unpushed work"
633
+ # No fork, no push to origin, not merged into main.
634
+
635
+ set +e
636
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
637
+ rc=$?
638
+ set -e
639
+
640
+ expect_code 1 "$rc" "truly-unpushed: teardown should refuse"
641
+ grep -q REFUSED "$case_dir/stderr" || fail "truly-unpushed: no REFUSED line in stderr"
642
+ pass "local-only worktree with truly unpushed work is refused (safety preserved)"
643
+ }
644
+
645
+ test_local_only_merged_to_local_main_allows() {
646
+ local case_dir rc
647
+ case_dir=$(make_case merged-main)
648
+ write_meta "$case_dir" local-only ship
649
+ wt_commit "$case_dir" "merged work"
650
+ # Fast-forward the project's main to the worktree's HEAD commit so HEAD is
651
+ # reachable from main. update-ref works whether or not main is checked out,
652
+ # and the worktree shares the project's object db so the commit is visible.
653
+ local wt_head
654
+ wt_head=$(git -C "$case_dir/wt" rev-parse HEAD)
655
+ git -C "$case_dir/project" update-ref refs/heads/main "$wt_head"
656
+
657
+ set +e
658
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
659
+ rc=$?
660
+ set -e
661
+
662
+ expect_code 0 "$rc" "merged-main: teardown should succeed when work is merged into local main"
663
+ ! grep -q REFUSED "$case_dir/stderr" || fail "merged-main: teardown printed a REFUSED line"
664
+ pass "local-only worktree with work merged into local main is torn down (no regression)"
665
+ }
666
+
667
+ test_no_mistakes_origin_remote_allows() {
668
+ local case_dir rc
669
+ case_dir=$(make_case nm-origin)
670
+ write_meta "$case_dir" no-mistakes ship
671
+ wt_commit "$case_dir" "shippable work"
672
+ # Push the task branch to origin and fetch so the worktree sees it.
673
+ git -C "$case_dir/wt" push -q origin fm/task-x1
674
+ git -C "$case_dir/project" fetch -q origin
675
+
676
+ set +e
677
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
678
+ rc=$?
679
+ set -e
680
+
681
+ expect_code 0 "$rc" "nm-origin: teardown should succeed when HEAD is on origin"
682
+ ! grep -q REFUSED "$case_dir/stderr" || fail "nm-origin: teardown printed a REFUSED line"
683
+ grep -F 'blockers are gone and date is due' "$case_dir/stdout" >/dev/null \
684
+ || fail "nm-origin: teardown manual prompt did not preserve date-gate check"
685
+ pass "no-mistakes worktree with HEAD on origin is torn down (no regression)"
686
+ }
687
+
688
+ test_no_mistakes_truly_unpushed_refuses() {
689
+ local case_dir rc
690
+ case_dir=$(make_case nm-unpushed)
691
+ write_meta "$case_dir" no-mistakes ship
692
+ # Real content that is not pushed, has no PR (default gh-axi mock), and never
693
+ # landed on origin/main: genuinely unlanded work that must still refuse.
694
+ wt_commit_file "$case_dir" feature.txt hello "unpushed work"
695
+
696
+ set +e
697
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
698
+ rc=$?
699
+ set -e
700
+
701
+ expect_code 1 "$rc" "nm-unpushed: teardown should refuse"
702
+ grep -q REFUSED "$case_dir/stderr" || fail "nm-unpushed: no REFUSED line in stderr"
703
+ pass "no-mistakes worktree with genuinely unlanded work is refused (safety preserved)"
704
+ }
705
+
706
+ test_squash_merged_branch_deleted_allows() {
707
+ local case_dir rc pr_head
708
+ case_dir=$(make_case squash-merged)
709
+ write_meta "$case_dir" no-mistakes ship
710
+ # Real branch content that is NOT pushed and NOT on origin/main: a squash merge
711
+ # rewrote it into a different commit on main and auto-deleted the head branch, so
712
+ # HEAD is unreachable from every remote-tracking branch. The matching merged PR is
713
+ # the only signal that the work landed.
714
+ wt_commit_file "$case_dir" feature.txt hello "add feature"
715
+ append_pr_meta_for_current_head "$case_dir"
716
+ pr_head=$(git -C "$case_dir/wt" rev-parse HEAD)
717
+ add_gh_pr_merged_for_head "$case_dir" "$pr_head"
718
+
719
+ set +e
720
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
721
+ rc=$?
722
+ set -e
723
+
724
+ expect_code 0 "$rc" "squash-merged: teardown should succeed when the PR is merged"
725
+ ! grep -q REFUSED "$case_dir/stderr" || fail "squash-merged: teardown printed a REFUSED line"
726
+ pass "squash-merged + deleted-branch worktree (PR merged) is torn down (the fix)"
727
+ }
728
+
729
+ test_squash_merged_pr_allows_when_head_ancestor_of_pr_head() {
730
+ local case_dir rc local_head pr_head
731
+ case_dir=$(make_case squash-ancestor)
732
+ write_meta "$case_dir" no-mistakes ship
733
+ wt_commit_file "$case_dir" feature.txt hello "add feature"
734
+ append_pr_meta_url "$case_dir"
735
+ local_head=$(git -C "$case_dir/wt" rev-parse HEAD)
736
+ pr_head=$(commit_tree_from_wt_head "$case_dir" "$local_head" "no-mistakes follow-up")
737
+ add_gh_pr_merged_for_head "$case_dir" "$pr_head"
738
+
739
+ set +e
740
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
741
+ rc=$?
742
+ set -e
743
+
744
+ expect_code 0 "$rc" "squash-ancestor: teardown should succeed when local HEAD is in the merged PR head"
745
+ ! grep -q REFUSED "$case_dir/stderr" || fail "squash-ancestor: teardown printed a REFUSED line"
746
+ pass "squash-merged PR accepts a local HEAD that is an ancestor of the final PR head"
747
+ }
748
+
749
+ test_no_pr_recorded_discovers_merged_pr_by_branch_allows() {
750
+ local case_dir rc local_head pr_head
751
+ case_dir=$(make_case no-pr-branch-discovery)
752
+ write_meta "$case_dir" no-mistakes ship
753
+ # Reproduces the real false-refusal report exactly, with NO pr=/pr_head=
754
+ # recorded in meta at all (fm-pr-check.sh was never run, e.g. a yolo merge on
755
+ # a repo with no PR CI so the "checks green" trigger that fires it never
756
+ # happened): a branch with a commit, a no-mistakes auto-fix commit pushed on
757
+ # top that never made it back into the local worktree, a squash merge onto
758
+ # main under a brand-new SHA, and the head branch deleted (simulated here by
759
+ # never pushing fm/task-x1 at all, so no refs/remotes/origin/fm/task-x1
760
+ # exists to make HEAD "reachable").
761
+ wt_commit_file "$case_dir" feature.txt hello "add feature"
762
+ local_head=$(git -C "$case_dir/wt" rev-parse HEAD)
763
+ pr_head=$(commit_tree_from_wt_head "$case_dir" "$local_head" "no-mistakes auto-fix")
764
+ land_on_origin_main "$case_dir" feature.txt hello
765
+ add_gh_pr_merged_for_head "$case_dir" "$pr_head"
766
+ seed_backlog_in_flight "$case_dir"
767
+ # No append_pr_meta_* call: state/task-x1.meta has no pr= or pr_head= line.
768
+
769
+ ! grep -qE '^(pr|pr_head)=' "$case_dir/state/task-x1.meta" \
770
+ || fail "no-pr-branch-discovery: test setup bug, meta unexpectedly has a pr= line"
771
+
772
+ set +e
773
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
774
+ rc=$?
775
+ set -e
776
+
777
+ expect_code 0 "$rc" "no-pr-branch-discovery: teardown should succeed by discovering the merged PR from the branch name"
778
+ ! grep -q REFUSED "$case_dir/stderr" || fail "no-pr-branch-discovery: teardown printed a REFUSED line"
779
+ assert_grep 'https://github.com/example/repo/pull/7' "$case_dir/data/backlog.md" \
780
+ "no-pr-branch-discovery: resolved PR URL was not recorded on completion"
781
+ pass "teardown discovers a merged PR by branch name and tears down when no pr= was ever recorded"
782
+ }
783
+
784
+ test_squash_merged_pr_allows_replayed_unpushed_patch() {
785
+ local case_dir rc parent_head pr_head
786
+ case_dir=$(make_case squash-replayed-patch)
787
+ write_meta "$case_dir" no-mistakes ship
788
+ wt_commit_file "$case_dir" local-parent.txt parent "local parent"
789
+ parent_head=$(git -C "$case_dir/wt" rev-parse HEAD)
790
+ git -C "$case_dir/wt" push -q origin "$parent_head:refs/heads/fm/task-x1"
791
+ git -C "$case_dir/project" fetch -q origin fm/task-x1
792
+ wt_commit_file "$case_dir" feature.txt hello "add feature"
793
+ append_pr_meta_url "$case_dir"
794
+ pr_head=$(land_equivalent_patch_on_origin_branch "$case_dir" pr-head feature.txt hello "add feature")
795
+ add_gh_pr_merged_for_head "$case_dir" "$pr_head"
796
+
797
+ set +e
798
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
799
+ rc=$?
800
+ set -e
801
+
802
+ expect_code 0 "$rc" "squash-replayed-patch: teardown should succeed when unpushed local patch is in the merged PR head"
803
+ ! grep -q REFUSED "$case_dir/stderr" || fail "squash-replayed-patch: teardown printed a REFUSED line"
804
+ pass "squash-merged PR accepts replayed unpushed local patches contained in the PR head"
805
+ }
806
+
807
+ test_merged_pr_with_later_local_commit_refuses() {
808
+ local case_dir rc pr_head
809
+ case_dir=$(make_case stale-pr-head)
810
+ write_meta "$case_dir" no-mistakes ship
811
+ wt_commit_file "$case_dir" feature.txt hello "add feature"
812
+ append_pr_meta_for_current_head "$case_dir"
813
+ pr_head=$(git -C "$case_dir/wt" rev-parse HEAD)
814
+ wt_commit_file "$case_dir" later.txt local-only "local follow-up"
815
+ add_gh_pr_merged_for_head "$case_dir" "$pr_head"
816
+
817
+ set +e
818
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
819
+ rc=$?
820
+ set -e
821
+
822
+ expect_code 1 "$rc" "stale-pr-head: teardown should refuse when HEAD moved after PR recording"
823
+ grep -q REFUSED "$case_dir/stderr" || fail "stale-pr-head: no REFUSED line in stderr"
824
+ pass "merged PR does not allow teardown after a later local commit"
825
+ }
826
+
827
+ test_pr_check_does_not_refresh_stale_pr_head() {
828
+ local case_dir rc pr_head new_head count
829
+ case_dir=$(make_case pr-check-stale)
830
+ write_meta "$case_dir" no-mistakes ship
831
+ wt_commit_file "$case_dir" feature.txt hello "add feature"
832
+ pr_head=$(git -C "$case_dir/wt" rev-parse HEAD)
833
+ add_gh_pr_merged_for_head "$case_dir" "$pr_head"
834
+
835
+ FM_ROOT_OVERRIDE="$ROOT" \
836
+ FM_STATE_OVERRIDE="$case_dir/state" \
837
+ PATH="$case_dir/fakebin:$PATH" \
838
+ "$PR_CHECK" task-x1 https://github.com/example/repo/pull/7 >/dev/null
839
+
840
+ wt_commit_file "$case_dir" later.txt local-only "local follow-up"
841
+ new_head=$(git -C "$case_dir/wt" rev-parse HEAD)
842
+
843
+ FM_ROOT_OVERRIDE="$ROOT" \
844
+ FM_STATE_OVERRIDE="$case_dir/state" \
845
+ PATH="$case_dir/fakebin:$PATH" \
846
+ "$PR_CHECK" task-x1 https://github.com/example/repo/pull/7 >/dev/null
847
+
848
+ count=$(grep -c '^pr_head=' "$case_dir/state/task-x1.meta" || true)
849
+ expect_code 1 "$count" "pr-check-stale: stale rerun should not append a second pr_head"
850
+ ! grep -qxF "pr_head=$new_head" "$case_dir/state/task-x1.meta" \
851
+ || fail "pr-check-stale: stale rerun recorded the later local HEAD"
852
+
853
+ set +e
854
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
855
+ rc=$?
856
+ set -e
857
+
858
+ expect_code 1 "$rc" "pr-check-stale: teardown should refuse after a later local commit"
859
+ grep -q REFUSED "$case_dir/stderr" || fail "pr-check-stale: no REFUSED line in stderr"
860
+ pass "fm-pr-check does not refresh PR head after HEAD moves"
861
+ }
862
+
863
+ test_pr_check_records_remote_head_when_local_lags() {
864
+ local case_dir local_head pr_head
865
+ case_dir=$(make_case pr-check-local-lags)
866
+ write_meta "$case_dir" no-mistakes ship
867
+ wt_commit_file "$case_dir" feature.txt hello "add feature"
868
+ local_head=$(git -C "$case_dir/wt" rev-parse HEAD)
869
+ pr_head=$(commit_tree_from_wt_head "$case_dir" "$local_head" "no-mistakes follow-up")
870
+ add_gh_pr_merged_for_head "$case_dir" "$pr_head"
871
+
872
+ FM_ROOT_OVERRIDE="$ROOT" \
873
+ FM_STATE_OVERRIDE="$case_dir/state" \
874
+ PATH="$case_dir/fakebin:$PATH" \
875
+ "$PR_CHECK" task-x1 https://github.com/example/repo/pull/7 >/dev/null
876
+
877
+ grep -qxF "pr_head=$pr_head" "$case_dir/state/task-x1.meta" \
878
+ || fail "pr-check-local-lags: did not record GitHub PR head"
879
+ ! grep -qxF "pr_head=$local_head" "$case_dir/state/task-x1.meta" \
880
+ || fail "pr-check-local-lags: recorded local HEAD instead of remote PR head"
881
+ pass "fm-pr-check records the remote PR head when the local worktree lags"
882
+ }
883
+
884
+ test_content_in_default_fallback_allows() {
885
+ local case_dir rc
886
+ case_dir=$(make_case content-landed)
887
+ write_meta "$case_dir" no-mistakes ship
888
+ # No pr= recorded and the default gh-axi mock reports no PR, so the merged-PR path
889
+ # cannot fire and the content check must carry it. The branch adds feature.txt, and
890
+ # the same net change has independently landed on origin/main via a squash commit.
891
+ wt_commit_file "$case_dir" feature.txt hello "add feature"
892
+ land_on_origin_main "$case_dir" feature.txt hello
893
+
894
+ set +e
895
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
896
+ rc=$?
897
+ set -e
898
+
899
+ expect_code 0 "$rc" "content-landed: teardown should succeed when content is already in the default branch"
900
+ ! grep -q REFUSED "$case_dir/stderr" || fail "content-landed: teardown printed a REFUSED line"
901
+ pass "worktree whose content already landed in the default branch is torn down (content fallback)"
902
+ }
903
+
904
+ test_content_fallback_refreshes_stale_origin_ref() {
905
+ local case_dir rc
906
+ case_dir=$(make_case content-stale-ref)
907
+ write_meta "$case_dir" no-mistakes ship
908
+ wt_commit_file "$case_dir" feature.txt hello "add feature"
909
+ git -C "$case_dir/project" config --unset-all remote.origin.fetch
910
+ git -C "$case_dir/project" config --add remote.origin.fetch '+refs/heads/not-main:refs/remotes/origin/not-main'
911
+ land_on_origin_main "$case_dir" feature.txt hello
912
+
913
+ set +e
914
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
915
+ rc=$?
916
+ set -e
917
+
918
+ expect_code 0 "$rc" "content-stale-ref: teardown should use the freshly fetched default branch"
919
+ ! grep -q REFUSED "$case_dir/stderr" || fail "content-stale-ref: teardown printed a REFUSED line"
920
+ pass "content fallback refreshes origin default before comparing trees"
921
+ }
922
+
923
+ test_dirty_worktree_refuses() {
924
+ local case_dir rc pr_head
925
+ case_dir=$(make_case dirty-wt)
926
+ write_meta "$case_dir" no-mistakes ship
927
+ printf '%s\n' 'pr=https://github.com/example/repo/pull/7' >> "$case_dir/state/task-x1.meta"
928
+ # The committed work has fully landed (merged PR + content in default), but an
929
+ # uncommitted edit remains. Dirtiness must refuse regardless: the reset would
930
+ # discard those changes.
931
+ wt_commit_file "$case_dir" feature.txt hello "add feature"
932
+ land_on_origin_main "$case_dir" feature.txt hello
933
+ pr_head=$(git -C "$case_dir/wt" rev-parse HEAD)
934
+ add_gh_pr_merged_for_head "$case_dir" "$pr_head"
935
+ printf '%s\n' "uncommitted edit" > "$case_dir/wt/feature.txt"
936
+
937
+ set +e
938
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
939
+ rc=$?
940
+ set -e
941
+
942
+ expect_code 1 "$rc" "dirty-wt: teardown should refuse a dirty worktree even when the committed work has landed"
943
+ grep -q REFUSED "$case_dir/stderr" || fail "dirty-wt: no REFUSED line in stderr"
944
+ grep -q "uncommitted changes" "$case_dir/stderr" || fail "dirty-wt: refusal did not cite uncommitted changes"
945
+ pass "dirty worktree is refused even when its committed work has landed (dirty always wins)"
946
+ }
947
+
948
+ test_gh_error_and_content_absent_refuses() {
949
+ local case_dir rc
950
+ case_dir=$(make_case gh-error)
951
+ write_meta "$case_dir" no-mistakes ship
952
+ printf '%s\n' 'pr=https://github.com/example/repo/pull/7' >> "$case_dir/state/task-x1.meta"
953
+ # Real content not pushed, the PR lookup errors, and origin/main never gained the
954
+ # content. The fail-safe must refuse rather than allow on a transient gh failure.
955
+ wt_commit_file "$case_dir" feature.txt hello "add feature"
956
+ add_gh_axi_error "$case_dir"
957
+
958
+ set +e
959
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
960
+ rc=$?
961
+ set -e
962
+
963
+ expect_code 1 "$rc" "gh-error: teardown should refuse when the PR lookup errors and content is not landed"
964
+ grep -q REFUSED "$case_dir/stderr" || fail "gh-error: no REFUSED line in stderr"
965
+ pass "gh lookup error with content not in default refuses (fail-safe)"
966
+ }
967
+
968
+ test_stale_index_lock_cleared_and_teardown_succeeds() {
969
+ local case_dir rc lock
970
+ case_dir=$(make_case stale-index-lock)
971
+ write_meta "$case_dir" no-mistakes ship
972
+ wt_commit "$case_dir" "shippable work"
973
+ git -C "$case_dir/wt" push -q origin fm/task-x1
974
+ git -C "$case_dir/project" fetch -q origin
975
+
976
+ add_lock_aware_treehouse "$case_dir"
977
+ add_lsof_no_holder "$case_dir"
978
+
979
+ lock=$(git_index_lock_path "$case_dir/wt")
980
+ mkdir -p "$(dirname "$lock")"
981
+ : > "$lock"
982
+ touch -t 200001010000 "$lock"
983
+
984
+ set +e
985
+ FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS=0 FM_STALE_WORKTREE_LOCK_AGE_SECS=1 \
986
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
987
+ rc=$?
988
+ set -e
989
+
990
+ expect_code 0 "$rc" "stale-index-lock: teardown should succeed after clearing the provably stale lock"
991
+ assert_grep "removed provably-stale git lock" "$case_dir/stderr" \
992
+ "stale-index-lock: teardown did not report clearing the stale lock"
993
+ assert_absent "$lock" "stale-index-lock: stale lock file should have been removed"
994
+ pass "provably-stale worktree index.lock (old, no live holder) is cleared and teardown succeeds"
995
+ }
996
+
997
+ test_live_index_lock_is_never_removed_and_teardown_refuses() {
998
+ local case_dir rc lock
999
+ case_dir=$(make_case live-index-lock)
1000
+ write_meta "$case_dir" no-mistakes ship
1001
+ wt_commit "$case_dir" "shippable work"
1002
+ git -C "$case_dir/wt" push -q origin fm/task-x1
1003
+ git -C "$case_dir/project" fetch -q origin
1004
+
1005
+ add_lock_aware_treehouse "$case_dir"
1006
+ add_lsof_live_holder "$case_dir"
1007
+
1008
+ lock=$(git_index_lock_path "$case_dir/wt")
1009
+ mkdir -p "$(dirname "$lock")"
1010
+ : > "$lock"
1011
+ # Even an old mtime must not be enough on its own: a live holder always wins.
1012
+ touch -t 200001010000 "$lock"
1013
+
1014
+ set +e
1015
+ FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS=0 FM_STALE_WORKTREE_LOCK_AGE_SECS=1 \
1016
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
1017
+ rc=$?
1018
+ set -e
1019
+
1020
+ expect_code 1 "$rc" "live-index-lock: teardown should refuse when the lock has a live holder"
1021
+ assert_grep "not provably stale" "$case_dir/stderr" \
1022
+ "live-index-lock: teardown did not explain the refusal"
1023
+ assert_not_contains "$(cat "$case_dir/stderr")" "removed provably-stale git lock" \
1024
+ "live-index-lock: teardown removed a lock with a live holder"
1025
+ [ -e "$lock" ] || fail "live-index-lock: live-held lock file was removed"
1026
+ pass "live-held worktree index.lock is never removed and teardown refuses"
1027
+ }
1028
+
1029
+ test_lsof_error_never_clears_index_lock() {
1030
+ local case_dir rc lock
1031
+ case_dir=$(make_case lsof-error-index-lock)
1032
+ write_meta "$case_dir" no-mistakes ship
1033
+ wt_commit "$case_dir" "shippable work"
1034
+ git -C "$case_dir/wt" push -q origin fm/task-x1
1035
+ git -C "$case_dir/project" fetch -q origin
1036
+
1037
+ add_lock_aware_treehouse "$case_dir"
1038
+ add_lsof_error "$case_dir"
1039
+
1040
+ lock=$(git_index_lock_path "$case_dir/wt")
1041
+ mkdir -p "$(dirname "$lock")"
1042
+ : > "$lock"
1043
+ touch -t 200001010000 "$lock"
1044
+
1045
+ set +e
1046
+ FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS=0 FM_STALE_WORKTREE_LOCK_AGE_SECS=1 \
1047
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
1048
+ rc=$?
1049
+ set -e
1050
+
1051
+ expect_code 1 "$rc" "lsof-error-index-lock: teardown should refuse when lsof errors"
1052
+ assert_grep "REFUSED: cannot determine leaked processes" "$case_dir/stderr" \
1053
+ "lsof-error-index-lock: teardown did not report the lsof failure"
1054
+ assert_not_contains "$(cat "$case_dir/stderr")" "removed provably-stale git lock" \
1055
+ "lsof-error-index-lock: teardown removed a lock after lsof failed"
1056
+ [ -e "$lock" ] || fail "lsof-error-index-lock: lock file was removed after lsof failed"
1057
+ pass "lsof errors leave worktree index.lock in place and refuse teardown"
1058
+ }
1059
+
1060
+ test_stale_index_lock_cleanup_rechecks_dirty_worktree() {
1061
+ local case_dir rc lock
1062
+ case_dir=$(make_case stale-lock-dirty-recheck)
1063
+ write_meta "$case_dir" no-mistakes ship
1064
+ wt_commit_file "$case_dir" feature.txt landed "landed work"
1065
+ git -C "$case_dir/wt" push -q origin fm/task-x1
1066
+ git -C "$case_dir/project" fetch -q origin
1067
+ printf '%s\n' dirty > "$case_dir/wt/feature.txt"
1068
+
1069
+ add_lock_aware_treehouse "$case_dir"
1070
+ add_lsof_no_holder "$case_dir"
1071
+ add_git_status_lock_failure "$case_dir"
1072
+
1073
+ lock=$(git_index_lock_path "$case_dir/wt")
1074
+ mkdir -p "$(dirname "$lock")"
1075
+ : > "$lock"
1076
+ touch -t 200001010000 "$lock"
1077
+
1078
+ set +e
1079
+ FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS=0 FM_STALE_WORKTREE_LOCK_AGE_SECS=1 \
1080
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
1081
+ rc=$?
1082
+ set -e
1083
+
1084
+ expect_code 1 "$rc" "stale-lock-dirty-recheck: teardown should refuse dirty work after clearing the stale lock"
1085
+ assert_grep "removed provably-stale git lock" "$case_dir/stderr" \
1086
+ "stale-lock-dirty-recheck: teardown did not report clearing the stale lock"
1087
+ assert_grep "uncommitted changes present" "$case_dir/stderr" \
1088
+ "stale-lock-dirty-recheck: teardown did not re-run the dirty check"
1089
+ assert_absent "$lock" "stale-lock-dirty-recheck: stale lock file should have been removed"
1090
+ [ -f "$case_dir/state/task-x1.meta" ] || fail "stale-lock-dirty-recheck: teardown completed despite dirty work"
1091
+ pass "stale lock cleanup rechecks and refuses dirty worktree before return"
1092
+ }
1093
+
1094
+ test_non_linked_index_lock_path_is_checked_from_worktree() {
1095
+ local case_dir rc lock
1096
+ case_dir=$(make_case non-linked-index-lock)
1097
+ git -C "$case_dir/project" worktree remove --force "$case_dir/wt"
1098
+ git clone -q "$case_dir/origin.git" "$case_dir/wt"
1099
+ git -C "$case_dir/wt" checkout -q -b fm/task-x1
1100
+ write_meta "$case_dir" no-mistakes ship
1101
+ wt_commit "$case_dir" "shippable normal clone work"
1102
+ git -C "$case_dir/wt" push -q origin fm/task-x1
1103
+ git -C "$case_dir/wt" fetch -q origin
1104
+
1105
+ add_lock_aware_treehouse "$case_dir"
1106
+ add_lsof_no_holder "$case_dir"
1107
+
1108
+ lock=$(git_index_lock_path "$case_dir/wt")
1109
+ mkdir -p "$(dirname "$lock")"
1110
+ : > "$lock"
1111
+ touch -t 200001010000 "$lock"
1112
+
1113
+ set +e
1114
+ FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS=0 FM_STALE_WORKTREE_LOCK_AGE_SECS=1 \
1115
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
1116
+ rc=$?
1117
+ set -e
1118
+
1119
+ expect_code 0 "$rc" "non-linked-index-lock: teardown should clear a normal repo index.lock"
1120
+ assert_grep "removed provably-stale git lock" "$case_dir/stderr" \
1121
+ "non-linked-index-lock: teardown did not report clearing the stale lock"
1122
+ assert_absent "$lock" "non-linked-index-lock: stale lock file should have been removed"
1123
+ pass "normal repo index.lock is resolved from the worktree and cleared when stale"
1124
+ }
1125
+
1126
+ test_index_lock_mtime_read_failure_refuses() {
1127
+ local case_dir rc lock
1128
+ case_dir=$(make_case mtime-error-index-lock)
1129
+ write_meta "$case_dir" no-mistakes ship
1130
+ wt_commit "$case_dir" "shippable work"
1131
+ git -C "$case_dir/wt" push -q origin fm/task-x1
1132
+ git -C "$case_dir/project" fetch -q origin
1133
+
1134
+ add_lock_aware_treehouse "$case_dir"
1135
+ add_lsof_no_holder "$case_dir"
1136
+ add_stat_error "$case_dir"
1137
+
1138
+ lock=$(git_index_lock_path "$case_dir/wt")
1139
+ mkdir -p "$(dirname "$lock")"
1140
+ : > "$lock"
1141
+ touch -t 200001010000 "$lock"
1142
+
1143
+ set +e
1144
+ FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS=0 FM_STALE_WORKTREE_LOCK_AGE_SECS=1 \
1145
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
1146
+ rc=$?
1147
+ set -e
1148
+
1149
+ expect_code 1 "$rc" "mtime-error-index-lock: teardown should refuse when lock mtime cannot be read"
1150
+ assert_grep "cannot read mtime for git lock" "$case_dir/stderr" \
1151
+ "mtime-error-index-lock: teardown did not report the mtime read failure"
1152
+ assert_grep "not provably stale" "$case_dir/stderr" \
1153
+ "mtime-error-index-lock: teardown did not explain the refusal"
1154
+ assert_not_contains "$(cat "$case_dir/stderr")" "removed provably-stale git lock" \
1155
+ "mtime-error-index-lock: teardown removed a lock after mtime read failed"
1156
+ [ -e "$lock" ] || fail "mtime-error-index-lock: lock file was removed after mtime read failed"
1157
+ pass "lock mtime read failures leave worktree index.lock in place and refuse teardown"
1158
+ }
1159
+
1160
+ test_transient_index_lock_clears_after_first_attempt_and_retry_succeeds() {
1161
+ local case_dir rc lock attempt_file
1162
+ case_dir=$(make_case transient-index-lock-retry)
1163
+ write_meta "$case_dir" no-mistakes ship
1164
+ wt_commit "$case_dir" "shippable work"
1165
+ git -C "$case_dir/wt" push -q origin fm/task-x1
1166
+ git -C "$case_dir/project" fetch -q origin
1167
+
1168
+ add_transient_lock_treehouse "$case_dir"
1169
+ add_lsof_no_holder "$case_dir"
1170
+
1171
+ lock=$(git_index_lock_path "$case_dir/wt")
1172
+ mkdir -p "$(dirname "$lock")"
1173
+ : > "$lock"
1174
+ # Fresh lock: not old enough for the force-remove path; patience must win.
1175
+ touch "$lock"
1176
+
1177
+ attempt_file="$case_dir/treehouse-attempts"
1178
+ : > "$attempt_file"
1179
+
1180
+ set +e
1181
+ TREEHOUSE_ATTEMPT_FILE="$attempt_file" \
1182
+ FM_TREEHOUSE_RETURN_LOCK_RETRIES=2 \
1183
+ FM_TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS=0 \
1184
+ FM_STALE_WORKTREE_LOCK_AGE_SECS=3600 \
1185
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
1186
+ rc=$?
1187
+ set -e
1188
+
1189
+ expect_code 0 "$rc" "transient-index-lock: teardown should succeed on retry after lock self-clears"
1190
+ assert_grep "succeeded on retry" "$case_dir/stderr" \
1191
+ "transient-index-lock: teardown did not report success on retry"
1192
+ assert_not_contains "$(cat "$case_dir/stderr")" "removed provably-stale git lock" \
1193
+ "transient-index-lock: teardown force-removed a lock that only needed patience"
1194
+ [ "$(cat "$attempt_file")" = 2 ] \
1195
+ || fail "transient-index-lock: expected exactly 2 treehouse return attempts, got $(cat "$attempt_file")"
1196
+ assert_absent "$lock" "transient-index-lock: lock should remain cleared after success"
1197
+ pass "transient index.lock cleared after first failed return is retried successfully without force-remove"
1198
+ }
1199
+
1200
+ test_persistent_index_lock_exhausts_retries_and_refuses_loudly() {
1201
+ local case_dir rc lock
1202
+ case_dir=$(make_case persistent-index-lock)
1203
+ write_meta "$case_dir" no-mistakes ship
1204
+ wt_commit "$case_dir" "shippable work"
1205
+ git -C "$case_dir/wt" push -q origin fm/task-x1
1206
+ git -C "$case_dir/project" fetch -q origin
1207
+
1208
+ add_persistent_lock_treehouse "$case_dir"
1209
+ # Fresh lock with a live holder: never provably stale, never force-removed.
1210
+ add_lsof_live_holder "$case_dir"
1211
+
1212
+ lock=$(git_index_lock_path "$case_dir/wt")
1213
+ mkdir -p "$(dirname "$lock")"
1214
+ : > "$lock"
1215
+ touch "$lock"
1216
+
1217
+ set +e
1218
+ FM_TREEHOUSE_RETURN_LOCK_RETRIES=2 \
1219
+ FM_TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS=0 \
1220
+ FM_STALE_WORKTREE_LOCK_AGE_SECS=3600 \
1221
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
1222
+ rc=$?
1223
+ set -e
1224
+
1225
+ expect_code 1 "$rc" "persistent-index-lock: teardown should refuse when the lock never clears"
1226
+ assert_grep "persisted across" "$case_dir/stderr" \
1227
+ "persistent-index-lock: teardown did not mention the exhausted retry window"
1228
+ assert_grep "not provably stale" "$case_dir/stderr" \
1229
+ "persistent-index-lock: teardown did not explain the refusal"
1230
+ assert_not_contains "$(cat "$case_dir/stderr")" "removed provably-stale git lock" \
1231
+ "persistent-index-lock: teardown removed a non-stale lock"
1232
+ [ -e "$lock" ] || fail "persistent-index-lock: lock file was removed"
1233
+ [ -f "$case_dir/state/task-x1.meta" ] \
1234
+ || fail "persistent-index-lock: teardown completed despite persistent lock"
1235
+ pass "persistent index.lock exhausts retries and refuses without force-removing the lock"
1236
+ }
1237
+
1238
+ test_empty_retry_wait_uses_default_without_aborting() {
1239
+ local case_dir rc lock attempt_file
1240
+ case_dir=$(make_case empty-retry-wait)
1241
+ write_meta "$case_dir" no-mistakes ship
1242
+ wt_commit "$case_dir" "shippable work"
1243
+ git -C "$case_dir/wt" push -q origin fm/task-x1
1244
+ git -C "$case_dir/project" fetch -q origin
1245
+
1246
+ add_transient_lock_treehouse "$case_dir"
1247
+ add_lsof_no_holder "$case_dir"
1248
+
1249
+ lock=$(git_index_lock_path "$case_dir/wt")
1250
+ mkdir -p "$(dirname "$lock")"
1251
+ : > "$lock"
1252
+
1253
+ attempt_file="$case_dir/treehouse-attempts"
1254
+ : > "$attempt_file"
1255
+
1256
+ set +e
1257
+ TREEHOUSE_ATTEMPT_FILE="$attempt_file" \
1258
+ FM_TREEHOUSE_RETURN_LOCK_RETRIES=1 \
1259
+ FM_TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS='' \
1260
+ FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS='' \
1261
+ FM_STALE_WORKTREE_LOCK_AGE_SECS=3600 \
1262
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
1263
+ rc=$?
1264
+ set -e
1265
+
1266
+ expect_code 0 "$rc" "empty-retry-wait: teardown should fall back to the default wait"
1267
+ assert_grep "waiting 1s and retrying" "$case_dir/stderr" \
1268
+ "empty-retry-wait: teardown did not use the default retry wait"
1269
+ [ "$(cat "$attempt_file")" = 2 ] \
1270
+ || fail "empty-retry-wait: expected exactly 2 treehouse return attempts, got $(cat "$attempt_file")"
1271
+ pass "empty retry wait overrides use the default without aborting teardown"
1272
+ }
1273
+
1274
+ test_fractional_legacy_retry_wait_refuses_without_arithmetic_error() {
1275
+ local case_dir rc lock
1276
+ case_dir=$(make_case fractional-legacy-retry-wait)
1277
+ write_meta "$case_dir" no-mistakes ship
1278
+ wt_commit "$case_dir" "shippable work"
1279
+ git -C "$case_dir/wt" push -q origin fm/task-x1
1280
+ git -C "$case_dir/project" fetch -q origin
1281
+
1282
+ add_persistent_lock_treehouse "$case_dir"
1283
+ add_lsof_live_holder "$case_dir"
1284
+
1285
+ lock=$(git_index_lock_path "$case_dir/wt")
1286
+ mkdir -p "$(dirname "$lock")"
1287
+ : > "$lock"
1288
+
1289
+ set +e
1290
+ FM_TREEHOUSE_RETURN_LOCK_RETRIES=1 \
1291
+ FM_TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS='' \
1292
+ FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS=0.1 \
1293
+ FM_STALE_WORKTREE_LOCK_AGE_SECS=3600 \
1294
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
1295
+ rc=$?
1296
+ set -e
1297
+
1298
+ expect_code 1 "$rc" "fractional-legacy-retry-wait: teardown should fail only for the persistent lock"
1299
+ assert_grep "waiting 0.1s each" "$case_dir/stderr" \
1300
+ "fractional-legacy-retry-wait: teardown did not preserve the legacy fractional wait"
1301
+ assert_not_contains "$(cat "$case_dir/stderr")" "syntax error" \
1302
+ "fractional-legacy-retry-wait: teardown hit an arithmetic error"
1303
+ pass "fractional legacy retry wait remains supported without arithmetic"
1304
+ }
1305
+
1306
+ test_local_only_force_overrides_unpushed() {
1307
+ local case_dir rc
1308
+ case_dir=$(make_case force-override)
1309
+ write_meta "$case_dir" local-only ship
1310
+ wt_commit "$case_dir" "unpushed work"
1311
+
1312
+ set +e
1313
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr"
1314
+ rc=$?
1315
+ set -e
1316
+
1317
+ expect_code 0 "$rc" "force-override: --force should bypass the unpushed-work check"
1318
+ ! grep -q REFUSED "$case_dir/stderr" || fail "force-override: REFUSED printed despite --force"
1319
+ pass "local-only worktree with unpushed work is torn down under --force (escape hatch)"
1320
+ }
1321
+
1322
+ test_teardown_missing_busy_sidecar_completes() {
1323
+ local case_dir gen rc
1324
+ case_dir=$(make_case missing-busy-sidecar)
1325
+ write_meta "$case_dir" local-only ship
1326
+ gen=$("$ROOT/bin/fm-busy-event.sh" arm "$case_dir/state" task-x1)
1327
+ printf 'busy_gen=%s\n' "$gen" >> "$case_dir/state/task-x1.meta"
1328
+ rm -f "$case_dir/state/task-x1.busy-gen"
1329
+
1330
+ set +e
1331
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr"
1332
+ rc=$?
1333
+ set -e
1334
+
1335
+ expect_code 0 "$rc" "missing-busy-sidecar: teardown should treat the incarnation as already retired"
1336
+ assert_absent "$case_dir/state/task-x1.busy-state" \
1337
+ "missing-busy-sidecar: teardown left the orphan busy record"
1338
+ assert_absent "$case_dir/state/task-x1.meta" \
1339
+ "missing-busy-sidecar: teardown remained incomplete"
1340
+ pass "teardown completes when an exact busy-state sidecar is already absent"
1341
+ }
1342
+
1343
+ test_herdr_teardown_clears_escalation_marker() {
1344
+ local case_dir marker
1345
+ case_dir=$(make_case herdr-marker-cleanup)
1346
+ write_meta "$case_dir" local-only ship
1347
+ sed -i.bak 's/^window=.*/window=default:wG:pQ/' "$case_dir/state/task-x1.meta"
1348
+ rm -f "$case_dir/state/task-x1.meta.bak"
1349
+ printf '%s\n' \
1350
+ 'backend=herdr' \
1351
+ 'herdr_session=default' \
1352
+ 'herdr_workspace_id=wG' \
1353
+ 'herdr_tab_id=wG:tQ' \
1354
+ 'herdr_pane_id=wG:pQ' >> "$case_dir/state/task-x1.meta"
1355
+ # A reachable session whose exact pane is already structurally gone: the
1356
+ # locked close is a no-op and the record gate sees a confirmed-gone pane.
1357
+ cat > "$case_dir/fakebin/herdr" <<SH
1358
+ #!/usr/bin/env bash
1359
+ case "\${1:-} \${2:-}" in
1360
+ "session list") printf '%s\n' '{"sessions":[{"name":"default","running":true,"socket_path":"$case_dir/herdr.sock"}]}' ;;
1361
+ "status --json") printf '%s\n' '{"server":{"running":true}}' ;;
1362
+ "pane get") printf '%s\n' '{"error":{"code":"pane_not_found"}}'; exit 1 ;;
1363
+ *) exit 0 ;;
1364
+ esac
1365
+ SH
1366
+ chmod +x "$case_dir/fakebin/herdr"
1367
+ marker="$case_dir/state/.herdr-escalated-default_wG_pQ"
1368
+ : > "$marker"
1369
+
1370
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" \
1371
+ || fail "herdr-marker-cleanup: forced teardown failed: $(cat "$case_dir/stderr")"
1372
+ [ ! -e "$marker" ] || fail "herdr-marker-cleanup: teardown left the pane's escalation marker behind"
1373
+ pass "herdr teardown removes pane-owned escalation dedupe state"
1374
+ }
1375
+
1376
+ # Flat (non-projected) Herdr endpoint whose fake pane exists until a locked
1377
+ # close removes it. The socket path is case-local so the derived presentation
1378
+ # lock never collides with another test or a real fleet session.
1379
+ configure_flat_herdr_teardown_case() { # <case-dir>
1380
+ local case_dir=$1
1381
+ sed -i.bak 's/^window=.*/window=default:wG:pQ/' "$case_dir/state/task-x1.meta"
1382
+ rm -f "$case_dir/state/task-x1.meta.bak"
1383
+ printf '%s\n' \
1384
+ 'backend=herdr' \
1385
+ 'herdr_session=default' \
1386
+ 'herdr_workspace_id=wG' \
1387
+ 'herdr_tab_id=wG:tQ' \
1388
+ 'herdr_pane_id=wG:pQ' >> "$case_dir/state/task-x1.meta"
1389
+ cat > "$case_dir/fakebin/herdr" <<SH
1390
+ #!/usr/bin/env bash
1391
+ set -u
1392
+ printf '%s\n' "\$*" >> "\${FM_FAKE_HERDR_LOG:?}"
1393
+ case "\${1:-} \${2:-}" in
1394
+ "workspace list")
1395
+ printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"wH","active_tab_id":"wH:t1","focused":true},{"workspace_id":"wG","active_tab_id":"wG:tQ","focused":false}]}}'
1396
+ ;;
1397
+ "tab list")
1398
+ case "\$*" in
1399
+ *"--workspace wH"*) printf '%s\n' '{"result":{"tabs":[{"tab_id":"wH:t1","focused":true}]}}' ;;
1400
+ *"--workspace wG"*) printf '%s\n' '{"result":{"tabs":[{"tab_id":"wG:tQ","workspace_id":"wG"}]}}' ;;
1401
+ *) printf '%s\n' '{"result":{"tabs":[]}}' ;;
1402
+ esac
1403
+ ;;
1404
+ "pane list")
1405
+ printf '%s\n' '{"result":{"panes":[{"pane_id":"wG:pQ","tab_id":"wG:tQ"}]}}'
1406
+ ;;
1407
+ "status --json")
1408
+ printf '%s\n' '{"server":{"running":true}}'
1409
+ ;;
1410
+ "session list")
1411
+ if [ "\${FM_FAKE_HERDR_SESSION_LIST_GARBAGE:-0}" = 1 ]; then
1412
+ printf '%s\n' 'not-json'
1413
+ else
1414
+ printf '%s\n' '{"sessions":[{"name":"default","running":true,"socket_path":"$case_dir/herdr.sock"}]}'
1415
+ fi
1416
+ ;;
1417
+ "pane close")
1418
+ : > "\${FM_FAKE_HERDR_CLOSED:?}"
1419
+ ;;
1420
+ "pane get")
1421
+ if [ "\${FM_FAKE_HERDR_PANE_GET_GARBAGE:-0}" = 1 ]; then
1422
+ printf '%s\n' 'not-json'
1423
+ exit 0
1424
+ fi
1425
+ if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then
1426
+ printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2
1427
+ exit 1
1428
+ fi
1429
+ printf '%s\n' '{"result":{"pane":{"pane_id":"wG:pQ","tab_id":"wG:tQ","workspace_id":"wG"}}}'
1430
+ ;;
1431
+ "agent get")
1432
+ printf '%s\n' '{"error":{"code":"agent_not_found"}}' >&2
1433
+ exit 1
1434
+ ;;
1435
+ esac
1436
+ SH
1437
+ chmod +x "$case_dir/fakebin/herdr"
1438
+ }
1439
+
1440
+ test_herdr_flat_teardown_refuses_orphaning_records_then_retry_completes() {
1441
+ local case_dir log closed lock ready release holder_pid rc thlog
1442
+ case_dir=$(make_case herdr-orphan-refusal)
1443
+ write_meta "$case_dir" local-only ship
1444
+ configure_flat_herdr_teardown_case "$case_dir"
1445
+ log="$case_dir/herdr.log"; : > "$log"
1446
+ closed="$case_dir/closed"
1447
+ : > "$case_dir/state/task-x1.status"
1448
+ : > "$case_dir/state/task-x1.turn-ended"
1449
+ # Record every treehouse invocation: the contended-lock refusal must fire
1450
+ # BEFORE the isolated copy is returned, so phase 1 may not invoke it at all.
1451
+ thlog="$case_dir/treehouse.log"; : > "$thlog"
1452
+ cat > "$case_dir/fakebin/treehouse" <<SH
1453
+ #!/usr/bin/env bash
1454
+ printf '%s\n' "\$*" >> "$thlog"
1455
+ exit 0
1456
+ SH
1457
+ chmod +x "$case_dir/fakebin/treehouse"
1458
+
1459
+ lock=$(FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" PATH="$case_dir/fakebin:$PATH" \
1460
+ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_presentation_session_lock_path default' "$ROOT") \
1461
+ || fail "herdr-orphan-refusal: could not resolve the fixture presentation lock path"
1462
+ ready="$case_dir/lock-ready"; release="$case_dir/lock-release"
1463
+ ROOT="$ROOT" LOCK="$lock" READY="$ready" RELEASE="$release" bash -c '
1464
+ . "$ROOT/bin/fm-wake-lib.sh"
1465
+ fm_lock_try_acquire "$LOCK" || exit 1
1466
+ : > "$READY"
1467
+ while [ ! -e "$RELEASE" ]; do sleep 0.1; done
1468
+ fm_lock_release "$LOCK"
1469
+ ' &
1470
+ holder_pid=$!
1471
+ local waited=0
1472
+ while [ ! -e "$ready" ] && [ "$waited" -lt 50 ]; do sleep 0.1; waited=$((waited + 1)); done
1473
+ [ -e "$ready" ] || fail "herdr-orphan-refusal: the contending lock holder never started"
1474
+
1475
+ rc=0
1476
+ FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \
1477
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
1478
+ if [ "$rc" -eq 0 ]; then
1479
+ : > "$release"; wait "$holder_pid" 2>/dev/null || true
1480
+ fail "herdr-orphan-refusal: teardown reported success while the exact pane still existed under lock contention"
1481
+ fi
1482
+ [ -e "$case_dir/state/task-x1.meta" ] || { : > "$release"; fail "herdr-orphan-refusal: refusal erased the durable endpoint metadata"; }
1483
+ [ -e "$case_dir/state/task-x1.status" ] || { : > "$release"; fail "herdr-orphan-refusal: refusal erased the task status record"; }
1484
+ [ -e "$case_dir/state/task-x1.turn-ended" ] || { : > "$release"; fail "herdr-orphan-refusal: refusal erased the turn-end record"; }
1485
+ assert_grep "presentation lock is contended" "$case_dir/stderr" \
1486
+ "herdr-orphan-refusal: the pre-return refusal was not explained visibly"
1487
+ if [ -s "$thlog" ]; then
1488
+ : > "$release"; fail "herdr-orphan-refusal: the contended refusal still returned the isolated copy: $(cat "$thlog")"
1489
+ fi
1490
+ [ -d "$case_dir/wt" ] || { : > "$release"; fail "herdr-orphan-refusal: the contended refusal removed the isolated copy"; }
1491
+ if [ "$(git -C "$case_dir/wt" rev-parse --abbrev-ref HEAD 2>/dev/null)" != "fm/task-x1" ]; then
1492
+ : > "$release"; fail "herdr-orphan-refusal: the contended refusal dropped the task branch before refusing"
1493
+ fi
1494
+ if grep -q "teardown task-x1 complete" "$case_dir/stdout"; then
1495
+ : > "$release"; fail "herdr-orphan-refusal: refusal still reported cleanup complete"
1496
+ fi
1497
+ if grep -q "^pane close" "$log"; then
1498
+ : > "$release"; fail "herdr-orphan-refusal: an unlocked pane close was attempted under contention"
1499
+ fi
1500
+
1501
+ : > "$release"
1502
+ wait "$holder_pid" 2>/dev/null || true
1503
+ FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=1 \
1504
+ run_teardown "$case_dir" --force > "$case_dir/stdout2" 2> "$case_dir/stderr2" \
1505
+ || fail "herdr-orphan-refusal: the retry after lock release failed: $(cat "$case_dir/stderr2")"
1506
+ [ -e "$closed" ] || fail "herdr-orphan-refusal: the retry never closed the pane under the lock"
1507
+ [ -s "$thlog" ] || fail "herdr-orphan-refusal: the successful retry never returned the isolated copy"
1508
+ [ ! -e "$case_dir/state/task-x1.meta" ] || fail "herdr-orphan-refusal: the successful retry left the metadata behind"
1509
+ [ ! -e "$case_dir/state/task-x1.status" ] || fail "herdr-orphan-refusal: the successful retry left the status record behind"
1510
+ grep -q "teardown task-x1 complete" "$case_dir/stdout2" \
1511
+ || fail "herdr-orphan-refusal: the successful retry did not report completion"
1512
+ pass "herdr flat teardown refuses before returning the isolated copy under lock contention and the retry completes cleanly"
1513
+ }
1514
+
1515
+ test_herdr_flat_teardown_refuses_records_on_unparseable_presence() {
1516
+ local case_dir log closed rc
1517
+ case_dir=$(make_case herdr-garbage-presence)
1518
+ write_meta "$case_dir" local-only ship
1519
+ configure_flat_herdr_teardown_case "$case_dir"
1520
+ log="$case_dir/herdr.log"; : > "$log"
1521
+ closed="$case_dir/closed"
1522
+ : > "$case_dir/state/task-x1.status"
1523
+ rc=0
1524
+ FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_PANE_GET_GARBAGE=1 \
1525
+ FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=1 \
1526
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
1527
+ [ "$rc" -ne 0 ] \
1528
+ || fail "herdr-garbage-presence: teardown erased records on an unparseable pane presence"
1529
+ [ -e "$case_dir/state/task-x1.meta" ] \
1530
+ || fail "herdr-garbage-presence: ambiguous presence erased the durable endpoint metadata"
1531
+ [ -e "$case_dir/state/task-x1.status" ] \
1532
+ || fail "herdr-garbage-presence: ambiguous presence erased the task status record"
1533
+ assert_grep "ambiguous structured presence" "$case_dir/stderr" \
1534
+ "herdr-garbage-presence: the ambiguity refusal was not explained visibly"
1535
+ pass "herdr flat teardown never erases records when pane presence is unparseable"
1536
+ }
1537
+
1538
+ assert_herdr_teardown_preflight_refuses_before_changes() {
1539
+ local mode=$1 case_dir log closed rc thlog teardown_bin
1540
+ case_dir=$(make_case "herdr-preflight-$mode")
1541
+ write_meta "$case_dir" local-only ship
1542
+ configure_flat_herdr_teardown_case "$case_dir"
1543
+ log="$case_dir/herdr.log"; : > "$log"
1544
+ closed="$case_dir/closed"
1545
+ : > "$case_dir/state/task-x1.status"
1546
+ : > "$case_dir/state/task-x1.turn-ended"
1547
+ thlog="$case_dir/treehouse.log"; : > "$thlog"
1548
+ cat > "$case_dir/fakebin/treehouse" <<SH
1549
+ #!/usr/bin/env bash
1550
+ printf '%s\n' "\$*" >> "$thlog"
1551
+ exit 0
1552
+ SH
1553
+ chmod +x "$case_dir/fakebin/treehouse"
1554
+
1555
+ teardown_bin=$TEARDOWN
1556
+ case "$mode" in
1557
+ missing-adapter|missing-parser|missing-explicit-close-helper)
1558
+ mkdir -p "$case_dir/test-root"
1559
+ cp -R "$ROOT/bin" "$case_dir/test-root/bin"
1560
+ if [ "$mode" = missing-adapter ]; then
1561
+ rm -f "$case_dir/test-root/bin/backends/herdr.sh"
1562
+ elif [ "$mode" = missing-explicit-close-helper ]; then
1563
+ sed -i.bak 's/^fm_backend_herdr_explicit_close_pane_confirmed()/fm_backend_herdr_explicit_close_pane_confirmed_unavailable()/' \
1564
+ "$case_dir/test-root/bin/backends/herdr.sh"
1565
+ rm -f "$case_dir/test-root/bin/backends/herdr.sh.bak"
1566
+ else
1567
+ sed -i.bak 's/^fm_backend_herdr_parse_target()/fm_backend_herdr_parse_target_unavailable()/' \
1568
+ "$case_dir/test-root/bin/backends/herdr.sh"
1569
+ rm -f "$case_dir/test-root/bin/backends/herdr.sh.bak"
1570
+ fi
1571
+ teardown_bin="$case_dir/test-root/bin/fm-teardown.sh"
1572
+ ;;
1573
+ esac
1574
+ rc=0
1575
+ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_DATA_OVERRIDE="$case_dir/data" \
1576
+ FM_CONFIG_OVERRIDE="$case_dir/config" FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \
1577
+ FM_FAKE_HERDR_SESSION_LIST_GARBAGE="$([ "$mode" = unresolvable-lock ] && printf 1 || printf 0)" \
1578
+ PATH="$case_dir/fakebin:$PATH" \
1579
+ "$teardown_bin" task-x1 --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
1580
+ [ "$rc" -ne 0 ] || fail "herdr-preflight-$mode: teardown continued without its required preflight"
1581
+ assert_grep "nothing was changed" "$case_dir/stderr" \
1582
+ "herdr-preflight-$mode: the retryable pre-return refusal was not explained visibly"
1583
+ [ -d "$case_dir/wt" ] || fail "herdr-preflight-$mode: refusal removed the isolated copy"
1584
+ [ "$(git -C "$case_dir/wt" rev-parse --abbrev-ref HEAD 2>/dev/null)" = "fm/task-x1" ] \
1585
+ || fail "herdr-preflight-$mode: refusal dropped the task branch"
1586
+ [ -e "$case_dir/state/task-x1.meta" ] \
1587
+ || fail "herdr-preflight-$mode: refusal erased the durable endpoint metadata"
1588
+ [ -e "$case_dir/state/task-x1.status" ] \
1589
+ || fail "herdr-preflight-$mode: refusal erased the task status record"
1590
+ [ -e "$case_dir/state/task-x1.turn-ended" ] \
1591
+ || fail "herdr-preflight-$mode: refusal erased the turn-end record"
1592
+ [ ! -s "$thlog" ] || fail "herdr-preflight-$mode: refusal returned the isolated copy"
1593
+ [ ! -e "$closed" ] || fail "herdr-preflight-$mode: refusal attempted an unlocked pane close"
1594
+ }
1595
+
1596
+ test_herdr_flat_teardown_preflight_refuses_before_changes() {
1597
+ assert_herdr_teardown_preflight_refuses_before_changes unresolvable-lock
1598
+ assert_herdr_teardown_preflight_refuses_before_changes missing-adapter
1599
+ assert_herdr_teardown_preflight_refuses_before_changes missing-parser
1600
+ assert_herdr_teardown_preflight_refuses_before_changes missing-explicit-close-helper
1601
+ pass "herdr flat teardown preflight refuses before every destructive change"
1602
+ }
1603
+
1604
+ configure_secondmate_with_herdr_child() { # <case-dir>
1605
+ local case_dir=$1 home="$1/secondmate-home"
1606
+ mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects"
1607
+ printf '%s\n' task-x1 > "$home/.fm-secondmate-home"
1608
+ printf '%s\n' "home=$home" >> "$case_dir/state/task-x1.meta"
1609
+ fm_write_meta "$home/state/child-herdr.meta" \
1610
+ "window=childsession:wC:p1" \
1611
+ "endpoint_task_id=child-herdr" \
1612
+ "worktree=$case_dir/wt" \
1613
+ "project=$case_dir/project" \
1614
+ "kind=ship" \
1615
+ "mode=local-only" \
1616
+ "backend=herdr" \
1617
+ "herdr_session=childsession" \
1618
+ "herdr_workspace_id=wC" \
1619
+ "herdr_tab_id=wC:t1" \
1620
+ "herdr_pane_id=wC:p1"
1621
+ : > "$home/state/child-herdr.status"
1622
+ : > "$home/state/child-herdr.turn-ended"
1623
+ cat > "$case_dir/fakebin/herdr" <<SH
1624
+ #!/usr/bin/env bash
1625
+ set -u
1626
+ printf '%s\n' "\$*" >> "\${FM_FAKE_HERDR_LOG:?}"
1627
+ case "\${1:-} \${2:-}" in
1628
+ "session list")
1629
+ if [ "\${FM_FAKE_HERDR_SESSION_LIST_GARBAGE:-0}" = 1 ]; then
1630
+ printf '%s\n' 'not-json'
1631
+ else
1632
+ printf '%s\n' '{"sessions":[{"name":"childsession","running":true,"socket_path":"$case_dir/child.sock"}]}'
1633
+ fi
1634
+ ;;
1635
+ "workspace list") exit 1 ;;
1636
+ "pane get")
1637
+ if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then
1638
+ if [ "\${FM_FAKE_HERDR_PRESENCE_UNKNOWN:-0}" = 1 ]; then
1639
+ printf '%s\n' 'not-json'
1640
+ else
1641
+ printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2
1642
+ exit 1
1643
+ fi
1644
+ else
1645
+ printf '%s\n' '{"result":{"pane":{"pane_id":"wC:p1","tab_id":"wC:t1","workspace_id":"wC"}}}'
1646
+ fi
1647
+ ;;
1648
+ "pane close") : > "\${FM_FAKE_HERDR_CLOSED:?}" ;;
1649
+ esac
1650
+ SH
1651
+ chmod +x "$case_dir/fakebin/herdr"
1652
+ }
1653
+
1654
+ test_forced_secondmate_herdr_child_preflight_refuses_before_changes() {
1655
+ local case_dir home log closed rc thlog
1656
+ case_dir=$(make_case herdr-child-preflight)
1657
+ write_meta "$case_dir" local-only secondmate
1658
+ configure_secondmate_with_herdr_child "$case_dir"
1659
+ home="$case_dir/secondmate-home"
1660
+ log="$case_dir/herdr.log"; closed="$case_dir/closed"; thlog="$case_dir/treehouse.log"
1661
+ : > "$log"; : > "$thlog"
1662
+ cat > "$case_dir/fakebin/treehouse" <<SH
1663
+ #!/usr/bin/env bash
1664
+ printf '%s\n' "\$*" >> "$thlog"
1665
+ exit 0
1666
+ SH
1667
+ chmod +x "$case_dir/fakebin/treehouse"
1668
+ rc=0
1669
+ FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \
1670
+ FM_FAKE_HERDR_SESSION_LIST_GARBAGE=1 \
1671
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
1672
+ [ "$rc" -ne 0 ] || fail "herdr-child-preflight: teardown continued through an unresolvable child lock"
1673
+ [ -e "$case_dir/state/task-x1.meta" ] || fail "herdr-child-preflight: refusal erased the parent record"
1674
+ [ -e "$home/state/child-herdr.meta" ] || fail "herdr-child-preflight: refusal erased the child record"
1675
+ [ -e "$home/state/child-herdr.status" ] || fail "herdr-child-preflight: refusal erased child status"
1676
+ [ -d "$home" ] || fail "herdr-child-preflight: refusal removed the secondmate home"
1677
+ [ ! -s "$thlog" ] || fail "herdr-child-preflight: refusal returned work before child preflight"
1678
+ [ ! -e "$closed" ] || fail "herdr-child-preflight: refusal attempted a child close"
1679
+ assert_grep "nothing was changed" "$case_dir/stderr" \
1680
+ "herdr-child-preflight: refusal did not explain its non-mutating boundary"
1681
+ pass "forced secondmate teardown preflights every Herdr child before cleanup mutation"
1682
+ }
1683
+
1684
+ configure_secondmate_with_tmux_children() { # <case-dir>
1685
+ local case_dir=$1 home="$1/secondmate-home" child child_wt
1686
+ mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects"
1687
+ printf '%s\n' task-x1 > "$home/.fm-secondmate-home"
1688
+ printf '%s\n' "home=$home" >> "$case_dir/state/task-x1.meta"
1689
+ for child in child-a child-b; do
1690
+ child_wt="$case_dir/$child-wt"
1691
+ git -C "$case_dir/project" worktree add -q -b "fm/$child" "$child_wt" main
1692
+ fm_write_meta "$home/state/$child.meta" \
1693
+ "window=firstmate:fm-$child" \
1694
+ "endpoint_task_id=$child" \
1695
+ "worktree=$child_wt" \
1696
+ "project=$case_dir/project" \
1697
+ "kind=ship" \
1698
+ "mode=local-only"
1699
+ : > "$home/state/$child.status"
1700
+ done
1701
+ }
1702
+
1703
+ test_forced_secondmate_teardown_holds_descendant_lifecycle_locks() {
1704
+ local case_dir home lock ready release holder_pid rc waited=0 child
1705
+ case_dir=$(make_case descendant-locks)
1706
+ write_meta "$case_dir" local-only secondmate
1707
+ configure_secondmate_with_tmux_children "$case_dir"
1708
+ home="$case_dir/secondmate-home"
1709
+ : > "$case_dir/kill.log"
1710
+ : > "$case_dir/treehouse.log"
1711
+ cat > "$case_dir/fakebin/tmux" <<SH
1712
+ #!/usr/bin/env bash
1713
+ printf '%s\n' "\$*" >> "$case_dir/kill.log"
1714
+ exit 0
1715
+ SH
1716
+ cat > "$case_dir/fakebin/treehouse" <<SH
1717
+ #!/usr/bin/env bash
1718
+ printf '%s\n' "\$*" >> "$case_dir/treehouse.log"
1719
+ exit 0
1720
+ SH
1721
+ chmod +x "$case_dir/fakebin/tmux" "$case_dir/fakebin/treehouse"
1722
+
1723
+ lock="$home/state/.control-child-b.lock"
1724
+ ready="$case_dir/lock-ready"
1725
+ release="$case_dir/lock-release"
1726
+ ROOT="$ROOT" LOCK="$lock" READY="$ready" RELEASE="$release" \
1727
+ HOME_STATE="$home/state" OWNER_PID="$$" bash -c '
1728
+ export FM_STATE_OVERRIDE="$HOME_STATE"
1729
+ . "$ROOT/bin/fm-wake-lib.sh"
1730
+ fm_lock_try_acquire "$LOCK" || exit 1
1731
+ : > "$READY"
1732
+ while [ ! -e "$RELEASE" ] && kill -0 "$OWNER_PID" 2>/dev/null; do sleep 0.1; done
1733
+ fm_lock_release "$LOCK"
1734
+ ' &
1735
+ holder_pid=$!
1736
+ while [ ! -e "$ready" ] && [ "$waited" -lt 50 ]; do
1737
+ sleep 0.1
1738
+ waited=$((waited + 1))
1739
+ done
1740
+ [ -e "$ready" ] || fail "descendant-locks: the contending lifecycle action never acquired its lock"
1741
+
1742
+ rc=0
1743
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
1744
+ if [ "$rc" -eq 0 ]; then
1745
+ : > "$release"
1746
+ wait "$holder_pid" 2>/dev/null || true
1747
+ fail "descendant-locks: forced teardown ignored a descendant lifecycle lock"
1748
+ fi
1749
+ assert_grep "descendant task child-b has a lifecycle action in flight" "$case_dir/stderr" \
1750
+ "descendant-locks: refusal did not name the contended descendant"
1751
+ [ ! -e "$home/state/.control-child-a.lock" ] \
1752
+ && [ ! -e "$home/state/.meta-child-a.lock" ] \
1753
+ || { : > "$release"; wait "$holder_pid" 2>/dev/null || true; fail "descendant-locks: refusal leaked earlier descendant locks"; }
1754
+ [ ! -s "$case_dir/kill.log" ] \
1755
+ || { : > "$release"; wait "$holder_pid" 2>/dev/null || true; fail "descendant-locks: refusal killed an endpoint"; }
1756
+ [ ! -s "$case_dir/treehouse.log" ] \
1757
+ || { : > "$release"; wait "$holder_pid" 2>/dev/null || true; fail "descendant-locks: refusal returned a worktree"; }
1758
+ [ -e "$case_dir/state/task-x1.meta" ] && [ -d "$home" ] \
1759
+ || { : > "$release"; wait "$holder_pid" 2>/dev/null || true; fail "descendant-locks: refusal removed parent state"; }
1760
+ for child in child-a child-b; do
1761
+ [ -e "$home/state/$child.meta" ] && [ -d "$case_dir/$child-wt" ] \
1762
+ || { : > "$release"; wait "$holder_pid" 2>/dev/null || true; fail "descendant-locks: refusal removed $child state or worktree"; }
1763
+ done
1764
+
1765
+ : > "$release"
1766
+ wait "$holder_pid" 2>/dev/null || true
1767
+ rc=0
1768
+ run_teardown "$case_dir" --force > "$case_dir/retry.stdout" 2> "$case_dir/retry.stderr" || rc=$?
1769
+ expect_code 0 "$rc" "descendant-locks: uncontended retry should complete"
1770
+ [ ! -e "$case_dir/state/task-x1.meta" ] && [ ! -d "$home" ] \
1771
+ || fail "descendant-locks: uncontended retry retained retired task state"
1772
+ [ -s "$case_dir/kill.log" ] && [ -s "$case_dir/treehouse.log" ] \
1773
+ || fail "descendant-locks: uncontended retry did not perform endpoint and worktree cleanup"
1774
+ pass "forced secondmate teardown holds every descendant lifecycle and metadata lock"
1775
+ }
1776
+
1777
+ test_forced_secondmate_herdr_child_retains_records_when_close_unconfirmed() {
1778
+ local case_dir home log closed rc
1779
+ case_dir=$(make_case herdr-child-unconfirmed-close)
1780
+ write_meta "$case_dir" local-only secondmate
1781
+ configure_secondmate_with_herdr_child "$case_dir"
1782
+ home="$case_dir/secondmate-home"
1783
+ log="$case_dir/herdr.log"; closed="$case_dir/closed"; : > "$log"
1784
+ rc=0
1785
+ FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_PRESENCE_UNKNOWN=1 \
1786
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
1787
+ [ "$rc" -ne 0 ] || fail "herdr-child-unconfirmed-close: teardown erased records after an ambiguous close"
1788
+ [ -e "$closed" ] || fail "herdr-child-unconfirmed-close: fixture did not attempt the child close"
1789
+ [ -e "$home/state/child-herdr.meta" ] || fail "herdr-child-unconfirmed-close: ambiguous close erased child metadata"
1790
+ [ -e "$home/state/child-herdr.status" ] || fail "herdr-child-unconfirmed-close: ambiguous close erased child status"
1791
+ [ -e "$case_dir/state/task-x1.meta" ] || fail "herdr-child-unconfirmed-close: failed child cleanup erased parent metadata"
1792
+ [ -d "$home" ] || fail "herdr-child-unconfirmed-close: failed child cleanup removed the secondmate home"
1793
+ assert_grep "retaining that child's durable identity records" "$case_dir/stderr" \
1794
+ "herdr-child-unconfirmed-close: refusal did not explain child record retention"
1795
+ pass "forced secondmate teardown retains Herdr child identity until exact pane disappearance"
1796
+ }
1797
+
1798
+ configure_nested_secondmate_with_herdr_grandchild() { # <case-dir>
1799
+ local case_dir=$1 home="$1/secondmate-home" nested_home="$1/secondmate-home/nested-home"
1800
+ mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects"
1801
+ mkdir -p "$nested_home/state" "$nested_home/data" "$nested_home/config" "$nested_home/projects"
1802
+ printf '%s\n' task-x1 > "$home/.fm-secondmate-home"
1803
+ printf '%s\n' nested-sm > "$nested_home/.fm-secondmate-home"
1804
+ printf '%s\n' "home=$home" >> "$case_dir/state/task-x1.meta"
1805
+ fm_write_meta "$home/state/nested-sm.meta" \
1806
+ "window=firstmate:fm-nested-sm" \
1807
+ "endpoint_task_id=nested-sm" \
1808
+ "worktree=$case_dir/wt" \
1809
+ "project=$case_dir/project" \
1810
+ "kind=secondmate" \
1811
+ "mode=local-only" \
1812
+ "home=$nested_home"
1813
+ fm_write_meta "$nested_home/state/grandchild-herdr.meta" \
1814
+ "window=grandchildsession:wG:p1" \
1815
+ "endpoint_task_id=grandchild-herdr" \
1816
+ "worktree=$case_dir/wt" \
1817
+ "project=$case_dir/project" \
1818
+ "kind=ship" \
1819
+ "mode=local-only" \
1820
+ "backend=herdr" \
1821
+ "herdr_session=grandchildsession" \
1822
+ "herdr_workspace_id=wG" \
1823
+ "herdr_tab_id=wG:t1" \
1824
+ "herdr_pane_id=wG:p1"
1825
+ : > "$nested_home/state/grandchild-herdr.status"
1826
+ : > "$nested_home/state/grandchild-herdr.turn-ended"
1827
+ cat > "$case_dir/fakebin/herdr" <<SH
1828
+ #!/usr/bin/env bash
1829
+ set -u
1830
+ printf '%s\n' "\$*" >> "\${FM_FAKE_HERDR_LOG:?}"
1831
+ case "\${1:-} \${2:-}" in
1832
+ "session list")
1833
+ printf '%s\n' '{"sessions":[{"name":"grandchildsession","running":true,"socket_path":"$case_dir/grandchild.sock"}]}'
1834
+ ;;
1835
+ "workspace list") exit 1 ;;
1836
+ "pane get")
1837
+ if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then
1838
+ printf '%s\n' 'not-json'
1839
+ else
1840
+ printf '%s\n' '{"result":{"pane":{"pane_id":"wG:p1","tab_id":"wG:t1","workspace_id":"wG"}}}'
1841
+ fi
1842
+ ;;
1843
+ "pane close") : > "\${FM_FAKE_HERDR_CLOSED:?}" ;;
1844
+ esac
1845
+ SH
1846
+ chmod +x "$case_dir/fakebin/herdr"
1847
+ }
1848
+
1849
+ test_forced_teardown_retains_nested_secondmate_home_when_grandchild_close_unconfirmed() {
1850
+ local case_dir home nested_home log closed rc
1851
+ case_dir=$(make_case herdr-grandchild-unconfirmed-close)
1852
+ write_meta "$case_dir" local-only secondmate
1853
+ configure_nested_secondmate_with_herdr_grandchild "$case_dir"
1854
+ home="$case_dir/secondmate-home"; nested_home="$home/nested-home"
1855
+ log="$case_dir/herdr.log"; closed="$case_dir/closed"; : > "$log"
1856
+ rc=0
1857
+ FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \
1858
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
1859
+ [ "$rc" -ne 0 ] \
1860
+ || fail "herdr-grandchild-unconfirmed-close: teardown erased records after an ambiguous grandchild close"
1861
+ [ -e "$closed" ] \
1862
+ || fail "herdr-grandchild-unconfirmed-close: fixture did not attempt the grandchild close"
1863
+ [ -d "$nested_home" ] \
1864
+ || fail "herdr-grandchild-unconfirmed-close: the recursive failure still removed the nested secondmate home"
1865
+ [ -e "$nested_home/state/grandchild-herdr.meta" ] \
1866
+ || fail "herdr-grandchild-unconfirmed-close: ambiguous close erased the grandchild's metadata"
1867
+ [ -e "$nested_home/state/grandchild-herdr.status" ] \
1868
+ || fail "herdr-grandchild-unconfirmed-close: ambiguous close erased the grandchild's status record"
1869
+ [ -e "$home/state/nested-sm.meta" ] \
1870
+ || fail "herdr-grandchild-unconfirmed-close: the recursive failure erased the nested secondmate's own record"
1871
+ [ -e "$case_dir/state/task-x1.meta" ] \
1872
+ || fail "herdr-grandchild-unconfirmed-close: the recursive failure erased the top-level secondmate's record"
1873
+ pass "forced teardown retains a nested secondmate home and its grandchild's Herdr identity when the grandchild close is unconfirmed"
1874
+ }
1875
+
1876
+ configure_herdr_projection_teardown_case() { # <case-dir>
1877
+ local case_dir=$1 token=AbCdEfGhIjKlMnOpQrStUv
1878
+ sed -i.bak 's/^window=.*/window=fmtest:w1:p2/' "$case_dir/state/task-x1.meta"
1879
+ rm -f "$case_dir/state/task-x1.meta.bak"
1880
+ printf '%s\n' \
1881
+ 'backend=herdr' \
1882
+ 'herdr_session=fmtest' \
1883
+ 'herdr_workspace_id=w1' \
1884
+ 'herdr_tab_id=w1:t2' \
1885
+ 'herdr_pane_id=w1:p2' >> "$case_dir/state/task-x1.meta"
1886
+ printf '%s\n' \
1887
+ 'version=1' \
1888
+ 'task_id=task-x1' \
1889
+ "projection_id=$token" > "$case_dir/state/task-x1.herdr-presentation"
1890
+ cat > "$case_dir/fakebin/herdr" <<'SH'
1891
+ #!/usr/bin/env bash
1892
+ set -u
1893
+ printf '%s\n' "$*" >> "${FM_FAKE_HERDR_LOG:?}"
1894
+ case "${1:-} ${2:-}" in
1895
+ "workspace list")
1896
+ if [ -e "${FM_FAKE_HERDR_RESTORED:?}" ]; then
1897
+ printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w2","active_tab_id":"w2:t2","label":"2ndmate-bravo","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","label":"2ndmate-alpha","focused":false}]}}'
1898
+ elif [ -e "${FM_FAKE_HERDR_CLOSED:?}" ]; then
1899
+ printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w2","active_tab_id":"w2:t2","label":"2ndmate-bravo","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","label":"2ndmate-alpha","focused":true}]}}'
1900
+ else
1901
+ printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t2","label":"firstmate/task-x1 · p:AbCdEfGhIjKlMnOpQrStUv","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t2","label":"2ndmate-bravo","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","label":"2ndmate-alpha","focused":false}]}}'
1902
+ fi
1903
+ ;;
1904
+ "tab list")
1905
+ case "$*" in
1906
+ *"--workspace w2"*) printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","focused":true}]}}' ;;
1907
+ *"--workspace w3"*) printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","focused":true}]}}' ;;
1908
+ *) printf '%s\n' '{"result":{"tabs":[]}}' ;;
1909
+ esac
1910
+ ;;
1911
+ "status --json")
1912
+ printf '%s\n' '{"server":{"running":true}}'
1913
+ ;;
1914
+ "session list")
1915
+ printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fmtest.sock"}]}'
1916
+ ;;
1917
+ "pane close")
1918
+ if [ "${FM_FAKE_HERDR_CLOSE_FAIL:-0}" = 1 ]; then
1919
+ exit 1
1920
+ fi
1921
+ : > "${FM_FAKE_HERDR_CLOSED:?}"
1922
+ ;;
1923
+ "pane get")
1924
+ if [ -e "${FM_FAKE_HERDR_CLOSED:?}" ]; then
1925
+ if [ "${FM_FAKE_HERDR_PRESENCE_UNKNOWN:-0}" = 1 ]; then
1926
+ printf '%s\n' '{"error":{"code":"internal"}}' >&2
1927
+ exit 1
1928
+ fi
1929
+ printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2
1930
+ exit 1
1931
+ fi
1932
+ printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p2","tab_id":"w1:t2","workspace_id":"w1"}}}'
1933
+ ;;
1934
+ "tab get")
1935
+ printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2"}}}'
1936
+ ;;
1937
+ "tab focus")
1938
+ if [ "${FM_FAKE_HERDR_RESTORE_FAIL:-0}" = 1 ]; then
1939
+ exit 1
1940
+ fi
1941
+ : > "${FM_FAKE_HERDR_RESTORED:?}"
1942
+ printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2","focused":true}}}'
1943
+ ;;
1944
+ "agent get")
1945
+ printf '%s\n' '{"error":{"code":"agent_not_found"}}' >&2
1946
+ exit 1
1947
+ ;;
1948
+ esac
1949
+ SH
1950
+ chmod +x "$case_dir/fakebin/herdr"
1951
+ }
1952
+
1953
+ test_herdr_projection_teardown_retires_journal_only_after_confirmed_close() {
1954
+ local case_dir log closed restored
1955
+ case_dir=$(make_case herdr-projection-confirmed-close)
1956
+ write_meta "$case_dir" local-only ship
1957
+ configure_herdr_projection_teardown_case "$case_dir"
1958
+ log="$case_dir/herdr.log"; closed="$case_dir/closed"; restored="$case_dir/restored"; : > "$log"
1959
+
1960
+ FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_RESTORED="$restored" \
1961
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" \
1962
+ || fail "herdr-projection-confirmed-close: forced teardown failed"
1963
+ [ ! -e "$case_dir/state/task-x1.herdr-presentation" ] \
1964
+ || fail "confirmed exact-pane close did not retire the presentation journal"
1965
+ assert_not_contains "$(cat "$log")" "workspace close" \
1966
+ "projected teardown must never call workspace close"
1967
+ assert_contains "$(cat "$log")" "tab focus w2:t2" \
1968
+ "projected teardown did not restore the exact pre-close active tab"
1969
+ pass "herdr projection teardown retires its journal only after confirming the exact recorded pane is gone"
1970
+ }
1971
+
1972
+ test_herdr_projection_teardown_retains_journal_when_close_unconfirmed() {
1973
+ local case_dir log closed restored
1974
+ case_dir=$(make_case herdr-projection-unconfirmed-close)
1975
+ write_meta "$case_dir" local-only ship
1976
+ configure_herdr_projection_teardown_case "$case_dir"
1977
+ log="$case_dir/herdr.log"; closed="$case_dir/closed"; restored="$case_dir/restored"; : > "$log"
1978
+
1979
+ local rc=0
1980
+ FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_RESTORED="$restored" FM_FAKE_HERDR_PRESENCE_UNKNOWN=1 \
1981
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
1982
+ [ "$rc" -ne 0 ] \
1983
+ || fail "herdr-projection-unconfirmed-close: teardown reported success after an unknown post-close presence read"
1984
+ [ -e "$closed" ] \
1985
+ || fail "herdr-projection-unconfirmed-close: regression did not exercise an attempted close"
1986
+ [ -e "$case_dir/state/task-x1.herdr-presentation" ] \
1987
+ || fail "unconfirmed task-pane close incorrectly retired the presentation journal"
1988
+ [ -e "$case_dir/state/task-x1.meta" ] \
1989
+ || fail "unconfirmed task-pane close erased the durable endpoint metadata"
1990
+ assert_grep "close could not be confirmed" "$case_dir/stderr" \
1991
+ "unconfirmed projected close did not explain why the journal was retained"
1992
+ assert_grep "not confirmed gone" "$case_dir/stderr" \
1993
+ "unconfirmed projected close did not explain why the records were retained"
1994
+ assert_not_contains "$(cat "$log")" "workspace close" \
1995
+ "unconfirmed projected close must not escalate to workspace cleanup"
1996
+ pass "herdr projection teardown retains every record when post-close presence is unknown"
1997
+ }
1998
+
1999
+ test_herdr_projection_teardown_surfaces_restore_failure_without_blocking_cleanup() {
2000
+ local case_dir log closed restored
2001
+ case_dir=$(make_case herdr-projection-restore-failure)
2002
+ write_meta "$case_dir" local-only ship
2003
+ configure_herdr_projection_teardown_case "$case_dir"
2004
+ log="$case_dir/herdr.log"; closed="$case_dir/closed"; restored="$case_dir/restored"; : > "$log"
2005
+
2006
+ FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_RESTORED="$restored" \
2007
+ FM_FAKE_HERDR_RESTORE_FAIL=1 \
2008
+ run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" \
2009
+ || fail "herdr-projection-restore-failure: a confirmed close with a failed focus restore blocked teardown"
2010
+ [ -e "$closed" ] \
2011
+ || fail "herdr-projection-restore-failure: regression did not exercise the exact projected-pane close"
2012
+ [ ! -e "$case_dir/state/task-x1.herdr-presentation" ] \
2013
+ || fail "herdr-projection-restore-failure: confirmed closure did not retire the presentation journal"
2014
+ assert_grep "exact-tab restoration failed" "$case_dir/stderr" \
2015
+ "herdr-projection-restore-failure: teardown swallowed the focus helper's restore warning"
2016
+ pass "herdr projection teardown surfaces failed focus restoration without turning confirmed cleanup into a hard failure"
2017
+ }
2018
+
2019
+ # --- Fix 1: conclude/abort the task's own parked no-mistakes run before the
2020
+ # worker is removed, and Fix 2: reap leaked descendant processes rooted under
2021
+ # the task's own worktree/tasktmp - both exercised through the real teardown
2022
+ # path (bin/fm-teardown.sh), never by matching its source text. ------------
2023
+
2024
+ # A parked-at-a-gate `axi status` TOON payload for <branch>/<head>, matching
2025
+ # the shape no-mistakes actually emits (see tests/fm-crew-state.test.sh's
2026
+ # run_parked fixture, the same shape bin/fm-crew-state.sh's own tests pin).
2027
+ parked_axi_status_toon() { # <branch> <head> [run-id]
2028
+ cat <<EOF
2029
+ run:
2030
+ id: "${3:-01RUN}"
2031
+ branch: $1
2032
+ status: awaiting_approval
2033
+ awaiting_agent: parked 2m10s
2034
+ head: "$2"
2035
+ pr: ""
2036
+ findings: none
2037
+ gate: review
2038
+ EOF
2039
+ }
2040
+
2041
+ running_axi_status_toon() { # <branch> <head> [run-id]
2042
+ cat <<EOF
2043
+ run:
2044
+ id: "${3:-01RUN}"
2045
+ branch: $1
2046
+ status: running
2047
+ head: "$2"
2048
+ pr: ""
2049
+ steps[1]{step,status,findings,summary}:
2050
+ test,running,0,"agent under way"
2051
+ EOF
2052
+ }
2053
+
2054
+ # Land a shippable commit on the task branch and push it to origin, the same
2055
+ # "definitely landed, teardown must ALLOW" shape test_no_mistakes_origin_remote_allows
2056
+ # uses, so these new cases exercise the abort/reap steps on a real successful
2057
+ # teardown rather than a refusal path.
2058
+ land_shippable_commit() {
2059
+ local case_dir=$1
2060
+ wt_commit "$case_dir" "shippable work"
2061
+ git -C "$case_dir/wt" push -q origin fm/task-x1
2062
+ git -C "$case_dir/project" fetch -q origin
2063
+ }
2064
+
2065
+ test_parked_own_run_is_aborted_before_teardown() {
2066
+ local case_dir rc head
2067
+ case_dir=$(make_case parked-run-abort)
2068
+ write_meta "$case_dir" no-mistakes ship
2069
+ land_shippable_commit "$case_dir"
2070
+ head=$(git -C "$case_dir/wt" rev-parse HEAD)
2071
+
2072
+ local rc=0
2073
+ FM_FAKE_AXI_STATUS="$(parked_axi_status_toon fm/task-x1 "$head")" \
2074
+ FM_FAKE_NM_ABORT_LOG="$case_dir/nm-abort.log" \
2075
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2076
+
2077
+ expect_code 0 "$rc" "parked-run-abort: teardown should still succeed"
2078
+ assert_present "$case_dir/nm-abort.log" \
2079
+ "parked-run-abort: no-mistakes axi abort was never invoked for the task's own parked run"
2080
+ assert_grep "abort --run 01RUN" "$case_dir/nm-abort.log" \
2081
+ "parked-run-abort: no-mistakes axi abort did not target the verified run id"
2082
+ assert_grep "parked at a gate; aborting" "$case_dir/stderr" \
2083
+ "parked-run-abort: teardown did not report aborting the parked run before removing the worker"
2084
+ pass "a task's own parked no-mistakes run is aborted, not orphaned, before the worker is removed"
2085
+ }
2086
+
2087
+ test_mismatched_run_after_abort_refuses_unconfirmed() {
2088
+ local case_dir rc head
2089
+ case_dir=$(make_case parked-run-replaced)
2090
+ write_meta "$case_dir" no-mistakes ship
2091
+ land_shippable_commit "$case_dir"
2092
+ head=$(git -C "$case_dir/wt" rev-parse HEAD)
2093
+
2094
+ rc=0
2095
+ FM_FAKE_AXI_STATUS="$(parked_axi_status_toon fm/task-x1 "$head" 01RUN)" \
2096
+ FM_FAKE_AXI_STATUS_AFTER_ABORT="$(parked_axi_status_toon fm/task-x1 "$head" 02RUN)" \
2097
+ FM_FAKE_NM_ABORT_LOG="$case_dir/nm-abort.log" \
2098
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2099
+
2100
+ expect_code 1 "$rc" "parked-run-replaced: a different run does not confirm the targeted abort"
2101
+ assert_grep "abort --run 01RUN" "$case_dir/nm-abort.log" \
2102
+ "parked-run-replaced: teardown did not abort only the verified run"
2103
+ assert_present "$case_dir/wt" "parked-run-replaced: teardown removed the worktree without confirmation"
2104
+ pass "a different run cannot confirm the targeted abort"
2105
+ }
2106
+
2107
+ test_empty_status_after_abort_refuses_unconfirmed() {
2108
+ local case_dir rc head
2109
+ case_dir=$(make_case parked-run-empty-confirmation)
2110
+ write_meta "$case_dir" no-mistakes ship
2111
+ land_shippable_commit "$case_dir"
2112
+ head=$(git -C "$case_dir/wt" rev-parse HEAD)
2113
+
2114
+ rc=0
2115
+ FM_FAKE_AXI_STATUS="$(parked_axi_status_toon fm/task-x1 "$head")" \
2116
+ FM_FAKE_NM_ABORT_LOG="$case_dir/nm-abort.log" \
2117
+ FM_FAKE_NM_EMPTY_AFTER_ABORT=1 \
2118
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2119
+
2120
+ expect_code 1 "$rc" "parked-run-empty-confirmation: empty status should refuse"
2121
+ assert_present "$case_dir/wt" "parked-run-empty-confirmation: teardown removed the worktree"
2122
+ pass "empty post-abort status is not accepted as confirmation"
2123
+ }
2124
+
2125
+ test_not_found_status_after_abort_confirms_completion() {
2126
+ local case_dir rc head
2127
+ case_dir=$(make_case parked-run-not-found-confirmation)
2128
+ write_meta "$case_dir" no-mistakes ship
2129
+ land_shippable_commit "$case_dir"
2130
+ head=$(git -C "$case_dir/wt" rev-parse HEAD)
2131
+
2132
+ rc=0
2133
+ FM_FAKE_AXI_STATUS="$(parked_axi_status_toon fm/task-x1 "$head")" \
2134
+ FM_FAKE_NM_ABORT_LOG="$case_dir/nm-abort.log" \
2135
+ FM_FAKE_NM_NOT_FOUND_AFTER_ABORT=1 \
2136
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2137
+
2138
+ expect_code 0 "$rc" "parked-run-not-found-confirmation: explicit not-found should confirm completion"
2139
+ pass "the CLI's exact run-not-found signal confirms completion"
2140
+ }
2141
+
2142
+ test_parked_own_run_refuses_when_abort_is_unconfirmed() {
2143
+ local case_dir rc head pid
2144
+ case_dir=$(make_case parked-run-abort-unconfirmed)
2145
+ write_meta "$case_dir" no-mistakes ship
2146
+ land_shippable_commit "$case_dir"
2147
+ head=$(git -C "$case_dir/wt" rev-parse HEAD)
2148
+ ( cd "$case_dir/wt" && exec sleep 300 ) &
2149
+ pid=$!
2150
+ disown
2151
+
2152
+ cat > "$case_dir/fakebin/treehouse" <<EOF
2153
+ #!/usr/bin/env bash
2154
+ printf 'return\n' >> "$case_dir/treehouse.log"
2155
+ EOF
2156
+ chmod +x "$case_dir/fakebin/treehouse"
2157
+
2158
+ rc=0
2159
+ FM_FAKE_AXI_STATUS="$(parked_axi_status_toon fm/task-x1 "$head")" \
2160
+ FM_FAKE_NM_ABORT_LOG="$case_dir/nm-abort.log" \
2161
+ FM_FAKE_NM_ABORT_NOOP=1 \
2162
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2163
+
2164
+ expect_code 1 "$rc" "parked-run-abort-unconfirmed: teardown should refuse"
2165
+ assert_grep "REFUSED: no-mistakes run for task-x1 is still parked after axi abort" "$case_dir/stderr" \
2166
+ "parked-run-abort-unconfirmed: teardown did not explain the parked-run refusal"
2167
+ assert_present "$case_dir/wt" \
2168
+ "parked-run-abort-unconfirmed: teardown removed the worktree after refusing"
2169
+ assert_present "$case_dir/state/task-x1.meta" \
2170
+ "parked-run-abort-unconfirmed: teardown removed task metadata after refusing"
2171
+ assert_absent "$case_dir/treehouse.log" \
2172
+ "parked-run-abort-unconfirmed: teardown returned the worktree after refusing"
2173
+ kill -0 "$pid" 2>/dev/null || fail "parked-run-abort-unconfirmed: process reap ran before refusal"
2174
+ kill -KILL "$pid" 2>/dev/null || true
2175
+ pass "teardown refuses before reap or removal when a task-owned run remains parked"
2176
+ }
2177
+
2178
+ test_another_branchs_parked_run_is_never_touched() {
2179
+ local case_dir rc
2180
+ case_dir=$(make_case parked-run-not-ours)
2181
+ write_meta "$case_dir" no-mistakes ship
2182
+ land_shippable_commit "$case_dir"
2183
+
2184
+ local rc=0
2185
+ # A parked run reported for a DIFFERENT branch - e.g. another crew's task
2186
+ # still validating on the shared gate - must never be aborted by this task's
2187
+ # teardown.
2188
+ FM_FAKE_AXI_STATUS="$(parked_axi_status_toon fm/some-other-task deadbeef)" \
2189
+ FM_FAKE_NM_ABORT_LOG="$case_dir/nm-abort.log" \
2190
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2191
+
2192
+ expect_code 0 "$rc" "parked-run-not-ours: teardown should still succeed"
2193
+ assert_absent "$case_dir/nm-abort.log" \
2194
+ "parked-run-not-ours: teardown called axi abort for a run on another branch"
2195
+ assert_not_contains "$(cat "$case_dir/stderr")" "aborting" \
2196
+ "parked-run-not-ours: teardown reported aborting a run it does not own"
2197
+ pass "a parked run on another branch is never aborted by this task's teardown (ownership is precise)"
2198
+ }
2199
+
2200
+ test_own_autonomous_run_is_left_alone() {
2201
+ local case_dir rc head
2202
+ case_dir=$(make_case autonomous-run-left-alone)
2203
+ write_meta "$case_dir" no-mistakes ship
2204
+ land_shippable_commit "$case_dir"
2205
+ head=$(git -C "$case_dir/wt" rev-parse HEAD)
2206
+
2207
+ rc=0
2208
+ FM_FAKE_AXI_STATUS="$(running_axi_status_toon fm/task-x1 "$head")" \
2209
+ FM_FAKE_NM_ABORT_LOG="$case_dir/nm-abort.log" \
2210
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2211
+
2212
+ expect_code 0 "$rc" "autonomous-run-left-alone: teardown should still succeed"
2213
+ assert_absent "$case_dir/nm-abort.log" \
2214
+ "autonomous-run-left-alone: teardown aborted a task-owned autonomous run"
2215
+ assert_not_contains "$(cat "$case_dir/stderr")" "aborting" \
2216
+ "autonomous-run-left-alone: teardown reported aborting an autonomous run"
2217
+ pass "a task-owned autonomous running step is left alone rather than aborted"
2218
+ }
2219
+
2220
+ test_leaked_worktree_process_is_reaped() {
2221
+ local case_dir rc pid
2222
+ case_dir=$(make_case leaked-process-reap)
2223
+ write_meta "$case_dir" no-mistakes ship
2224
+ land_shippable_commit "$case_dir"
2225
+
2226
+ # A backgrounded, disowned process rooted (by cwd) under the task's own
2227
+ # worktree - the same shape the observed incident's leaked `go test`
2228
+ # binaries took (reparented to init, no live task meta to attribute them
2229
+ # to once an unpatched teardown had already run).
2230
+ ( cd "$case_dir/wt" && exec sleep 300 ) &
2231
+ pid=$!
2232
+ disown
2233
+ sleep 0.3
2234
+ kill -0 "$pid" 2>/dev/null || fail "leaked-process-reap: setup sleeper did not start"
2235
+
2236
+ rc=0
2237
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2238
+
2239
+ expect_code 0 "$rc" "leaked-process-reap: teardown should still succeed"
2240
+ if kill -0 "$pid" 2>/dev/null; then
2241
+ kill -KILL "$pid" 2>/dev/null || true
2242
+ fail "leaked-process-reap: leaked worktree process survived teardown"
2243
+ fi
2244
+ assert_grep "reaping leaked worktree process" "$case_dir/stderr" \
2245
+ "leaked-process-reap: teardown did not report reaping the leaked process"
2246
+ pass "a leaked descendant process rooted under the task's worktree is reaped by teardown, not left surviving"
2247
+ }
2248
+
2249
+ test_leaked_tasktmp_process_is_reaped() {
2250
+ local case_dir rc pid
2251
+ case_dir=$(make_case leaked-tasktmp-reap)
2252
+ write_meta "$case_dir" no-mistakes ship
2253
+ printf '%s\n' "tasktmp=$case_dir/tasktmp" >> "$case_dir/state/task-x1.meta"
2254
+ mkdir -p "$case_dir/tasktmp"
2255
+ land_shippable_commit "$case_dir"
2256
+
2257
+ ( cd "$case_dir/tasktmp" && exec sleep 300 ) &
2258
+ pid=$!
2259
+ disown
2260
+ sleep 0.3
2261
+ kill -0 "$pid" 2>/dev/null || fail "leaked-tasktmp-reap: setup sleeper did not start"
2262
+
2263
+ rc=0
2264
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2265
+
2266
+ expect_code 0 "$rc" "leaked-tasktmp-reap: teardown should still succeed"
2267
+ if kill -0 "$pid" 2>/dev/null; then
2268
+ kill -KILL "$pid" 2>/dev/null || true
2269
+ fail "leaked-tasktmp-reap: leaked tasktmp process survived teardown"
2270
+ fi
2271
+ assert_grep "reaping leaked worktree process" "$case_dir/stderr" \
2272
+ "leaked-tasktmp-reap: teardown did not report reaping the leaked tasktmp process"
2273
+ pass "a leaked descendant process rooted under the task's per-task tasktmp is reaped by teardown too"
2274
+ }
2275
+
2276
+ test_lsof_absent_reaps_tmux_process_group() {
2277
+ local case_dir rc pid path_without_lsof
2278
+ case_dir=$(make_case lsof-absent-process-group-reap)
2279
+ write_meta "$case_dir" no-mistakes ship
2280
+ land_shippable_commit "$case_dir"
2281
+ path_without_lsof=$(make_path_without_lsof "$case_dir")
2282
+ PATH="$path_without_lsof" command -v lsof >/dev/null 2>&1 \
2283
+ && fail "lsof-absent-process-group-reap: fixture path unexpectedly exposes lsof"
2284
+
2285
+ perl -e 'setpgrp(0, 0); chdir shift or die; exec "sleep", "300"' "$case_dir/wt" &
2286
+ pid=$!
2287
+ disown
2288
+ sleep 0.3
2289
+ kill -0 "$pid" 2>/dev/null || fail "lsof-absent-process-group-reap: setup sleeper did not start"
2290
+ cat > "$case_dir/fakebin/tmux" <<EOF
2291
+ #!/usr/bin/env bash
2292
+ if [ "\${1:-}" = display-message ] && [ "\${*: -1}" = '#{pane_pid}' ]; then
2293
+ printf '%s\n' '$pid'
2294
+ fi
2295
+ exit 0
2296
+ EOF
2297
+ chmod +x "$case_dir/fakebin/tmux"
2298
+
2299
+ rc=0
2300
+ FM_TEARDOWN_TEST_PATH="$path_without_lsof" \
2301
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2302
+
2303
+ expect_code 0 "$rc" "lsof-absent-process-group-reap: teardown should succeed"
2304
+ if kill -0 "$pid" 2>/dev/null; then
2305
+ kill -KILL "$pid" 2>/dev/null || true
2306
+ fail "lsof-absent-process-group-reap: tmux process group survived teardown"
2307
+ fi
2308
+ assert_grep "reaping leaked worktree process group" "$case_dir/stderr" \
2309
+ "lsof-absent-process-group-reap: teardown did not use the process-group fallback"
2310
+ pass "missing lsof falls back to reaping the tmux pane process group"
2311
+ }
2312
+
2313
+ test_lsof_error_refuses_before_removal() {
2314
+ local case_dir rc
2315
+ case_dir=$(make_case lsof-error-refusal)
2316
+ write_meta "$case_dir" no-mistakes ship
2317
+ land_shippable_commit "$case_dir"
2318
+ cat > "$case_dir/fakebin/lsof" <<'SH'
2319
+ #!/usr/bin/env bash
2320
+ exit 1
2321
+ SH
2322
+ cat > "$case_dir/fakebin/treehouse" <<EOF
2323
+ #!/usr/bin/env bash
2324
+ printf 'return\n' >> "$case_dir/treehouse.log"
2325
+ EOF
2326
+ chmod +x "$case_dir/fakebin/lsof" "$case_dir/fakebin/treehouse"
2327
+
2328
+ rc=0
2329
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2330
+
2331
+ expect_code 1 "$rc" "lsof-error-refusal: teardown should refuse"
2332
+ assert_grep "REFUSED: cannot determine leaked processes under $case_dir/wt for task-x1 (lsof failed)" "$case_dir/stderr" \
2333
+ "lsof-error-refusal: teardown did not explain the lsof refusal"
2334
+ assert_present "$case_dir/wt" "lsof-error-refusal: teardown removed the worktree"
2335
+ assert_present "$case_dir/state/task-x1.meta" "lsof-error-refusal: teardown removed task metadata"
2336
+ assert_absent "$case_dir/treehouse.log" "lsof-error-refusal: teardown returned the worktree"
2337
+ pass "an erroring lsof scan refuses teardown and preserves the task"
2338
+ }
2339
+
2340
+ test_reused_pid_identity_is_not_force_killed() {
2341
+ local case_dir rc pid
2342
+ case_dir=$(make_case reused-pid-identity)
2343
+ write_meta "$case_dir" no-mistakes ship
2344
+ land_shippable_commit "$case_dir"
2345
+
2346
+ perl -e '$SIG{TERM} = "IGNORE"; sleep 300' &
2347
+ pid=$!
2348
+ disown
2349
+ sleep 0.2
2350
+ cat > "$case_dir/fakebin/lsof" <<EOF
2351
+ #!/usr/bin/env bash
2352
+ count=0
2353
+ [ ! -f '$case_dir/lsof-count' ] || count=\$(cat '$case_dir/lsof-count')
2354
+ count=\$((count + 1))
2355
+ printf '%s\n' "\$count" > '$case_dir/lsof-count'
2356
+ if [ "\$count" -le 3 ]; then printf 'p%s\nfcwd\nn%s\n' '$pid' '$case_dir/wt'; fi
2357
+ EOF
2358
+ cat > "$case_dir/fakebin/ps" <<'SH'
2359
+ #!/usr/bin/env bash
2360
+ if [ "${1:-}" = -p ] && [ "${2:-}" = "${FM_FAKE_REUSED_PID:-}" ] \
2361
+ && [ "${3:-}" = -o ] && [ "${4:-}" = lstart= ]; then
2362
+ count=0
2363
+ [ ! -f "$FM_FAKE_PS_COUNT" ] || count=$(cat "$FM_FAKE_PS_COUNT")
2364
+ count=$((count + 1))
2365
+ printf '%s\n' "$count" > "$FM_FAKE_PS_COUNT"
2366
+ if [ "$count" -le 2 ]; then printf 'Tue Aug 4 10:00:00 2026\n'
2367
+ else printf 'Tue Aug 4 10:00:01 2026\n'; fi
2368
+ exit 0
2369
+ fi
2370
+ exec "$REAL_PS_FOR_TEST" "$@"
2371
+ SH
2372
+ chmod +x "$case_dir/fakebin/lsof" "$case_dir/fakebin/ps"
2373
+
2374
+ rc=0
2375
+ FM_PROC_ROOT_OVERRIDE="$case_dir/no-proc" \
2376
+ FM_FAKE_REUSED_PID="$pid" FM_FAKE_PS_COUNT="$case_dir/ps-count" \
2377
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2378
+
2379
+ expect_code 0 "$rc" "reused-pid-identity: teardown should skip the replacement process"
2380
+ if ! kill -0 "$pid" 2>/dev/null; then
2381
+ fail "reused-pid-identity: teardown force-killed a process whose start time changed"
2382
+ fi
2383
+ kill -KILL "$pid" 2>/dev/null || true
2384
+ pass "a reused pid with a different start time is never force-killed"
2385
+ }
2386
+
2387
+ test_exec_changed_process_is_still_reaped() {
2388
+ local case_dir rc pid marker done_flag survived=0
2389
+ case_dir=$(make_case exec-changed-process)
2390
+ write_meta "$case_dir" no-mistakes ship
2391
+ land_shippable_commit "$case_dir"
2392
+ marker="$case_dir/exec-now"
2393
+ done_flag="$case_dir/exec-done"
2394
+
2395
+ ( cd "$case_dir/wt" && exec perl -e '
2396
+ my ($marker, $done) = @ARGV;
2397
+ until (-e $marker) { select undef, undef, undef, 0.01; }
2398
+ open my $fh, ">", $done or die "open";
2399
+ close $fh;
2400
+ exec "perl", "-e", '\''$SIG{TERM} = "IGNORE"; sleep 300'\'';
2401
+ ' "$marker" "$done_flag" ) &
2402
+ pid=$!
2403
+ disown
2404
+ sleep 0.2
2405
+ cat > "$case_dir/fakebin/ps" <<'SH'
2406
+ #!/usr/bin/env bash
2407
+ if [ "${1:-}" = -p ] && [ "${2:-}" = "${FM_FAKE_EXEC_PID:-}" ] \
2408
+ && [ "${3:-}" = -o ] && [ "${4:-}" = lstart= ]; then
2409
+ out=$("$REAL_PS_FOR_TEST" "$@") || exit $?
2410
+ [ -e "$FM_FAKE_EXEC_MARKER" ] || : > "$FM_FAKE_EXEC_MARKER"
2411
+ printf '%s\n' "$out"
2412
+ exit 0
2413
+ fi
2414
+ exec "$REAL_PS_FOR_TEST" "$@"
2415
+ SH
2416
+ cat > "$case_dir/fakebin/lsof" <<'SH'
2417
+ #!/usr/bin/env bash
2418
+ count=0
2419
+ [ ! -f "$FM_FAKE_LSOF_COUNT" ] || count=$(cat "$FM_FAKE_LSOF_COUNT")
2420
+ count=$((count + 1))
2421
+ printf '%s\n' "$count" > "$FM_FAKE_LSOF_COUNT"
2422
+ if [ "$count" -eq 2 ]; then
2423
+ i=0
2424
+ while [ "$i" -lt 100 ]; do
2425
+ [ ! -e "$FM_FAKE_EXEC_DONE" ] || break
2426
+ sleep 0.01
2427
+ i=$((i + 1))
2428
+ done
2429
+ fi
2430
+ exec "$REAL_LSOF_FOR_TEST" "$@"
2431
+ SH
2432
+ chmod +x "$case_dir/fakebin/ps" "$case_dir/fakebin/lsof"
2433
+
2434
+ rc=0
2435
+ FM_PROC_ROOT_OVERRIDE="$case_dir/no-proc" \
2436
+ FM_FAKE_EXEC_PID="$pid" FM_FAKE_EXEC_MARKER="$marker" \
2437
+ FM_FAKE_EXEC_DONE="$done_flag" FM_FAKE_LSOF_COUNT="$case_dir/lsof-count" \
2438
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2439
+
2440
+ if kill -0 "$pid" 2>/dev/null; then
2441
+ survived=1
2442
+ kill -KILL "$pid" 2>/dev/null || true
2443
+ fi
2444
+ expect_code 0 "$rc" "exec-changed-process: teardown should succeed"
2445
+ [ "$survived" -eq 0 ] || fail "exec-changed-process: exec-changed leaked process survived teardown"
2446
+ pass "an exec change preserves birth identity and the process is reaped"
2447
+ }
2448
+
2449
+ test_process_spawned_during_grace_is_reaped_on_later_pass() {
2450
+ local case_dir rc pid child_file child_pid="" parent_survived=0 child_survived=0
2451
+ case_dir=$(make_case grace-spawn-convergence)
2452
+ write_meta "$case_dir" no-mistakes ship
2453
+ land_shippable_commit "$case_dir"
2454
+ child_file="$case_dir/child.pid"
2455
+
2456
+ ( cd "$case_dir/wt" && exec perl -e '
2457
+ my $file = shift;
2458
+ $SIG{TERM} = sub {
2459
+ my $child = fork();
2460
+ die "fork" unless defined $child;
2461
+ if (!$child) { exec "sleep", "300"; }
2462
+ open my $fh, ">", $file or die "open";
2463
+ print {$fh} "$child\n";
2464
+ close $fh;
2465
+ exit 0;
2466
+ };
2467
+ sleep 300;
2468
+ ' "$child_file" ) &
2469
+ pid=$!
2470
+ disown
2471
+ sleep 0.2
2472
+
2473
+ rc=0
2474
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2475
+
2476
+ if [ -f "$child_file" ]; then child_pid=$(cat "$child_file"); fi
2477
+ if [ -n "$child_pid" ] && kill -0 "$child_pid" 2>/dev/null; then
2478
+ child_survived=1
2479
+ kill -KILL "$child_pid" 2>/dev/null || true
2480
+ fi
2481
+ if kill -0 "$pid" 2>/dev/null; then
2482
+ parent_survived=1
2483
+ kill -KILL "$pid" 2>/dev/null || true
2484
+ fi
2485
+ expect_code 0 "$rc" "grace-spawn-convergence: teardown should converge"
2486
+ assert_present "$child_file" "grace-spawn-convergence: TERM handler did not spawn a child"
2487
+ [ "$child_survived" -eq 0 ] || fail "grace-spawn-convergence: spawned child survived"
2488
+ [ "$parent_survived" -eq 0 ] || fail "grace-spawn-convergence: original process survived"
2489
+ pass "a process spawned during grace is reaped on a later pass"
2490
+ }
2491
+
2492
+ test_persistent_scan_refuses_after_bounded_retries() {
2493
+ local case_dir rc wt_path fake_pid=99999999
2494
+ case_dir=$(make_case persistent-reap-refusal)
2495
+ write_meta "$case_dir" no-mistakes ship
2496
+ land_shippable_commit "$case_dir"
2497
+ wt_path=$(cd "$case_dir/wt" && pwd -P)
2498
+ cat > "$case_dir/fakebin/lsof" <<EOF
2499
+ #!/usr/bin/env bash
2500
+ printf 'p%s\nfcwd\nn%s\n' '$fake_pid' '$wt_path'
2501
+ EOF
2502
+ cat > "$case_dir/fakebin/ps" <<'SH'
2503
+ #!/usr/bin/env bash
2504
+ if [ "${1:-}" = -p ] && [ "${2:-}" = "${FM_FAKE_PERSISTENT_PID:-}" ] \
2505
+ && [ "${3:-}" = -o ] && [ "${4:-}" = lstart= ]; then
2506
+ printf 'Tue Aug 4 10:00:00 2026\n'
2507
+ exit 0
2508
+ fi
2509
+ exec "$REAL_PS_FOR_TEST" "$@"
2510
+ SH
2511
+ chmod +x "$case_dir/fakebin/lsof" "$case_dir/fakebin/ps"
2512
+
2513
+ rc=0
2514
+ FM_PROC_ROOT_OVERRIDE="$case_dir/no-proc" FM_FAKE_PERSISTENT_PID="$fake_pid" \
2515
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2516
+
2517
+ expect_code 1 "$rc" "persistent-reap-refusal: teardown should refuse"
2518
+ assert_grep "remain after 3 reap attempts" "$case_dir/stderr" \
2519
+ "persistent-reap-refusal: teardown did not report bounded non-convergence"
2520
+ assert_present "$case_dir/wt" "persistent-reap-refusal: teardown removed the worktree"
2521
+ assert_present "$case_dir/state/task-x1.meta" "persistent-reap-refusal: teardown removed task metadata"
2522
+ pass "persistent leaked processes refuse teardown after bounded retries"
2523
+ }
2524
+
2525
+ test_process_exit_during_identity_lookup_does_not_refuse() {
2526
+ local case_dir rc wt_path fake_pid=99999998
2527
+ case_dir=$(make_case identity-exit-convergence)
2528
+ write_meta "$case_dir" no-mistakes ship
2529
+ land_shippable_commit "$case_dir"
2530
+ wt_path=$(cd "$case_dir/wt" && pwd -P)
2531
+ cat > "$case_dir/fakebin/lsof" <<EOF
2532
+ #!/usr/bin/env bash
2533
+ count=0
2534
+ [ ! -f "$case_dir/lsof-count" ] || count=\$(cat "$case_dir/lsof-count")
2535
+ count=\$((count + 1))
2536
+ printf '%s\n' "\$count" > "$case_dir/lsof-count"
2537
+ if [ "\$count" -eq 1 ]; then
2538
+ printf 'p%s\nfcwd\nn%s\n' '$fake_pid' '$wt_path'
2539
+ fi
2540
+ EOF
2541
+ cat > "$case_dir/fakebin/ps" <<'SH'
2542
+ #!/usr/bin/env bash
2543
+ if [ "${1:-}" = -p ] && [ "${2:-}" = "${FM_FAKE_EXITED_PID:-}" ]; then
2544
+ exit 1
2545
+ fi
2546
+ exec "$REAL_PS_FOR_TEST" "$@"
2547
+ SH
2548
+ cat > "$case_dir/fakebin/treehouse" <<EOF
2549
+ #!/usr/bin/env bash
2550
+ printf 'returned\n' > "$case_dir/treehouse.log"
2551
+ EOF
2552
+ chmod +x "$case_dir/fakebin/lsof" "$case_dir/fakebin/ps" "$case_dir/fakebin/treehouse"
2553
+
2554
+ rc=0
2555
+ FM_PROC_ROOT_OVERRIDE="$case_dir/no-proc" FM_FAKE_EXITED_PID="$fake_pid" \
2556
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2557
+
2558
+ expect_code 0 "$rc" "identity-exit-convergence: teardown should succeed"
2559
+ assert_present "$case_dir/treehouse.log" \
2560
+ "identity-exit-convergence: teardown did not reach worktree return"
2561
+ ! grep -q REFUSED "$case_dir/stderr" || \
2562
+ fail "identity-exit-convergence: a disappeared process caused teardown refusal"
2563
+ pass "a process exiting during identity lookup does not block teardown"
2564
+ }
2565
+
2566
+ test_run_abort_precedes_process_reap_precedes_worktree_removal() {
2567
+ local case_dir rc head pid abort_log
2568
+ case_dir=$(make_case abort-then-reap-then-remove-order)
2569
+ write_meta "$case_dir" no-mistakes ship
2570
+ land_shippable_commit "$case_dir"
2571
+ head=$(git -C "$case_dir/wt" rev-parse HEAD)
2572
+ abort_log="$case_dir/nm-abort.log"
2573
+
2574
+ ( cd "$case_dir/wt" && exec sleep 300 ) &
2575
+ pid=$!
2576
+ disown
2577
+ sleep 0.3
2578
+ kill -0 "$pid" 2>/dev/null || fail "abort-then-reap-then-remove-order: setup sleeper did not start"
2579
+
2580
+ # A treehouse fake that snapshots, at the exact moment the destructive
2581
+ # worktree return runs, whether the run was already aborted and whether the
2582
+ # leaked process was already reaped - direct causal proof of ordering from
2583
+ # real observed state, not a source-text or line-number correlation.
2584
+ cat > "$case_dir/fakebin/treehouse" <<EOF
2585
+ #!/usr/bin/env bash
2586
+ if [ -s "$abort_log" ]; then echo "abort-already-happened" >> "$case_dir/order.log"; fi
2587
+ if ! kill -0 $pid 2>/dev/null; then echo "reap-already-happened" >> "$case_dir/order.log"; fi
2588
+ exit 0
2589
+ EOF
2590
+ chmod +x "$case_dir/fakebin/treehouse"
2591
+
2592
+ rc=0
2593
+ FM_FAKE_AXI_STATUS="$(parked_axi_status_toon fm/task-x1 "$head")" \
2594
+ FM_FAKE_NM_ABORT_LOG="$abort_log" \
2595
+ run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
2596
+ expect_code 0 "$rc" "abort-then-reap-then-remove-order: teardown should still succeed"
2597
+ kill -0 "$pid" 2>/dev/null && { kill -KILL "$pid" 2>/dev/null || true; }
2598
+
2599
+ assert_present "$case_dir/order.log" \
2600
+ "abort-then-reap-then-remove-order: the destructive worktree return was never invoked"
2601
+ assert_grep "abort-already-happened" "$case_dir/order.log" \
2602
+ "abort-then-reap-then-remove-order: the run was not yet aborted when the worktree return ran"
2603
+ assert_grep "reap-already-happened" "$case_dir/order.log" \
2604
+ "abort-then-reap-then-remove-order: the leaked process was not yet reaped when the worktree return ran"
2605
+ pass "the run abort and the leaked-process reap both complete before the destructive worktree return"
2606
+ }
2607
+
2608
+ test_local_only_fork_remote_allows
2609
+ test_teardown_closes_the_backlog_item_itself
2610
+ test_teardown_manual_backend_leaves_the_backlog_to_the_operator
2611
+ test_local_only_truly_unpushed_refuses
2612
+ test_local_only_merged_to_local_main_allows
2613
+ test_no_mistakes_origin_remote_allows
2614
+ test_no_mistakes_truly_unpushed_refuses
2615
+ test_local_only_force_overrides_unpushed
2616
+ test_teardown_missing_busy_sidecar_completes
2617
+ test_herdr_teardown_clears_escalation_marker
2618
+ test_herdr_flat_teardown_refuses_orphaning_records_then_retry_completes
2619
+ test_herdr_flat_teardown_refuses_records_on_unparseable_presence
2620
+ test_herdr_flat_teardown_preflight_refuses_before_changes
2621
+ test_forced_secondmate_herdr_child_preflight_refuses_before_changes
2622
+ test_forced_secondmate_teardown_holds_descendant_lifecycle_locks
2623
+ test_forced_secondmate_herdr_child_retains_records_when_close_unconfirmed
2624
+ test_forced_teardown_retains_nested_secondmate_home_when_grandchild_close_unconfirmed
2625
+ test_herdr_projection_teardown_retires_journal_only_after_confirmed_close
2626
+ test_herdr_projection_teardown_retains_journal_when_close_unconfirmed
2627
+ test_herdr_projection_teardown_surfaces_restore_failure_without_blocking_cleanup
2628
+ test_squash_merged_branch_deleted_allows
2629
+ test_squash_merged_pr_allows_when_head_ancestor_of_pr_head
2630
+ test_no_pr_recorded_discovers_merged_pr_by_branch_allows
2631
+ test_squash_merged_pr_allows_replayed_unpushed_patch
2632
+ test_merged_pr_with_later_local_commit_refuses
2633
+ test_pr_check_does_not_refresh_stale_pr_head
2634
+ test_pr_check_records_remote_head_when_local_lags
2635
+ test_content_in_default_fallback_allows
2636
+ test_content_fallback_refreshes_stale_origin_ref
2637
+ test_dirty_worktree_refuses
2638
+ test_gh_error_and_content_absent_refuses
2639
+ test_stale_index_lock_cleared_and_teardown_succeeds
2640
+ test_live_index_lock_is_never_removed_and_teardown_refuses
2641
+ test_lsof_error_never_clears_index_lock
2642
+ test_stale_index_lock_cleanup_rechecks_dirty_worktree
2643
+ test_non_linked_index_lock_path_is_checked_from_worktree
2644
+ test_index_lock_mtime_read_failure_refuses
2645
+ test_transient_index_lock_clears_after_first_attempt_and_retry_succeeds
2646
+ test_persistent_index_lock_exhausts_retries_and_refuses_loudly
2647
+ test_empty_retry_wait_uses_default_without_aborting
2648
+ test_fractional_legacy_retry_wait_refuses_without_arithmetic_error
2649
+ test_parked_own_run_is_aborted_before_teardown
2650
+ test_parked_own_run_refuses_when_abort_is_unconfirmed
2651
+ test_mismatched_run_after_abort_refuses_unconfirmed
2652
+ test_empty_status_after_abort_refuses_unconfirmed
2653
+ test_not_found_status_after_abort_confirms_completion
2654
+ test_another_branchs_parked_run_is_never_touched
2655
+ test_own_autonomous_run_is_left_alone
2656
+ test_leaked_worktree_process_is_reaped
2657
+ test_leaked_tasktmp_process_is_reaped
2658
+ test_lsof_absent_reaps_tmux_process_group
2659
+ test_lsof_error_refuses_before_removal
2660
+ test_reused_pid_identity_is_not_force_killed
2661
+ test_exec_changed_process_is_still_reaped
2662
+ test_process_spawned_during_grace_is_reaped_on_later_pass
2663
+ test_persistent_scan_refuses_after_bounded_retries
2664
+ test_process_exit_during_identity_lookup_does_not_refuse
2665
+ test_run_abort_precedes_process_reap_precedes_worktree_removal