@gajae-code/coding-agent 0.18.7 → 0.18.9

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 (344) hide show
  1. package/CHANGELOG.md +377 -1
  2. package/dist/types/async/endpoint-id.d.ts +16 -0
  3. package/dist/types/async/job-manager.d.ts +1 -1
  4. package/dist/types/async/support.d.ts +0 -14
  5. package/dist/types/capability/mcp.d.ts +2 -0
  6. package/dist/types/capability/skill.d.ts +2 -0
  7. package/dist/types/capability/types.d.ts +2 -0
  8. package/dist/types/cli/public-command-errors.d.ts +3 -1
  9. package/dist/types/cli/setup-cli.d.ts +1 -1
  10. package/dist/types/commands/deep-interview.d.ts +3 -0
  11. package/dist/types/commands/mcp-serve.d.ts +4 -0
  12. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  13. package/dist/types/config/file-lock.d.ts +1 -0
  14. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  15. package/dist/types/config/model-profile-activation.d.ts +11 -3
  16. package/dist/types/config/model-registry.d.ts +4 -9
  17. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  18. package/dist/types/cursor-delete-path.d.ts +1 -0
  19. package/dist/types/cursor-mcp-name.d.ts +8 -0
  20. package/dist/types/customization/inventory.d.ts +2 -0
  21. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  24. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  25. package/dist/types/discovery/helpers.d.ts +6 -1
  26. package/dist/types/discovery/mcp-json.d.ts +1 -0
  27. package/dist/types/eval/py/kernel.d.ts +18 -0
  28. package/dist/types/export/html/css-color.d.ts +2 -0
  29. package/dist/types/export/html/html-export-destination.d.ts +2 -0
  30. package/dist/types/export/html/template.generated.d.ts +1 -1
  31. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
  32. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  33. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  34. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  35. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  36. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  37. package/dist/types/extensibility/skill-management.d.ts +4 -1
  38. package/dist/types/extensibility/skills.d.ts +2 -0
  39. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  40. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  41. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  42. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
  43. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  44. package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
  45. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  46. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  47. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  48. package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
  49. package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
  50. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  51. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  52. package/dist/types/lsp/config.d.ts +8 -5
  53. package/dist/types/lsp/workspace-path.d.ts +1 -0
  54. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  55. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  56. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  57. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  58. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  59. package/dist/types/modes/interactive-mode.d.ts +1 -0
  60. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  61. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  62. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  63. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  64. package/dist/types/progress/progress-contract.d.ts +154 -0
  65. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  66. package/dist/types/progress/project-progress.d.ts +141 -0
  67. package/dist/types/progress/render-progress.d.ts +28 -0
  68. package/dist/types/runtime-mcp/manager.d.ts +2 -1
  69. package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
  70. package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -0
  71. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  72. package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
  73. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  74. package/dist/types/runtime-mcp/types.d.ts +6 -0
  75. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  76. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  77. package/dist/types/sdk/broker/broker.d.ts +24 -2
  78. package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
  79. package/dist/types/sdk/broker/ensure.d.ts +14 -1
  80. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  81. package/dist/types/sdk/bus/index.d.ts +12 -1
  82. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  83. package/dist/types/sdk/client/client.d.ts +2 -0
  84. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  85. package/dist/types/sdk/index.d.ts +1 -0
  86. package/dist/types/sdk/router/session-router.d.ts +2 -0
  87. package/dist/types/sdk/session-list.d.ts +1 -1
  88. package/dist/types/sdk/session.d.ts +8 -0
  89. package/dist/types/session/agent-session.d.ts +38 -9
  90. package/dist/types/session/artifacts.d.ts +2 -2
  91. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  92. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  93. package/dist/types/session/internal/managed-session-storage.d.ts +26 -1
  94. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  95. package/dist/types/session/internal/task-artifact-owner-access.d.ts +2 -0
  96. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +9 -1
  97. package/dist/types/session/session-manager.d.ts +31 -5
  98. package/dist/types/session/session-retirement.d.ts +31 -7
  99. package/dist/types/session/session-storage.d.ts +69 -2
  100. package/dist/types/session/task-artifact-owner-codec.d.ts +2 -0
  101. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  102. package/dist/types/setup/credential-import.d.ts +6 -4
  103. package/dist/types/skill-state/active-state.d.ts +38 -0
  104. package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
  105. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  106. package/dist/types/task/executor.d.ts +3 -0
  107. package/dist/types/task/isolation-dir.d.ts +26 -0
  108. package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
  109. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  110. package/dist/types/tools/browser/cdp-url.d.ts +2 -0
  111. package/dist/types/tools/github-side-effect.d.ts +22 -0
  112. package/dist/types/tools/index.d.ts +3 -0
  113. package/dist/types/tools/monitor-mutation.d.ts +9 -0
  114. package/dist/types/tools/python.d.ts +9 -1
  115. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  116. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  117. package/dist/types/tools/todo-write.d.ts +6 -0
  118. package/dist/types/utils/tools-checksum.d.ts +7 -0
  119. package/package.json +7 -7
  120. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  121. package/scripts/verify-managed-empty-stop.ts +28 -0
  122. package/src/async/endpoint-id.ts +26 -0
  123. package/src/async/job-manager.ts +247 -132
  124. package/src/async/support.ts +0 -25
  125. package/src/capability/index.ts +15 -0
  126. package/src/capability/mcp.ts +2 -0
  127. package/src/capability/skill.ts +2 -0
  128. package/src/capability/types.ts +2 -0
  129. package/src/cli/auth-broker-cli.ts +1 -1
  130. package/src/cli/customize-doctor.ts +15 -2
  131. package/src/cli/doctor/repairs.ts +4 -1
  132. package/src/cli/doctor-supervisor.ts +3 -0
  133. package/src/cli/public-command-errors.ts +21 -1
  134. package/src/cli/public-command-registry.ts +28 -6
  135. package/src/cli/setup-cli.ts +4 -4
  136. package/src/cli/skills-cli.ts +5 -0
  137. package/src/commands/deep-interview.ts +47 -8
  138. package/src/commands/mcp-serve.ts +7 -0
  139. package/src/commands/sdk.ts +455 -397
  140. package/src/config/autorouting-tier-map.ts +117 -7
  141. package/src/config/file-lock.ts +316 -79
  142. package/src/config/mcp-schema.json +4 -0
  143. package/src/config/model-bindings-applier.ts +20 -0
  144. package/src/config/model-profile-activation.ts +240 -46
  145. package/src/config/model-registry.ts +47 -153
  146. package/src/config/skill-settings-defaults.ts +7 -0
  147. package/src/coordinator-mcp/policy.ts +3 -2
  148. package/src/coordinator-mcp/server.ts +2 -0
  149. package/src/cursor-delete-path.ts +10 -0
  150. package/src/cursor-mcp-name.ts +15 -0
  151. package/src/cursor.ts +13 -2
  152. package/src/customization/inventory.ts +4 -13
  153. package/src/dap/config.ts +2 -2
  154. package/src/debug/report-bundle-parent.ts +22 -0
  155. package/src/debug/report-bundle.ts +3 -0
  156. package/src/debug/system-info.ts +13 -1
  157. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
  158. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  159. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  160. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  161. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  162. package/src/defaults/gjc-defaults.test.ts +18 -1
  163. package/src/defaults/gjc-defaults.ts +3 -0
  164. package/src/defaults/gjc-skills.generated.ts +58 -36
  165. package/src/discovery/agents.ts +6 -1
  166. package/src/discovery/builtin.ts +13 -1
  167. package/src/discovery/helpers.ts +171 -29
  168. package/src/discovery/mcp-json.ts +26 -12
  169. package/src/discovery/ssh.ts +1 -1
  170. package/src/discovery/substitute-plugin-root.ts +3 -1
  171. package/src/edit/file-read-cache.ts +1 -1
  172. package/src/eval/py/executor.ts +796 -154
  173. package/src/eval/py/kernel.ts +79 -13
  174. package/src/eval/py/tool-bridge.ts +4 -0
  175. package/src/export/html/css-color.ts +9 -0
  176. package/src/export/html/html-export-destination.ts +16 -0
  177. package/src/export/html/index.ts +7 -10
  178. package/src/export/html/template.generated.ts +1 -1
  179. package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
  180. package/src/extensibility/extensions/runner.ts +15 -1
  181. package/src/extensibility/extensions/types.ts +7 -0
  182. package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
  183. package/src/extensibility/gjc-plugins/runtime-adapters.ts +85 -8
  184. package/src/extensibility/plugins/manager.ts +3 -2
  185. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  186. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  187. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  188. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  189. package/src/extensibility/skill-management.ts +9 -2
  190. package/src/extensibility/skills.ts +6 -1
  191. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  192. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  193. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  194. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  195. package/src/gjc-runtime/gc-runtime.ts +206 -9
  196. package/src/gjc-runtime/launch-worktree.ts +70 -9
  197. package/src/gjc-runtime/psmux-detect.ts +28 -4
  198. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  199. package/src/gjc-runtime/session-state-sidecar.ts +3 -2
  200. package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
  201. package/src/gjc-runtime/state-argv.ts +2 -0
  202. package/src/gjc-runtime/state-migrations.ts +91 -29
  203. package/src/gjc-runtime/state-runtime.ts +3716 -135
  204. package/src/gjc-runtime/state-writer.ts +105 -16
  205. package/src/gjc-runtime/tmux-common.ts +8 -5
  206. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  207. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  208. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  209. package/src/harness-control-plane/operate.ts +12 -4
  210. package/src/harness-control-plane/owner.ts +5 -1
  211. package/src/hashline/parser.ts +48 -9
  212. package/src/hashline/recovery.ts +13 -4
  213. package/src/internal-urls/docs-index.generated.ts +20 -19
  214. package/src/internal-urls/skill-path.ts +19 -0
  215. package/src/internal-urls/skill-protocol.ts +2 -0
  216. package/src/lsp/config.ts +12 -63
  217. package/src/lsp/edits.ts +6 -0
  218. package/src/lsp/workspace-path.ts +51 -0
  219. package/src/main.ts +195 -31
  220. package/src/memories/index.ts +88 -7
  221. package/src/modes/acp/acp-agent.ts +88 -4
  222. package/src/modes/components/custom-editor.ts +14 -0
  223. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  224. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  225. package/src/modes/components/extensions/state-manager.ts +15 -5
  226. package/src/modes/components/hook-editor.ts +1 -1
  227. package/src/modes/components/hook-selector.ts +1 -1
  228. package/src/modes/components/model-selector.ts +1 -4
  229. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  230. package/src/modes/components/status-line/segments.ts +4 -0
  231. package/src/modes/controllers/event-controller.ts +11 -4
  232. package/src/modes/controllers/input-controller.ts +76 -31
  233. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -16
  234. package/src/modes/controllers/selector-controller.ts +24 -15
  235. package/src/modes/controllers/todo-command-controller.ts +1 -1
  236. package/src/modes/interactive-mode.ts +5 -1
  237. package/src/modes/runtime-init.ts +4 -0
  238. package/src/modes/utils/abort-message.ts +3 -1
  239. package/src/modes/utils/interrupt-ownership.ts +12 -0
  240. package/src/modes/utils/keybinding-matchers.ts +4 -0
  241. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  242. package/src/progress/collect-project-progress.ts +153 -0
  243. package/src/progress/progress-contract.ts +172 -0
  244. package/src/progress/progress-snapshot.ts +140 -0
  245. package/src/progress/project-progress.ts +446 -0
  246. package/src/progress/render-progress.ts +209 -0
  247. package/src/prompts/system/master-mode.md +1 -0
  248. package/src/prompts/tools/skill-discovery.md +1 -1
  249. package/src/runtime-mcp/client.ts +4 -2
  250. package/src/runtime-mcp/config-writer.ts +10 -6
  251. package/src/runtime-mcp/config.ts +1 -0
  252. package/src/runtime-mcp/json-rpc.ts +2 -1
  253. package/src/runtime-mcp/manager.ts +29 -11
  254. package/src/runtime-mcp/mcp-redirect.ts +73 -0
  255. package/src/runtime-mcp/oauth-flow.ts +14 -2
  256. package/src/runtime-mcp/oauth-public-url.ts +14 -0
  257. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  258. package/src/runtime-mcp/pool.ts +6 -0
  259. package/src/runtime-mcp/project-host-values.ts +46 -0
  260. package/src/runtime-mcp/smithery-registry.ts +22 -3
  261. package/src/runtime-mcp/transports/http.ts +4 -3
  262. package/src/runtime-mcp/types.ts +6 -0
  263. package/src/sdk/acp/adapter.ts +95 -8
  264. package/src/sdk/broker/broker-exit.ts +52 -23
  265. package/src/sdk/broker/broker.ts +81 -6
  266. package/src/sdk/broker/doctor-restart.ts +4 -0
  267. package/src/sdk/broker/ensure.ts +31 -2
  268. package/src/sdk/broker/lifecycle.ts +711 -66
  269. package/src/sdk/broker/session-index.ts +191 -81
  270. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  271. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  272. package/src/sdk/bus/index.ts +238 -43
  273. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  274. package/src/sdk/cli/session-cli.ts +17 -3
  275. package/src/sdk/client/client.ts +28 -8
  276. package/src/sdk/host/query/handlers.ts +9 -1
  277. package/src/sdk/host/session-runtime.test.ts +2705 -303
  278. package/src/sdk/host/session-runtime.ts +490 -143
  279. package/src/sdk/host/surface-policy.ts +1 -0
  280. package/src/sdk/index.ts +8 -0
  281. package/src/sdk/lifecycle-session.ts +3 -1
  282. package/src/sdk/prompt-deadline-manager.ts +15 -0
  283. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  284. package/src/sdk/protocol/operation-registry.ts +9 -2
  285. package/src/sdk/router/session-router.ts +82 -18
  286. package/src/sdk/session-list.ts +1 -0
  287. package/src/sdk/session.ts +69 -45
  288. package/src/session/agent-session.ts +679 -238
  289. package/src/session/artifacts.ts +176 -71
  290. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  291. package/src/session/internal/managed-session-scope.ts +3779 -462
  292. package/src/session/internal/managed-session-storage.ts +501 -78
  293. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  294. package/src/session/internal/task-artifact-owner-access.ts +19 -3
  295. package/src/session/internal/task-artifact-owner-transcript.ts +547 -1
  296. package/src/session/session-manager.ts +951 -133
  297. package/src/session/session-retirement.ts +573 -14
  298. package/src/session/session-storage.ts +447 -15
  299. package/src/session/task-artifact-owner-codec.ts +71 -8
  300. package/src/session/task-artifact-owner-retirement.ts +21 -19
  301. package/src/setup/credential-auto-import.ts +12 -3
  302. package/src/setup/credential-import.ts +176 -5
  303. package/src/setup/paseo/announce.ts +6 -1
  304. package/src/skill-state/active-state.ts +145 -2
  305. package/src/skill-state/workflow-mutation-guard.ts +102 -10
  306. package/src/slash-commands/builtin-registry.ts +39 -9
  307. package/src/slash-commands/helpers/mcp.ts +8 -4
  308. package/src/slash-commands/helpers/progress-report.ts +23 -0
  309. package/src/stt/setup.ts +3 -5
  310. package/src/task/commands.ts +3 -1
  311. package/src/task/executor.ts +4 -0
  312. package/src/task/index.ts +6 -0
  313. package/src/task/isolation-dir.ts +143 -0
  314. package/src/task/worktree.ts +16 -7
  315. package/src/tools/ask.ts +205 -6
  316. package/src/tools/ast-edit-write-path.ts +68 -0
  317. package/src/tools/ast-edit.ts +10 -1
  318. package/src/tools/bash-allowed-prefixes.ts +15 -0
  319. package/src/tools/bash-skill-urls.ts +56 -3
  320. package/src/tools/bash.ts +48 -79
  321. package/src/tools/browser/cdp-url.ts +18 -0
  322. package/src/tools/browser.ts +8 -1
  323. package/src/tools/descriptors.ts +5 -1
  324. package/src/tools/eval.ts +56 -36
  325. package/src/tools/gh.ts +10 -0
  326. package/src/tools/github-side-effect.ts +112 -0
  327. package/src/tools/image-gen.ts +1 -1
  328. package/src/tools/index.ts +3 -0
  329. package/src/tools/monitor-mutation.ts +27 -0
  330. package/src/tools/monitor.ts +7 -0
  331. package/src/tools/python.ts +188 -66
  332. package/src/tools/recipe/index.ts +9 -0
  333. package/src/tools/recipe/recipe-guard.ts +19 -0
  334. package/src/tools/restricted-cwd.ts +10 -0
  335. package/src/tools/skill-discovery.ts +5 -0
  336. package/src/tools/skill.ts +28 -1
  337. package/src/tools/todo-write.ts +10 -1
  338. package/src/tools/tool-catalog.generated.ts +1 -1
  339. package/src/tools/write.ts +1 -0
  340. package/src/utils/external-editor.ts +3 -2
  341. package/src/utils/tools-checksum.ts +34 -0
  342. package/src/utils/tools-manager.ts +24 -6
  343. package/src/web/insane/url-guard.ts +2 -1
  344. package/src/web/search/providers/codex.ts +3 -2
@@ -12,6 +12,7 @@ import type {
12
12
  } from "@gajae-code/natives";
13
13
  import {
14
14
  exactRestore,
15
+ exactUnlinkDirect,
15
16
  openRecoveryFsRoot,
16
17
  renameDirectoryNoReplacePathAsync,
17
18
  renameNoReplacePathAsync,
@@ -66,7 +67,8 @@ export class FileLockAcquireError extends Error {
66
67
  readonly orphanPath?: string,
67
68
  readonly removalFailure?: FileLockStaleRemovalFailure,
68
69
  ) {
69
- const detail = reason === "orphan_transition" && orphanPath ? `orphan_transition at ${orphanPath}` : holder;
70
+ const detail =
71
+ reason === "orphan_transition" && orphanPath ? `blocked by retained removal transition ${orphanPath}` : holder;
70
72
  const removalDetail = removalFailure
71
73
  ? ` The dead owner's lock directory could not be reaped on this host (${removalFailure.message}${
72
74
  removalFailure.code ? ` [${removalFailure.code}]` : ""
@@ -105,6 +107,9 @@ const DEFAULT_OPTIONS: Required<
105
107
  const PUBLICATION_SHARING_RETRY_ATTEMPTS = 3;
106
108
  const PUBLICATION_SHARING_RETRY_DELAY_MS = 10;
107
109
 
110
+ /** Ownerless empty residue must outlive both this grace and the acquire budget. */
111
+ const REMOVAL_TRANSITION_GRACE_MS = 10_000;
112
+
108
113
  /** Release retries cover transient handle denial and a competing exact-removal quarantine cleanup. */
109
114
  export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 20;
110
115
  export const FILE_LOCK_RELEASE_RETRY_DELAY_MS = 25;
@@ -127,6 +132,7 @@ type LockInfo = FileLockOwnerToken;
127
132
 
128
133
  export const FileLockTestHooks: {
129
134
  afterParentMkdir?: (lockPath: string) => void | Promise<void>;
135
+ beforeRemovalOwnerPublish?: (transitionPath: string) => void | Promise<void>;
130
136
  nativePublicationBindings?: () => {
131
137
  renameNoReplacePathAsync: typeof renameNoReplacePathAsync;
132
138
  renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
@@ -258,6 +264,19 @@ type LockInfoPathState = {
258
264
  file: LockInfoFileState;
259
265
  };
260
266
 
267
+ /** Bun/libuv may expose Windows file IDs as i64; native snapshots use u64. */
268
+ function canonicalLockFileId(value: bigint): bigint {
269
+ if (value < -(1n << 63n) || value > (1n << 64n) - 1n) throw new Error("file_lock_identity_out_of_range");
270
+ return BigInt.asUintN(64, value);
271
+ }
272
+
273
+ /** Windows volume serials are u32 even when Bun returns a signed `stat.dev`. */
274
+ function canonicalLockDeviceId(value: bigint): bigint {
275
+ if (process.platform !== "win32") return canonicalLockFileId(value);
276
+ if (value < -(1n << 31n) || value > (1n << 32n) - 1n) throw new Error("file_lock_identity_out_of_range");
277
+ return BigInt.asUintN(32, value);
278
+ }
279
+
261
280
  function lockInfoFileState(stats: BigIntStats): LockInfoFileState | null {
262
281
  if (stats.isSymbolicLink() || !stats.isFile()) return null;
263
282
  // Some supported filesystems report creation time as missing or epoch zero. Keep
@@ -265,8 +284,8 @@ function lockInfoFileState(stats: BigIntStats): LockInfoFileState | null {
265
284
  // acquire/release/GC on a metadata field the filesystem cannot provide.
266
285
  const birthtimeNs = typeof stats.birthtimeNs === "bigint" && stats.birthtimeNs > 0n ? stats.birthtimeNs : 0n;
267
286
  return {
268
- dev: stats.dev,
269
- ino: stats.ino,
287
+ dev: canonicalLockDeviceId(stats.dev),
288
+ ino: canonicalLockFileId(stats.ino),
270
289
  mode: stats.mode,
271
290
  size: stats.size,
272
291
  mtimeNs: stats.mtimeNs,
@@ -310,7 +329,7 @@ async function lockInfoPathState(lockPath: string): Promise<LockInfoPathState |
310
329
  const file = lockInfoFileState(info);
311
330
  if (!file) return null;
312
331
  return {
313
- root: { dev: root.dev, ino: root.ino, mode: root.mode },
332
+ root: { dev: canonicalLockDeviceId(root.dev), ino: canonicalLockFileId(root.ino), mode: root.mode },
314
333
  file,
315
334
  };
316
335
  }
@@ -326,10 +345,10 @@ function sameLockInfoPathState(left: LockInfoPathState, right: LockInfoPathState
326
345
 
327
346
  function fileLockDirIdentityFromPathState(state: LockInfoPathState, bytes: string): GenericFileLockDirIdentity {
328
347
  return {
329
- rootDev: String(state.root.dev),
330
- rootIno: String(state.root.ino),
331
- infoDev: String(state.file.dev),
332
- infoIno: String(state.file.ino),
348
+ rootDev: canonicalLockDeviceId(state.root.dev).toString(),
349
+ rootIno: canonicalLockFileId(state.root.ino).toString(),
350
+ infoDev: canonicalLockDeviceId(state.file.dev).toString(),
351
+ infoIno: canonicalLockFileId(state.file.ino).toString(),
333
352
  infoNlink: String(state.file.nlink),
334
353
  infoSize: String(state.file.size),
335
354
  infoMtimeNs: String(state.file.mtimeNs),
@@ -539,8 +558,8 @@ async function removeDetachedLockQuarantineOnDisk(
539
558
  if (
540
559
  !current.isDirectory() ||
541
560
  current.isSymbolicLink() ||
542
- current.dev.toString() !== rootDev ||
543
- current.ino.toString() !== rootIno
561
+ canonicalLockDeviceId(current.dev).toString() !== rootDev ||
562
+ canonicalLockFileId(current.ino).toString() !== rootIno
544
563
  ) {
545
564
  return false;
546
565
  }
@@ -556,8 +575,8 @@ async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boo
556
575
  if (
557
576
  !current.isDirectory() ||
558
577
  current.isSymbolicLink() ||
559
- current.dev.toString() !== pending.rootDev ||
560
- current.ino.toString() !== pending.rootIno
578
+ canonicalLockDeviceId(current.dev).toString() !== pending.rootDev ||
579
+ canonicalLockFileId(current.ino).toString() !== pending.rootIno
561
580
  ) {
562
581
  throw new Error("Detached file lock cleanup identity changed; refusing removal");
563
582
  }
@@ -576,6 +595,7 @@ async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boo
576
595
  if (!isEnoent(error)) throw error;
577
596
  }
578
597
  pendingDetachedLockCleanups.delete(owner);
598
+ await clearRemovalRecord(pending.path);
579
599
  return true;
580
600
  }
581
601
 
@@ -587,10 +607,57 @@ function fileLockRemovalTransitionPath(lockPath: string): string {
587
607
  return `${lockPath}.removing`;
588
608
  }
589
609
 
610
+ type RemovalTransitionOwnerRecord = {
611
+ owner: FileLockOwnerToken;
612
+ rootDev: string;
613
+ rootIno: string;
614
+ };
615
+
616
+ const localRemovalRecords = new Map<string, { bytes: string; resumable: boolean }>();
617
+
618
+ function sameRemovalRecordBytes(left: string, right: string): boolean {
619
+ try {
620
+ const a = JSON.parse(left) as RemovalTransitionOwnerRecord;
621
+ const b = JSON.parse(right) as RemovalTransitionOwnerRecord;
622
+ const aOwner = parseLockInfoBytes(JSON.stringify(a.owner));
623
+ const bOwner = parseLockInfoBytes(JSON.stringify(b.owner));
624
+ return (
625
+ aOwner !== null &&
626
+ bOwner !== null &&
627
+ a.rootDev === b.rootDev &&
628
+ a.rootIno === b.rootIno &&
629
+ JSON.stringify(aOwner) === JSON.stringify(bOwner)
630
+ );
631
+ } catch {
632
+ return false;
633
+ }
634
+ }
635
+
636
+ function isResumableLocalRemoval(transitionPath: string, record: RemovalTransitionOwnerRecord): boolean {
637
+ const local = localRemovalRecords.get(`${transitionPath}.owner`);
638
+ return local?.resumable === true && sameRemovalRecordBytes(local.bytes, JSON.stringify(record));
639
+ }
640
+
590
641
  type FileLockRemovalTransitionState = "active" | "abandoned" | "orphan_transition";
591
642
  type FileLockOrphanTransition = { kind: "orphan_transition"; path: string };
592
643
  type FileLockAcquisitionResult = LockInfo | FileLockOrphanTransition | null;
593
644
 
645
+ async function readRemovalTransitionOwnerRecord(transitionPath: string): Promise<RemovalTransitionOwnerRecord | null> {
646
+ try {
647
+ const stat = await fs.lstat(`${transitionPath}.owner`);
648
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return null;
649
+ const record = JSON.parse(await Bun.file(`${transitionPath}.owner`).text()) as RemovalTransitionOwnerRecord;
650
+ const owner = parseLockInfoBytes(JSON.stringify(record.owner));
651
+ return owner && typeof record.rootDev === "string" && typeof record.rootIno === "string"
652
+ ? { owner, rootDev: record.rootDev, rootIno: record.rootIno }
653
+ : null;
654
+ } catch (error) {
655
+ if (isEnoent(error)) return null;
656
+ logger.debug("Failed to read removal transition owner record", { error });
657
+ return null;
658
+ }
659
+ }
660
+
594
661
  async function classifyFileLockRemovalTransition(
595
662
  lockPath: string,
596
663
  orphanAgeMs: number,
@@ -598,16 +665,51 @@ async function classifyFileLockRemovalTransition(
598
665
  previousOwnerHostIds: readonly string[] = [],
599
666
  ): Promise<FileLockRemovalTransitionState | null> {
600
667
  const transitionPath = fileLockRemovalTransitionPath(lockPath);
668
+ let transition: BigIntStats;
601
669
  try {
602
- const transition = await fs.lstat(transitionPath);
670
+ transition = await fs.lstat(transitionPath, { bigint: true });
603
671
  if (!transition.isDirectory() || transition.isSymbolicLink()) return "active";
604
672
  } catch (error) {
605
673
  if (isEnoent(error)) return null;
606
674
  throw error;
607
675
  }
608
676
 
677
+ // A sibling record survives rename and scrub without mutating the captured tree.
678
+ const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
679
+ if (recordedOwner) {
680
+ if (
681
+ recordedOwner.owner.owner_host_id !== undefined &&
682
+ lockRecordIsForeignHost(recordedOwner.owner, ownerHostId, previousOwnerHostIds)
683
+ )
684
+ return "active";
685
+ if (
686
+ canonicalLockDeviceId(transition.dev).toString() !== recordedOwner.rootDev ||
687
+ canonicalLockFileId(transition.ino).toString() !== recordedOwner.rootIno
688
+ )
689
+ return "active";
690
+ if (isResumableLocalRemoval(transitionPath, recordedOwner)) return "orphan_transition"; // The remover itself may resume its own interrupted cleanup.
691
+ return ownerIsAlive(recordedOwner.owner) || ownerLiveness(recordedOwner.owner.pid) === "unknown"
692
+ ? "active"
693
+ : "orphan_transition";
694
+ }
695
+ try {
696
+ await fs.lstat(`${transitionPath}.owner`);
697
+ return "active";
698
+ } catch (error) {
699
+ if (!isEnoent(error)) return "active";
700
+ }
701
+
609
702
  const observation = await readLockInfoObservation(transitionPath);
610
- if (!observation) return "active";
703
+ if (!observation) {
704
+ try {
705
+ if (Date.now() - Number(transition.mtimeMs) >= orphanAgeMs && (await fs.readdir(transitionPath)).length === 0)
706
+ return "orphan_transition";
707
+ } catch (error) {
708
+ if (isEnoent(error)) return null;
709
+ throw error;
710
+ }
711
+ return "active";
712
+ }
611
713
  const info = parseLockInfoBytes(observation.bytes);
612
714
  if (info) {
613
715
  const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds, undefined, true);
@@ -622,46 +724,152 @@ function isFileLockOrphanTransition(value: FileLockAcquisitionResult): value is
622
724
  return value !== null && "kind" in value && value.kind === "orphan_transition";
623
725
  }
624
726
 
625
- /**
626
- * Whether `snapshot` has the exact shape the native removal primitive leaves
627
- * behind after its payload scrub: a directory tree in which every file entry
628
- * has already been truncated to zero bytes. Only the identity-bound native
629
- * exact-removal primitive produces that residue, and it does so after every
630
- * authorized payload mutation is complete, so the shape itself is the proof
631
- * that no live owner can still be using the tree.
632
- */
633
- function isScrubbedRemovalTransition(snapshot: NativeDirectoryTreeSnapshot): boolean {
634
- if (snapshot.entries.length === 0) return false;
635
- const root = snapshot.entries.find(entry => entry.relativePath === "");
636
- if (root?.kind !== "directory") return false;
637
- return snapshot.entries.every(entry => entry.kind === "directory" || entry.size === "0");
727
+ /** Without an owner, only an aged, completely empty directory is reclaimable. */
728
+ function isEmptyRemovalTransition(snapshot: NativeDirectoryTreeSnapshot, graceMs: number): boolean {
729
+ const root = snapshot.entries[0];
730
+ if (snapshot.entries.length !== 1 || root?.relativePath !== "" || root.kind !== "directory") return false;
731
+ const ageMs = Date.now() - Number(root.mtimeNs) / 1_000_000;
732
+ return Number.isFinite(ageMs) && ageMs >= graceMs;
733
+ }
734
+
735
+ async function removeRemovalRecord(recordPath: string, expectedBytes: string): Promise<boolean> {
736
+ try {
737
+ const stat = await fs.lstat(recordPath, { bigint: true });
738
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
739
+ const bytes = await Bun.file(recordPath).text();
740
+ if (!sameRemovalRecordBytes(bytes, expectedBytes)) return false;
741
+ return exactUnlinkDirect(recordPath, {
742
+ dev: canonicalLockDeviceId(stat.dev),
743
+ ino: canonicalLockFileId(stat.ino),
744
+ size: stat.size,
745
+ mtimeNs: stat.mtimeNs,
746
+ nlink: stat.nlink,
747
+ sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
748
+ quarantineName: `${path.basename(recordPath)}.retired.${crypto.randomUUID()}`,
749
+ }).ok;
750
+ } catch (error) {
751
+ if (isEnoent(error)) return true;
752
+ throw error;
753
+ }
754
+ }
755
+
756
+ async function clearRemovalRecord(transitionPath: string): Promise<void> {
757
+ const recordPath = `${transitionPath}.owner`;
758
+ const bytes = localRemovalRecords.get(recordPath)?.bytes;
759
+ if (!bytes) return;
760
+ try {
761
+ await fs.lstat(transitionPath);
762
+ return;
763
+ } catch (error) {
764
+ if (!isEnoent(error)) throw error;
765
+ }
766
+ if (await removeRemovalRecord(recordPath, bytes)) localRemovalRecords.delete(recordPath);
767
+ }
768
+
769
+ async function publishRemovalRecord(
770
+ transitionPath: string,
771
+ snapshot: NativeDirectoryTreeSnapshot,
772
+ ownerHostId?: string,
773
+ resumable = false,
774
+ ): Promise<boolean> {
775
+ const recordPath = `${transitionPath}.owner`;
776
+ if (localRemovalRecords.has(recordPath)) return false;
777
+ const bytes = JSON.stringify({
778
+ owner: lockInfo(ownerHostId, crypto.randomUUID()),
779
+ rootDev: snapshot.rootDev,
780
+ rootIno: snapshot.rootIno,
781
+ });
782
+ const staged = `${recordPath}.${process.pid}.${crypto.randomUUID()}`;
783
+ await Bun.write(staged, bytes, { mode: 0o600 });
784
+ try {
785
+ await fs.chmod(staged, 0o600);
786
+ await FileLockTestHooks.beforeRemovalOwnerPublish?.(transitionPath);
787
+ const published = await renameNoReplacePathAsync(staged, recordPath);
788
+ if (!published.ok) return false;
789
+ localRemovalRecords.set(recordPath, { bytes, resumable });
790
+ return true;
791
+ } finally {
792
+ await fs.rm(staged, { force: true });
793
+ }
794
+ }
795
+
796
+ /** Publish outside the captured tree, atomically and before native rename. */
797
+ async function exactRemoveLockTree(
798
+ lockPath: string,
799
+ snapshot: NativeDirectoryTreeSnapshot,
800
+ ownerHostId?: string,
801
+ parentIdentity?: { dev: bigint; ino: bigint },
802
+ detachOnly?: boolean,
803
+ ): Promise<NativeExactUnlinkResult> {
804
+ if (process.platform === "win32" || lockPath.endsWith(".removing"))
805
+ return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
806
+ const transitionPath = fileLockRemovalTransitionPath(lockPath);
807
+ const recordPath = `${transitionPath}.owner`;
808
+ const prior = await readRemovalTransitionOwnerRecord(transitionPath);
809
+ if (prior?.owner.owner_host_id !== undefined && lockRecordIsForeignHost(prior.owner, ownerHostId, []))
810
+ return { ok: false, code: "identity_mismatch" };
811
+ if (prior && !ownerIsAlive(prior.owner) && ownerLiveness(prior.owner.pid) !== "unknown") {
812
+ try {
813
+ await fs.lstat(transitionPath);
814
+ return { ok: false, code: "identity_mismatch" };
815
+ } catch (error) {
816
+ if (!isEnoent(error)) throw error;
817
+ }
818
+ if (!(await removeRemovalRecord(recordPath, JSON.stringify(prior))))
819
+ return { ok: false, code: "identity_mismatch" };
820
+ }
821
+ if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId)))
822
+ return { ok: false, code: "identity_mismatch" };
823
+ try {
824
+ return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
825
+ } finally {
826
+ await clearRemovalRecord(transitionPath);
827
+ }
638
828
  }
639
829
 
640
830
  /**
641
831
  * Adopt-and-finish a provably orphaned POSIX removal transition.
642
832
  *
643
- * On POSIX the native exact-removal primitive detaches the verified tree to its
644
- * deterministic `<lock>.removing` sibling, scrubs every authorized file payload
645
- * (truncating each to zero bytes), and hands the retained tree to the caller for
646
- * the final on-disk unlink. A process SIGKILLed in that gap leaves the scrubbed
647
- * tree behind with a zero-byte `info`, so the original owner record is gone and
648
- * no token or liveness proof can identify the owner. Ownership is therefore
649
- * proven by the object: a plain `.removing` directory whose snapshot is the
650
- * native scrub residue and whose `info` mtime is at least the full acquisition
651
- * budget old. Finishing that removal deletes only already-scrubbed residue and
652
- * never live lock state; any successor, placeholder, unscrubbed tree, or
653
- * transient native refusal is returned unadopted so the caller keeps the typed
654
- * `orphan_transition` diagnostic.
833
+ * The prepublished sibling carries the remover's process generation and the
834
+ * captured root identity until cleanup finishes. A dead remover permits replay
835
+ * even when death preceded payload scrub. Without that evidence only an aged
836
+ * empty root is safe: zero-byte files alone do not prove abandonment. Native
837
+ * identity verification and the durable-scrub receipt still govern completion.
655
838
  */
656
- async function adoptOrphanedFileLockRemovalTransition(lockPath: string, orphanAgeMs: number): Promise<boolean> {
839
+ async function adoptOrphanedFileLockRemovalTransition(
840
+ lockPath: string,
841
+ orphanAgeMs: number,
842
+ ownerHostId?: string,
843
+ previousOwnerHostIds: readonly string[] = [],
844
+ ): Promise<boolean> {
657
845
  const transitionPath = fileLockRemovalTransitionPath(lockPath);
846
+ const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
847
+ if (recordedOwner && ownerIsAlive(recordedOwner.owner) && !isResumableLocalRemoval(transitionPath, recordedOwner))
848
+ return false;
658
849
  // Re-validate at the moment of adoption: a successor that published a parsed
659
850
  // owner or replaced the tree must never inherit an earlier orphan verdict.
660
- if ((await classifyFileLockRemovalTransition(lockPath, orphanAgeMs)) !== "orphan_transition") return false;
851
+ if (
852
+ (await classifyFileLockRemovalTransition(lockPath, orphanAgeMs, ownerHostId, previousOwnerHostIds)) !==
853
+ "orphan_transition"
854
+ )
855
+ return false;
661
856
  const captured = snapshotDirectoryTree(transitionPath);
662
857
  if (!captured.ok || !captured.snapshot) return false;
663
858
  const snapshot = captured.snapshot;
664
- if (!isScrubbedRemovalTransition(snapshot)) return false;
859
+ if (!recordedOwner && !isEmptyRemovalTransition(snapshot, orphanAgeMs)) return false;
860
+ if (recordedOwner && (snapshot.rootDev !== recordedOwner.rootDev || snapshot.rootIno !== recordedOwner.rootIno))
861
+ return false;
862
+ // Claim an ownerless generation without replacing any live publisher's record.
863
+ // The snapshot binds both the grace verdict and subsequent native removal.
864
+ if (!recordedOwner && !(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) return false;
865
+ const finish = async (): Promise<boolean> => {
866
+ if (recordedOwner) {
867
+ localRemovalRecords.set(`${transitionPath}.owner`, { bytes: JSON.stringify(recordedOwner), resumable: true });
868
+ }
869
+ await clearRemovalRecord(transitionPath);
870
+ logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
871
+ return true;
872
+ };
665
873
  let removal: NativeExactUnlinkResult;
666
874
  try {
667
875
  removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
@@ -670,14 +878,14 @@ async function adoptOrphanedFileLockRemovalTransition(lockPath: string, orphanAg
670
878
  throw error;
671
879
  }
672
880
  if (removal.ok === true) {
673
- return removal.code === undefined && Object.keys(removal).every(key => key === "ok");
881
+ return removal.code === undefined && Object.keys(removal).every(key => key === "ok") && (await finish());
674
882
  }
675
883
  if (
676
884
  removal.ok === false &&
677
885
  removal.code === "not_found" &&
678
886
  Object.keys(removal).every(key => key === "ok" || key === "code")
679
887
  )
680
- return true;
888
+ return await finish();
681
889
  // POSIX cannot bind a namespace unlink to the verified descriptor, so the
682
890
  // primitive retains the scrubbed tree under its deterministic name and the
683
891
  // caller finishes the on-disk removal. Finish only the exact tree this call
@@ -693,8 +901,10 @@ async function adoptOrphanedFileLockRemovalTransition(lockPath: string, orphanAg
693
901
  removal.retainedPlaceholderPath === undefined &&
694
902
  removal.retainedUnknownPath === undefined &&
695
903
  Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
696
- )
697
- return await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
904
+ ) {
905
+ const finished = await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
906
+ return finished && (await finish());
907
+ }
698
908
  return false;
699
909
  }
700
910
 
@@ -704,6 +914,8 @@ async function adoptAbandonedFileLockRemovalTransition(
704
914
  previousOwnerHostIds: readonly string[] = [],
705
915
  ): Promise<boolean> {
706
916
  const transitionPath = fileLockRemovalTransitionPath(lockPath);
917
+ const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
918
+ if (recordedOwner) return false; // The sibling-owner adoption path owns this generation.
707
919
  const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds, undefined, true);
708
920
  if (!stale.stale || !stale.identity) return false;
709
921
  if (!(await isNativeExactRemovalUsable())) return false;
@@ -738,6 +950,9 @@ async function adoptAbandonedFileLockRemovalTransition(
738
950
  !nativeFileLockInfoMatchesContentEvidence(infoEntry, stale.identity)
739
951
  )
740
952
  return false;
953
+ // Legacy info proves the old owner dead, but native scrub destroys that info.
954
+ // Commit this remover's sibling identity before allowing any payload mutation.
955
+ if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) return false;
741
956
 
742
957
  let removal: NativeExactUnlinkResult;
743
958
  try {
@@ -746,7 +961,7 @@ async function adoptAbandonedFileLockRemovalTransition(
746
961
  if (isTransientReleaseError(error)) return false;
747
962
  throw error;
748
963
  }
749
- if (removal.ok === true) return removal.code === undefined && Object.keys(removal).every(key => key === "ok");
964
+ let finished = removal.ok === true && removal.code === undefined && Object.keys(removal).every(key => key === "ok");
750
965
  if (
751
966
  removal.ok === false &&
752
967
  removal.code === "cleanup_pending" &&
@@ -758,12 +973,18 @@ async function adoptAbandonedFileLockRemovalTransition(
758
973
  removal.retainedUnknownPath === undefined &&
759
974
  Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
760
975
  )
761
- return await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
762
- return (
976
+ finished = await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
977
+ if (
763
978
  removal.ok === false &&
764
979
  removal.code === "not_found" &&
765
980
  Object.keys(removal).every(key => key === "ok" || key === "code")
766
- );
981
+ )
982
+ finished = true;
983
+ if (finished) {
984
+ await clearRemovalRecord(transitionPath);
985
+ logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
986
+ }
987
+ return finished;
767
988
  }
768
989
 
769
990
  function sameFileLockTreeAfterPublication(
@@ -859,8 +1080,8 @@ async function rollbackPublishedFileLock(
859
1080
  if (
860
1081
  !current.isDirectory() ||
861
1082
  current.isSymbolicLink() ||
862
- current.dev !== BigInt(`0x${identity[1]}`) ||
863
- current.ino !== BigInt(`0x${identity[2]}`)
1083
+ canonicalLockDeviceId(current.dev) !== BigInt(`0x${identity[1]}`) ||
1084
+ canonicalLockFileId(current.ino) !== BigInt(`0x${identity[2]}`)
864
1085
  ) {
865
1086
  throw new Error("File lock rollback placeholder identity changed; refusing removal");
866
1087
  }
@@ -1147,7 +1368,6 @@ function ownerGenerationIsDead(owner: FileLockOwnerToken): boolean {
1147
1368
 
1148
1369
  /** Outcome of a guarded lock-dir removal attempt (`removeFileLockDirForGc`). */
1149
1370
  export type FileLockGcRemoval = "removed" | "owner_changed" | "missing" | "cleanup_failed";
1150
-
1151
1371
  type LockStaleSnapshot =
1152
1372
  | { stale: false }
1153
1373
  | { stale: true; owner: FileLockOwnerToken; identity: GenericFileLockDirIdentity };
@@ -1261,10 +1481,11 @@ async function removeVerifiedLockDirWithoutNative(
1261
1481
  if (isTransientReleaseError(error)) throw error;
1262
1482
  return "cleanup_failed";
1263
1483
  }
1264
- const removal = nativeFileLockBindings().exactRemoveDirectoryTree(
1484
+ const removal = await exactRemoveLockTree(
1265
1485
  lockDir,
1266
1486
  expected,
1267
- { dev: parent.dev, ino: parent.ino },
1487
+ owner?.owner_host_id,
1488
+ { dev: canonicalLockDeviceId(parent.dev), ino: canonicalLockFileId(parent.ino) },
1268
1489
  true,
1269
1490
  );
1270
1491
  if (removal.ok && !removal.detachedPath) return "removed";
@@ -1289,9 +1510,10 @@ async function removeVerifiedLockDirWithoutNative(
1289
1510
  removal.retainedUnknownPath !== undefined
1290
1511
  )
1291
1512
  return "cleanup_failed";
1292
- return (await removeDetachedLockQuarantineOnDisk(removal.detachedPath, expected.rootDev, expected.rootIno))
1293
- ? "removed"
1294
- : "cleanup_failed";
1513
+ if (!(await removeDetachedLockQuarantineOnDisk(removal.detachedPath, expected.rootDev, expected.rootIno)))
1514
+ return "cleanup_failed";
1515
+ await clearRemovalRecord(removal.detachedPath);
1516
+ return "removed";
1295
1517
  }
1296
1518
 
1297
1519
  async function removeVerifiedOwnedLockDirWithoutNative(
@@ -1433,7 +1655,7 @@ export async function removeFileLockDirForGc(
1433
1655
  return "owner_changed";
1434
1656
  let removed: NativeExactUnlinkResult;
1435
1657
  try {
1436
- removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
1658
+ removed = await exactRemoveLockTree(nativeCapturePath, captured.snapshot, expected.owner_host_id);
1437
1659
  const retainedPath = fileLockRemovalTransitionPath(nativeCapturePath);
1438
1660
  if (
1439
1661
  process.platform === "linux" &&
@@ -1487,6 +1709,7 @@ export async function removeFileLockDirForGc(
1487
1709
  // contract gc-runtime applies to its own exact removals. Any other outcome —
1488
1710
  // including a retained successor or placeholder — leaves the judged object (or
1489
1711
  // its replacement) in place and reports the removal as refused.
1712
+ //
1490
1713
  const detachedPath = removed.detachedPath;
1491
1714
  const verifiedDetach =
1492
1715
  detachedPath !== undefined &&
@@ -1585,12 +1808,9 @@ async function staleLockSnapshot(
1585
1808
  throw error;
1586
1809
  }
1587
1810
  if (!info) {
1588
- // A directory without a valid owner record is either a contender between
1589
- // native directory ownership and metadata publication, or malformed state
1590
- // with no PID/incarnation/host proof of any process generation. Neither
1591
- // case carries liveness evidence, so elapsed time and byte stability must
1592
- // never make this namespace reclaimable: only an independently committed
1593
- // owner record proving its process generation dead authorizes removal.
1811
+ // An ownerless directory has no generation or liveness proof. Age and
1812
+ // emptiness cannot distinguish interrupted cleanup from a live legacy holder
1813
+ // or a paused acquisition, so every platform fails closed.
1594
1814
  return { stale: false };
1595
1815
  }
1596
1816
 
@@ -1633,8 +1853,6 @@ async function staleLockSnapshot(
1633
1853
  return { stale: false };
1634
1854
  }
1635
1855
 
1636
- type StaleLockRemovalAttempt = { removed: true } | { removed: false; failure?: FileLockStaleRemovalFailure };
1637
-
1638
1856
  type RecordedStaleRemovalFailure = {
1639
1857
  owner: FileLockOwnerToken;
1640
1858
  identity: GenericFileLockDirIdentity;
@@ -1678,10 +1896,12 @@ function manualLockCleanupCommand(lockPath: string): string {
1678
1896
  return `rm -rf -- '${quotedPath}'`;
1679
1897
  }
1680
1898
 
1899
+ type FileLockRemovalAttempt = { removed: true } | { removed: false; failure?: FileLockStaleRemovalFailure };
1900
+
1681
1901
  async function removeStaleLockForAcquire(
1682
1902
  lockPath: string,
1683
1903
  snapshot: LockStaleSnapshot,
1684
- ): Promise<StaleLockRemovalAttempt> {
1904
+ ): Promise<FileLockRemovalAttempt> {
1685
1905
  if (!snapshot.stale) return { removed: false };
1686
1906
  try {
1687
1907
  const outcome = await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity);
@@ -1818,7 +2038,14 @@ async function tryAcquireLock(
1818
2038
  // A scrubbed, aged transition is the residue of a removal whose owner died
1819
2039
  // before its final on-disk cleanup. Adopt and finish it here so the wedged
1820
2040
  // namespace heals instead of aborting (or re-spinning the whole budget).
1821
- if (!(await adoptOrphanedFileLockRemovalTransition(destinationPath, orphanTransitionAgeMs)))
2041
+ if (
2042
+ !(await adoptOrphanedFileLockRemovalTransition(
2043
+ destinationPath,
2044
+ orphanTransitionAgeMs,
2045
+ ownerHostId,
2046
+ previousOwnerHostIds,
2047
+ ))
2048
+ )
1822
2049
  return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
1823
2050
  transitionState = await classifyFileLockRemovalTransition(
1824
2051
  destinationPath,
@@ -1901,7 +2128,9 @@ async function tryAcquireLock(
1901
2128
  }
1902
2129
  }
1903
2130
  if (!publishedSuccessfully) {
1904
- if (published.reason === "destination_exists") return null;
2131
+ if (published.reason === "destination_exists") {
2132
+ return null;
2133
+ }
1905
2134
  const failure = new Error(
1906
2135
  `Failed to publish file lock: ${published.code ?? published.reason ?? "unknown"}.`,
1907
2136
  ) as NodeJS.ErrnoException;
@@ -2047,7 +2276,7 @@ async function quarantineReleasedLock(
2047
2276
  if (infoEntry.sha256 !== expectedDigest) return false;
2048
2277
  let removed: NativeExactUnlinkResult;
2049
2278
  try {
2050
- removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
2279
+ removed = await exactRemoveLockTree(nativeCapturePath, captured.snapshot, owner.owner_host_id);
2051
2280
  } catch (error) {
2052
2281
  if (isTransientReleaseError(error)) return false;
2053
2282
  throw error;
@@ -2198,7 +2427,7 @@ async function lockHolderDescription(
2198
2427
  if (transitionState === "abandoned")
2199
2428
  return `blocked by abandoned removal transition at ${fileLockRemovalTransitionPath(lockPath)}; inspect and remove the directory manually once no publisher remains`;
2200
2429
  if (transitionState === "active")
2201
- return "blocked by retained removal transition; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed";
2430
+ return `blocked by retained removal transition ${fileLockRemovalTransitionPath(lockPath)}; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed`;
2202
2431
  }
2203
2432
  let info = await readLockInfo(lockPath);
2204
2433
  let bytes: string | null = null;
@@ -2268,7 +2497,7 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2268
2497
  if (options.previousOwnerHostIds?.some(hostId => !hostId))
2269
2498
  throw new Error("previousOwnerHostIds must contain only non-empty identities");
2270
2499
  const opts = { ...DEFAULT_OPTIONS, ...options };
2271
- const orphanTransitionAgeMs = Math.max(0, opts.retries * opts.retryDelayMs);
2500
+ const orphanTransitionAgeMs = Math.max(REMOVAL_TRANSITION_GRACE_MS, opts.retries * opts.retryDelayMs);
2272
2501
 
2273
2502
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
2274
2503
  const lockPath = getLockPath(filePath);
@@ -2362,7 +2591,14 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2362
2591
  opts.retries,
2363
2592
  await lockHolderDescription(lockPath, orphanTransitionAgeMs, opts.ownerHostId, opts.previousOwnerHostIds ?? []),
2364
2593
  "acquire_timeout",
2365
- undefined,
2594
+ (await classifyFileLockRemovalTransition(
2595
+ lockPath,
2596
+ orphanTransitionAgeMs,
2597
+ opts.ownerHostId,
2598
+ opts.previousOwnerHostIds ?? [],
2599
+ ))
2600
+ ? fileLockRemovalTransitionPath(lockPath)
2601
+ : undefined,
2366
2602
  await staleRemovalFailureForCurrentGeneration(lockPath, staleRemovalFailure),
2367
2603
  );
2368
2604
  }
@@ -2441,8 +2677,8 @@ export async function inspectFileLockStagingDir(
2441
2677
  if (
2442
2678
  !captured.ok ||
2443
2679
  !captured.snapshot ||
2444
- captured.snapshot.rootDev !== root.dev.toString() ||
2445
- captured.snapshot.rootIno !== root.ino.toString()
2680
+ captured.snapshot.rootDev !== canonicalLockDeviceId(root.dev).toString() ||
2681
+ captured.snapshot.rootIno !== canonicalLockFileId(root.ino).toString()
2446
2682
  )
2447
2683
  return kept;
2448
2684
  const observation = await readFileLockObservationForGc(canonical);
@@ -2487,7 +2723,7 @@ export async function inspectFileLockStagingDir(
2487
2723
  const removal = await removeVerifiedLockDirWithoutNative(canonical, captured.snapshot);
2488
2724
  return { ...result, removed: removal === "removed", reason: removal };
2489
2725
  }
2490
- const removal = nativeFileLockBindings().exactRemoveDirectoryTree(canonical, captured.snapshot);
2726
+ const removal = await exactRemoveLockTree(canonical, captured.snapshot);
2491
2727
  if (
2492
2728
  removal.detachedPath &&
2493
2729
  path.resolve(removal.detachedPath) !== path.resolve(canonical) &&
@@ -2505,14 +2741,15 @@ export async function inspectFileLockStagingDir(
2505
2741
  if (
2506
2742
  detached?.isDirectory() &&
2507
2743
  !detached.isSymbolicLink() &&
2508
- detached.dev.toString() === captured.snapshot.rootDev &&
2509
- detached.ino.toString() === captured.snapshot.rootIno
2744
+ canonicalLockDeviceId(detached.dev).toString() === captured.snapshot.rootDev &&
2745
+ canonicalLockFileId(detached.ino).toString() === captured.snapshot.rootIno
2510
2746
  ) {
2511
2747
  try {
2512
2748
  await fs.rmdir(removal.detachedPath);
2513
2749
  } catch (error) {
2514
2750
  if (!isEnoent(error)) throw error;
2515
2751
  }
2752
+ await clearRemovalRecord(removal.detachedPath);
2516
2753
  return { ...result, removed: true, reason: "removed" };
2517
2754
  }
2518
2755
  }