@gajae-code/coding-agent 0.18.11 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (462) hide show
  1. package/CHANGELOG.md +760 -0
  2. package/dist/types/async/job-manager.d.ts +7 -3
  3. package/dist/types/async/result-formatting.d.ts +2 -0
  4. package/dist/types/autoresearch/git.d.ts +1 -1
  5. package/dist/types/capability/fs.d.ts +2 -2
  6. package/dist/types/capability/rule.d.ts +7 -2
  7. package/dist/types/cli/accounts-cli.d.ts +2 -2
  8. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  9. package/dist/types/cli/args.d.ts +7 -2
  10. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  11. package/dist/types/cli/customize-doctor.d.ts +4 -3
  12. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  13. package/dist/types/cli/install-activation.d.ts +10 -4
  14. package/dist/types/cli/public-command-errors.d.ts +2 -0
  15. package/dist/types/cli/root-flags.d.ts +0 -12
  16. package/dist/types/cli/update-cli.d.ts +21 -3
  17. package/dist/types/cli-main.d.ts +0 -6
  18. package/dist/types/cli-ordinary.d.ts +2 -2
  19. package/dist/types/commands/accounts.d.ts +2 -1
  20. package/dist/types/commands/launch.d.ts +0 -6
  21. package/dist/types/commands/migrate.d.ts +1 -1
  22. package/dist/types/commands/sdk.d.ts +13 -0
  23. package/dist/types/commit/changelog/index.d.ts +1 -0
  24. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  25. package/dist/types/config/autorouting-tier-map.d.ts +53 -1
  26. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  27. package/dist/types/config/config-hot-reload.d.ts +44 -0
  28. package/dist/types/config/file-lock.d.ts +52 -3
  29. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  30. package/dist/types/config/model-profile-activation.d.ts +79 -9
  31. package/dist/types/config/model-registry.d.ts +72 -8
  32. package/dist/types/config/model-resolver.d.ts +7 -4
  33. package/dist/types/config/models-config-schema.d.ts +10 -0
  34. package/dist/types/config/settings-schema.d.ts +57 -4
  35. package/dist/types/config/settings.d.ts +33 -1
  36. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  37. package/dist/types/customization/import.d.ts +2 -0
  38. package/dist/types/customization/types.d.ts +1 -1
  39. package/dist/types/dap/config.d.ts +8 -4
  40. package/dist/types/dap/session.d.ts +11 -2
  41. package/dist/types/debug/system-info.d.ts +6 -1
  42. package/dist/types/discovery/helpers.d.ts +7 -2
  43. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  44. package/dist/types/edit/index.d.ts +5 -0
  45. package/dist/types/edit/modes/patch.d.ts +14 -0
  46. package/dist/types/edit/modes/replace.d.ts +3 -0
  47. package/dist/types/eval/py/runtime.d.ts +9 -0
  48. package/dist/types/exec/bash-executor.d.ts +12 -0
  49. package/dist/types/export/ttsr.d.ts +19 -0
  50. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  51. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  52. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  53. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  54. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  55. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  56. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  57. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  58. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  59. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  60. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  61. package/dist/types/hindsight/content.d.ts +5 -6
  62. package/dist/types/internal-urls/types.d.ts +2 -0
  63. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  64. package/dist/types/main.d.ts +23 -18
  65. package/dist/types/migrate/action-planner.d.ts +10 -1
  66. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  67. package/dist/types/migrate/adapters/index.d.ts +42 -1
  68. package/dist/types/migrate/executor.d.ts +3 -1
  69. package/dist/types/migrate/hook-module.d.ts +23 -0
  70. package/dist/types/migrate/report.d.ts +2 -1
  71. package/dist/types/migrate/types.d.ts +82 -3
  72. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  73. package/dist/types/modes/components/model-selector.d.ts +43 -0
  74. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  75. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  76. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  77. package/dist/types/modes/theme/theme.d.ts +1 -0
  78. package/dist/types/modes/utils/host-status.d.ts +15 -11
  79. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  80. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  81. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  82. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  83. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  84. package/dist/types/sdk/broker/broker-exit.d.ts +3 -3
  85. package/dist/types/sdk/broker/broker.d.ts +29 -2
  86. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  87. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  88. package/dist/types/sdk/broker/ensure.d.ts +141 -9
  89. package/dist/types/sdk/broker/hop.d.ts +45 -0
  90. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  91. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  92. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  93. package/dist/types/sdk/broker/session-index.d.ts +6 -1
  94. package/dist/types/sdk/broker/transport.d.ts +2 -0
  95. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  96. package/dist/types/sdk/bus/index.d.ts +44 -0
  97. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  98. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -1
  99. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  100. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  101. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  102. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  103. package/dist/types/sdk/host/events.d.ts +4 -0
  104. package/dist/types/sdk/host/host.d.ts +2 -1
  105. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +40 -8
  107. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  109. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  111. package/dist/types/sdk/prompt-status.d.ts +11 -4
  112. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  113. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  114. package/dist/types/sdk/session-list.d.ts +1 -1
  115. package/dist/types/session/agent-session.d.ts +107 -9
  116. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  117. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  118. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  119. package/dist/types/session/messages.d.ts +5 -1
  120. package/dist/types/session/session-manager.d.ts +29 -3
  121. package/dist/types/session/startup-auth-config.d.ts +24 -2
  122. package/dist/types/session/streaming-output.d.ts +11 -0
  123. package/dist/types/session/terminal-abort.d.ts +3 -1
  124. package/dist/types/session-import/service.d.ts +1 -1
  125. package/dist/types/skill-state/workflow-mutation-guard.d.ts +20 -4
  126. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  127. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  128. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  129. package/dist/types/system-prompt.d.ts +3 -2
  130. package/dist/types/task/executor.d.ts +19 -2
  131. package/dist/types/task/role-body.d.ts +11 -0
  132. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  133. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  134. package/dist/types/tools/awaiting-input.d.ts +28 -0
  135. package/dist/types/tools/bash-allowed-prefixes.d.ts +17 -0
  136. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  137. package/dist/types/tools/bash.d.ts +7 -0
  138. package/dist/types/tools/descriptors.d.ts +15 -2
  139. package/dist/types/tools/fetch.d.ts +7 -1
  140. package/dist/types/tools/index.d.ts +2 -0
  141. package/dist/types/tools/irc.d.ts +5 -3
  142. package/dist/types/tools/python.d.ts +1 -1
  143. package/dist/types/tools/report-issue.d.ts +41 -0
  144. package/dist/types/tools/subagent.d.ts +23 -0
  145. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  146. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  147. package/dist/types/tools/write.d.ts +2 -0
  148. package/dist/types/utils/file-mentions.d.ts +5 -0
  149. package/dist/types/utils/image-limits.d.ts +7 -0
  150. package/dist/types/utils/image-loading.d.ts +0 -1
  151. package/dist/types/utils/open.d.ts +0 -1
  152. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  153. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  154. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  155. package/dist/types/utils/title-generator.d.ts +13 -3
  156. package/dist/types/web/scrapers/types.d.ts +8 -1
  157. package/dist/types/web/search/providers/codex.d.ts +6 -7
  158. package/dist/types/web/search/providers/insane.d.ts +7 -1
  159. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  160. package/package.json +10 -9
  161. package/scripts/acp-conformance-agent.ts +12 -2
  162. package/scripts/build-binary.ts +2 -1
  163. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  164. package/scripts/generate-docs-index.ts +8 -1
  165. package/scripts/generate-sdk-operation-inventory.ts +38 -4
  166. package/src/async/job-manager.ts +356 -56
  167. package/src/async/result-formatting.ts +40 -0
  168. package/src/autoresearch/git.ts +9 -2
  169. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  170. package/src/capability/fs.ts +58 -22
  171. package/src/capability/index.ts +2 -2
  172. package/src/capability/rule.ts +11 -2
  173. package/src/cli/accounts-cli.ts +176 -8
  174. package/src/cli/append-system-prompt.ts +12 -0
  175. package/src/cli/args.ts +11 -7
  176. package/src/cli/auth-broker-cli.ts +21 -11
  177. package/src/cli/customize-doctor-precedence.ts +31 -0
  178. package/src/cli/customize-doctor.ts +49 -39
  179. package/src/cli/doctor/install-repairs.ts +35 -5
  180. package/src/cli/fast-help.ts +1 -3
  181. package/src/cli/install-activation.ts +47 -4
  182. package/src/cli/migrate-cli.ts +19 -6
  183. package/src/cli/public-command-errors.ts +6 -0
  184. package/src/cli/root-flags.ts +2 -4
  185. package/src/cli/stats-cli.ts +9 -5
  186. package/src/cli/update-cli.ts +471 -13
  187. package/src/cli-ordinary.ts +7 -2
  188. package/src/commands/accounts.ts +10 -5
  189. package/src/commands/harness.ts +25 -15
  190. package/src/commands/migrate.ts +3 -3
  191. package/src/commands/sdk.ts +277 -50
  192. package/src/commands/ultragoal.ts +16 -8
  193. package/src/commit/agentic/agent.ts +6 -6
  194. package/src/commit/analysis/conventional.ts +14 -5
  195. package/src/commit/changelog/index.ts +39 -1
  196. package/src/commit/map-reduce/index.ts +7 -7
  197. package/src/commit/message.ts +10 -3
  198. package/src/commit/pipeline.ts +12 -12
  199. package/src/config/autorouting-tier-map.ts +50 -1
  200. package/src/config/config-file.ts +106 -3
  201. package/src/config/config-hot-reload-trust.ts +148 -0
  202. package/src/config/config-hot-reload.ts +742 -0
  203. package/src/config/file-lock.ts +1615 -328
  204. package/src/config/keybindings.ts +154 -7
  205. package/src/config/model-bindings-applier.ts +85 -47
  206. package/src/config/model-discovery-manager.ts +10 -0
  207. package/src/config/model-profile-activation.ts +889 -148
  208. package/src/config/model-registry.ts +1706 -205
  209. package/src/config/model-resolver.ts +52 -22
  210. package/src/config/models-config-schema.ts +6 -0
  211. package/src/config/settings-schema.ts +73 -4
  212. package/src/config/settings.ts +460 -24
  213. package/src/coordinator-mcp/policy.ts +26 -1
  214. package/src/coordinator-mcp/server.ts +37 -2
  215. package/src/crash/sanitize.ts +7 -1
  216. package/src/customization/import.ts +279 -8
  217. package/src/customization/inventory.ts +24 -8
  218. package/src/customization/types.ts +1 -0
  219. package/src/dap/client.ts +3 -2
  220. package/src/dap/config.ts +31 -11
  221. package/src/dap/defaults.json +1 -1
  222. package/src/dap/session.ts +161 -36
  223. package/src/debug/crash-diagnostics.ts +89 -6
  224. package/src/debug/report-bundle.ts +90 -2
  225. package/src/debug/system-info.ts +8 -3
  226. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  227. package/src/discovery/agents-md.ts +1 -1
  228. package/src/discovery/builtin.ts +4 -3
  229. package/src/discovery/helpers.ts +25 -16
  230. package/src/discovery/mcp-compat.ts +120 -17
  231. package/src/edit/apply-patch/index.ts +12 -1
  232. package/src/edit/index.ts +204 -22
  233. package/src/edit/modes/patch.ts +214 -8
  234. package/src/edit/modes/replace.ts +136 -0
  235. package/src/eval/js/context-manager.ts +29 -3
  236. package/src/eval/js/worker-entry.ts +51 -18
  237. package/src/eval/py/executor.ts +14 -4
  238. package/src/eval/py/runner.py +144 -5
  239. package/src/eval/py/runtime.ts +44 -4
  240. package/src/exec/bash-executor.ts +81 -4
  241. package/src/exec/bash-shell-guardian.ts +4 -1
  242. package/src/exec/bash-shell-supervisor.ts +8 -4
  243. package/src/exec/isolated-shell.ts +5 -1
  244. package/src/exec/non-interactive-env.ts +0 -1
  245. package/src/export/html/index.ts +86 -6
  246. package/src/export/ttsr.ts +176 -1
  247. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  248. package/src/extensibility/extensions/loader.ts +10 -1
  249. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  250. package/src/extensibility/extensions/types.ts +5 -1
  251. package/src/extensibility/hooks/loader.ts +10 -2
  252. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  253. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  254. package/src/extensibility/slash-commands.ts +5 -0
  255. package/src/gjc-runtime/gc-runtime.ts +37 -7
  256. package/src/gjc-runtime/launch-tmux.ts +30 -15
  257. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  258. package/src/gjc-runtime/launch-worktree.ts +101 -14
  259. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  260. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  261. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  262. package/src/gjc-runtime/session-resolution.ts +38 -6
  263. package/src/gjc-runtime/session-state-lock.ts +59 -14
  264. package/src/gjc-runtime/state-runtime.ts +23 -19
  265. package/src/gjc-runtime/state-writer.ts +109 -5
  266. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  267. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  268. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  269. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  270. package/src/harness-control-plane/finalize.ts +11 -0
  271. package/src/harness-control-plane/receipt-spool.ts +105 -28
  272. package/src/harness-control-plane/storage.ts +124 -6
  273. package/src/hindsight/content.ts +116 -16
  274. package/src/hindsight/mental-models.ts +22 -10
  275. package/src/hindsight/state.ts +7 -4
  276. package/src/hooks/native-skill-hook.ts +17 -12
  277. package/src/internal-urls/artifact-protocol.ts +3 -1
  278. package/src/internal-urls/docs-index.generated.ts +23 -22
  279. package/src/internal-urls/local-protocol.ts +167 -8
  280. package/src/internal-urls/types.ts +2 -0
  281. package/src/lsp/client.ts +2 -1
  282. package/src/lsp/clients/biome-client.ts +2 -0
  283. package/src/lsp/clients/swiftlint-client.ts +2 -0
  284. package/src/lsp/index.ts +29 -0
  285. package/src/lsp/workspace-path.ts +23 -0
  286. package/src/main.ts +330 -92
  287. package/src/memories/index.ts +25 -2
  288. package/src/migrate/action-planner.ts +574 -2
  289. package/src/migrate/adapters/claude-code.ts +162 -5
  290. package/src/migrate/adapters/codex.ts +77 -7
  291. package/src/migrate/adapters/index.ts +258 -5
  292. package/src/migrate/adapters/opencode.ts +10 -1
  293. package/src/migrate/executor.ts +222 -7
  294. package/src/migrate/hook-module.ts +193 -0
  295. package/src/migrate/report.ts +63 -2
  296. package/src/migrate/types.ts +84 -4
  297. package/src/modes/DESIGN.md +51 -0
  298. package/src/modes/acp/acp-agent.ts +10 -1
  299. package/src/modes/acp/permission-mode.ts +18 -1
  300. package/src/modes/components/agent-dashboard.ts +20 -4
  301. package/src/modes/components/assistant-message.ts +55 -1
  302. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  303. package/src/modes/components/customization/import-wizard.ts +29 -3
  304. package/src/modes/components/model-selector.ts +157 -103
  305. package/src/modes/components/settings-selector.ts +103 -37
  306. package/src/modes/components/welcome.ts +3 -1
  307. package/src/modes/controllers/command-controller.ts +300 -9
  308. package/src/modes/controllers/event-controller.ts +1 -1
  309. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  310. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  311. package/src/modes/controllers/input-controller.ts +2 -0
  312. package/src/modes/controllers/plan-mode-controller.ts +66 -11
  313. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  314. package/src/modes/controllers/selector-controller.ts +76 -7
  315. package/src/modes/interactive-mode.ts +123 -4
  316. package/src/modes/prompt-suggestion-controller.ts +1 -1
  317. package/src/modes/runtime-init.ts +5 -1
  318. package/src/modes/theme/theme.ts +15 -0
  319. package/src/modes/utils/host-status.ts +21 -10
  320. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  321. package/src/prompts/system/irc-incoming.md +3 -1
  322. package/src/prompts/tools/debug.md +1 -1
  323. package/src/prompts/tools/irc.md +1 -1
  324. package/src/prompts/tools/read.md +1 -1
  325. package/src/prompts/tools/report-issue.md +13 -0
  326. package/src/prompts/tools/task.md +1 -1
  327. package/src/runtime-credential-selector.ts +2 -2
  328. package/src/runtime-mcp/content-limits.ts +7 -0
  329. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  330. package/src/runtime-mcp/manager.ts +40 -3
  331. package/src/runtime-mcp/oauth-flow.ts +32 -6
  332. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  333. package/src/runtime-mcp/project-host-values.ts +12 -8
  334. package/src/runtime-mcp/render.ts +16 -1
  335. package/src/runtime-mcp/tool-bridge.ts +194 -21
  336. package/src/runtime-mcp/transports/stdio.ts +2 -1
  337. package/src/sdk/broker/broker-exit.ts +10 -5
  338. package/src/sdk/broker/broker.ts +484 -85
  339. package/src/sdk/broker/daemon-entry.ts +338 -116
  340. package/src/sdk/broker/doctor-restart.ts +2 -1
  341. package/src/sdk/broker/ensure.ts +1214 -268
  342. package/src/sdk/broker/hop.ts +286 -0
  343. package/src/sdk/broker/lifecycle-ledger.ts +38 -11
  344. package/src/sdk/broker/lifecycle.ts +102 -6
  345. package/src/sdk/broker/lock-host-identity.ts +29 -0
  346. package/src/sdk/broker/session-index.ts +91 -16
  347. package/src/sdk/broker/transport.ts +17 -1
  348. package/src/sdk/bus/chat-command-policy.ts +16 -3
  349. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  350. package/src/sdk/bus/index.ts +460 -35
  351. package/src/sdk/bus/reconciliation-store.ts +201 -8
  352. package/src/sdk/bus/session-control.ts +2 -0
  353. package/src/sdk/bus/slack-daemon.ts +105 -9
  354. package/src/sdk/bus/telegram-daemon-contract.ts +9 -1
  355. package/src/sdk/bus/telegram-daemon.ts +10 -2
  356. package/src/sdk/bus/threaded-render.ts +20 -1
  357. package/src/sdk/cli/session-cli.ts +23 -6
  358. package/src/sdk/host/control/dispatch.ts +13 -1
  359. package/src/sdk/host/control/operations.ts +10 -1
  360. package/src/sdk/host/events.ts +67 -4
  361. package/src/sdk/host/host.ts +8 -2
  362. package/src/sdk/host/prompt-image-upload.ts +438 -0
  363. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  364. package/src/sdk/host/session-runtime.test.ts +1737 -129
  365. package/src/sdk/host/session-runtime.ts +684 -83
  366. package/src/sdk/host/surface-policy.ts +4 -0
  367. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  368. package/src/sdk/host/websocket-transport.ts +19 -7
  369. package/src/sdk/lifecycle/service.ts +15 -1
  370. package/src/sdk/prompt-deadline-flush.ts +78 -5
  371. package/src/sdk/prompt-deadline-manager.ts +68 -14
  372. package/src/sdk/prompt-status.ts +6 -4
  373. package/src/sdk/protocol/adapter-validation.ts +81 -12
  374. package/src/sdk/protocol/operation-inventory.generated.json +261 -2
  375. package/src/sdk/protocol/operation-registry.ts +33 -3
  376. package/src/sdk/reconciliation-extensions.ts +1 -0
  377. package/src/sdk/session-list.ts +1 -0
  378. package/src/sdk/session.ts +237 -97
  379. package/src/session/agent-session.ts +2726 -479
  380. package/src/session/history-storage.ts +182 -12
  381. package/src/session/internal/managed-session-scope.ts +173 -57
  382. package/src/session/internal/managed-session-storage.ts +85 -12
  383. package/src/session/internal/native-publish-outcome.ts +5 -1
  384. package/src/session/messages.ts +47 -6
  385. package/src/session/session-manager.ts +326 -19
  386. package/src/session/startup-auth-config.ts +116 -8
  387. package/src/session/streaming-output.ts +262 -4
  388. package/src/session/terminal-abort.ts +14 -2
  389. package/src/session-import/redact.ts +53 -2
  390. package/src/session-import/service.ts +1 -0
  391. package/src/skill-state/workflow-mutation-guard.ts +2145 -206
  392. package/src/slash-commands/builtin-registry.ts +21 -6
  393. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  394. package/src/slash-commands/helpers/mcp.ts +11 -3
  395. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  396. package/src/ssh/ssh-executor.ts +9 -2
  397. package/src/stt/recorder.ts +26 -9
  398. package/src/system-prompt.ts +61 -16
  399. package/src/task/executor.ts +115 -20
  400. package/src/task/index.ts +389 -175
  401. package/src/task/role-body.ts +84 -0
  402. package/src/task/token-log-mode.test.ts +162 -0
  403. package/src/task/token-log.ts +91 -2
  404. package/src/tools/archive-reader.ts +650 -37
  405. package/src/tools/ask.ts +1 -0
  406. package/src/tools/ast-edit-write-path.ts +64 -0
  407. package/src/tools/ast-edit.ts +18 -1
  408. package/src/tools/ast-grep.ts +7 -2
  409. package/src/tools/atomic-file-write.ts +46 -15
  410. package/src/tools/auto-generated-guard.ts +15 -3
  411. package/src/tools/awaiting-input.ts +114 -0
  412. package/src/tools/bash-allowed-prefixes.ts +214 -6
  413. package/src/tools/bash-interactive.ts +14 -2
  414. package/src/tools/bash-skill-urls.ts +58 -5
  415. package/src/tools/bash.ts +113 -26
  416. package/src/tools/browser/tab-worker.ts +39 -13
  417. package/src/tools/browser.ts +17 -7
  418. package/src/tools/cron.ts +10 -1
  419. package/src/tools/debug.ts +16 -8
  420. package/src/tools/descriptors.test.ts +44 -0
  421. package/src/tools/descriptors.ts +50 -6
  422. package/src/tools/eval.ts +15 -0
  423. package/src/tools/fetch.ts +93 -4
  424. package/src/tools/gh.ts +27 -12
  425. package/src/tools/image-gen.ts +20 -6
  426. package/src/tools/index.ts +2 -0
  427. package/src/tools/irc.ts +5 -3
  428. package/src/tools/monitor.ts +9 -1
  429. package/src/tools/output-meta.ts +74 -5
  430. package/src/tools/python.ts +1 -1
  431. package/src/tools/read.ts +26 -37
  432. package/src/tools/recipe/runner.ts +23 -12
  433. package/src/tools/render-utils.ts +2 -2
  434. package/src/tools/report-issue.ts +184 -0
  435. package/src/tools/search.ts +3 -2
  436. package/src/tools/ssh.ts +274 -8
  437. package/src/tools/subagent-render.ts +114 -12
  438. package/src/tools/subagent.ts +118 -5
  439. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  440. package/src/tools/todo-write.ts +41 -3
  441. package/src/tools/tool-catalog.generated.ts +32 -4
  442. package/src/tools/ultragoal-ask-guard.ts +13 -1
  443. package/src/tools/write.ts +42 -3
  444. package/src/tui/tree-list.ts +15 -1
  445. package/src/utils/commit-message-generator.ts +12 -4
  446. package/src/utils/external-editor.ts +21 -7
  447. package/src/utils/file-mentions.ts +75 -7
  448. package/src/utils/git.ts +38 -7
  449. package/src/utils/image-limits.ts +8 -0
  450. package/src/utils/image-loading.ts +1 -1
  451. package/src/utils/open.ts +73 -4
  452. package/src/utils/pasted-image-loading.ts +9 -8
  453. package/src/utils/prompt-suggestion.ts +5 -4
  454. package/src/utils/shell-snapshot.ts +9 -1
  455. package/src/utils/title-generator.ts +72 -22
  456. package/src/web/scrapers/index.ts +3 -1
  457. package/src/web/scrapers/types.ts +8 -0
  458. package/src/web/scrapers/youtube.ts +3 -2
  459. package/src/web/search/index.ts +2 -1
  460. package/src/web/search/providers/codex.ts +29 -32
  461. package/src/web/search/providers/insane.ts +91 -28
  462. package/src/web/search/providers/searxng.ts +62 -10
@@ -13,6 +13,7 @@ import type {
13
13
  import {
14
14
  exactRestore,
15
15
  exactUnlinkDirect,
16
+ exactUnlinkOpenFile,
16
17
  openRecoveryFsRoot,
17
18
  renameDirectoryNoReplacePathAsync,
18
19
  renameNoReplacePathAsync,
@@ -24,6 +25,8 @@ import { nativeProcessBindings } from "@gajae-code/utils/native-process";
24
25
 
25
26
  export interface FileLockOptions {
26
27
  staleMs?: number;
28
+ /** Age after which an ownerless removal transition may be adopted; defaults to `staleMs`. */
29
+ orphanTransitionAgeMs?: number;
27
30
  retries?: number;
28
31
  retryDelayMs?: number;
29
32
  signal?: AbortSignal;
@@ -36,6 +39,7 @@ export interface FileLockOptions {
36
39
  }
37
40
 
38
41
  export type FileLockAcquireReason = "acquire_timeout" | "orphan_transition";
42
+ export type FileLockPublicationFailureCode = "access_denied" | "sharing_violation";
39
43
 
40
44
  /**
41
45
  * Why the acquire path could not retire a lock directory whose owner it had already
@@ -66,6 +70,9 @@ export class FileLockAcquireError extends Error {
66
70
  readonly reason: FileLockAcquireReason = "acquire_timeout",
67
71
  readonly orphanPath?: string,
68
72
  readonly removalFailure?: FileLockStaleRemovalFailure,
73
+ readonly publicationFailureCode?: FileLockPublicationFailureCode,
74
+ /** True only when exhaustion proved a same-host owner process is live. */
75
+ readonly holderIsLive = false,
69
76
  ) {
70
77
  const detail =
71
78
  reason === "orphan_transition" && orphanPath ? `blocked by retained removal transition ${orphanPath}` : holder;
@@ -77,38 +84,93 @@ export class FileLockAcquireError extends Error {
77
84
  const cleanupDetail = removalFailure?.manualCleanupCommand
78
85
  ? `; manual cleanup is appropriate only after independently verifying this exact directory still belongs to the dead owner and no successor has taken it: ${removalFailure.manualCleanupCommand}`
79
86
  : "";
87
+ const publicationDetail = publicationFailureCode
88
+ ? `; last lock publication failed with ${publicationFailureCode}`
89
+ : "";
90
+ const contentionGuidance =
91
+ publicationFailureCode === "access_denied" && !holderIsLive
92
+ ? ""
93
+ : "; a live owner is never displaced — if this is an SDK broker (gjc sdk session list), it must finish or be stopped before retrying";
80
94
  super(
81
- `Failed to acquire lock for ${filePath} after ${attempts} attempts: ${detail}${removalDetail} (${lockPath}); ` +
82
- `a live owner is never displaced — if this is an SDK broker (gjc sdk session list), it must finish or be stopped before retrying${cleanupDetail}`,
95
+ `Failed to acquire lock for ${filePath} after ${attempts} attempts: ${detail}${removalDetail}${publicationDetail} (${lockPath})${contentionGuidance}${cleanupDetail}`,
83
96
  );
84
97
  this.code = reason;
85
98
  this.name = "FileLockAcquireError";
86
99
  }
87
100
  }
88
101
 
102
+ export class FileLockStagingCleanupError extends Error {
103
+ readonly code: string | undefined;
104
+
105
+ constructor(
106
+ readonly lockPath: string,
107
+ readonly stagingPath: string,
108
+ cause: unknown,
109
+ ) {
110
+ const code =
111
+ typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string"
112
+ ? cause.code
113
+ : undefined;
114
+ const detail = cause instanceof Error ? cause.message : String(cause);
115
+ super(`Failed to clean staged file-lock publication at ${stagingPath}${code ? ` (${code})` : ""}: ${detail}`);
116
+ this.code = code;
117
+ this.name = "FileLockStagingCleanupError";
118
+ }
119
+ }
120
+
89
121
  export function isFileLockAcquireTimeout(error: unknown): error is FileLockAcquireError {
90
- return error instanceof FileLockAcquireError && error.code === "acquire_timeout";
122
+ return (
123
+ error instanceof FileLockAcquireError &&
124
+ error.code === "acquire_timeout" &&
125
+ (error.publicationFailureCode !== "access_denied" || error.holderIsLive)
126
+ );
91
127
  }
92
128
 
93
129
  const DEFAULT_OPTIONS: Required<
94
- Omit<FileLockOptions, "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired" | "onContended">
130
+ Omit<
131
+ FileLockOptions,
132
+ "orphanTransitionAgeMs" | "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired" | "onContended"
133
+ >
95
134
  > = {
96
135
  staleMs: 10_000,
97
136
  retries: 50,
98
137
  retryDelayMs: 100,
99
138
  };
100
139
 
140
+ type FileLockStagingFileIdentity = { dev: string; ino: string };
141
+ type FileLockStagingRootIdentity = {
142
+ rootDev: string;
143
+ rootIno: string;
144
+ treeSnapshot?: NativeDirectoryTreeSnapshot;
145
+ infoFileIdentity?: FileLockStagingFileIdentity;
146
+ };
147
+ type PendingFileLockStagingCleanup = {
148
+ lockPath: string;
149
+ identity?: FileLockStagingRootIdentity;
150
+ };
151
+
152
+ const pendingStagingCleanups = new Map<string, PendingFileLockStagingCleanup>();
153
+
101
154
  /**
102
- * Windows can transiently deny a no-replace publication while another handle still
103
- * has the staged or destination path open without delete sharing. The native result
104
- * is explicitly pre-mutation in this case, so retrying the same source name cannot
105
- * publish twice or clean up a committed namespace change.
155
+ * Windows can transiently deny a no-replace publication while another process holds
156
+ * the staged or destination path open. Retry only validated pre-mutation receipts;
157
+ * persistent access-denied failures remain fatal rather than being reported as contention.
106
158
  */
107
- const PUBLICATION_SHARING_RETRY_ATTEMPTS = 3;
108
- const PUBLICATION_SHARING_RETRY_DELAY_MS = 10;
159
+ const PUBLICATION_TRANSIENT_RETRY_ATTEMPTS = 3;
160
+ const PUBLICATION_TRANSIENT_RETRY_DELAY_MS = 10;
161
+ /** Allow one caller backoff for transient Windows denial without multiplying a persistent ACL failure by the full lock wait. */
162
+ const MAX_ACCESS_DENIED_PUBLICATION_ACQUISITION_ATTEMPTS = 2;
109
163
 
110
- /** Ownerless empty residue must outlive both this grace and the acquire budget. */
164
+ /** Grace period after which an unpublished removal transition becomes reclaimable as orphan. */
111
165
  const REMOVAL_TRANSITION_GRACE_MS = 10_000;
166
+ /**
167
+ * Additional buffer beyond the grace period to ensure a retained removal transition
168
+ * whose owner died is reclaimed promptly, decoupled from acquisition retry budget.
169
+ * This bounds how long a dead owner's transition can block lock acquisition.
170
+ */
171
+ const REMOVAL_TRANSITION_ADOPTION_BUFFER_MS = 5_000;
172
+ export const FILE_LOCK_ORPHAN_TRANSITION_ADOPTION_AGE_MS =
173
+ REMOVAL_TRANSITION_GRACE_MS + REMOVAL_TRANSITION_ADOPTION_BUFFER_MS;
112
174
 
113
175
  /** Release retries cover transient handle denial and a competing exact-removal quarantine cleanup. */
114
176
  export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 20;
@@ -118,6 +180,7 @@ const PROCESS_START_TIME_FORMAT = "utc-v1";
118
180
  type LocalLockState = {
119
181
  owner: FileLockOwnerToken;
120
182
  status: "held" | "release_pending" | "releasing";
183
+ releasePath?: string;
121
184
  releasePromise?: Promise<void>;
122
185
  };
123
186
 
@@ -127,20 +190,385 @@ type LocalLockState = {
127
190
  * created, so a nested contender cannot steal a lock from a still-running holder.
128
191
  */
129
192
  const localLockStates = new Map<string, LocalLockState>();
193
+ const MAX_FILE_LOCK_REMOVAL_RECORD_BYTES = 64 * 1024;
130
194
 
131
195
  type LockInfo = FileLockOwnerToken;
132
196
 
133
197
  export const FileLockTestHooks: {
134
198
  afterParentMkdir?: (lockPath: string) => void | Promise<void>;
199
+ afterStagingInfoWrite?: (stagingPath: string) => void | Promise<void>;
200
+ stagingInfoIdentity?: (
201
+ infoPath: string,
202
+ capture: () => Promise<FileLockStagingFileIdentity>,
203
+ ) => Promise<FileLockStagingFileIdentity>;
204
+ stagingInfoCleanup?: (
205
+ infoPath: string,
206
+ callerFd: number,
207
+ quarantineName: string,
208
+ ) => NativeExactUnlinkResult | Promise<NativeExactUnlinkResult>;
209
+ beforeDetachedLockCleanup?: (detachedPath: string) => boolean | Promise<boolean>;
210
+ afterRemovalOwnerPublish?: (transitionPath: string) => void | Promise<void>;
211
+ afterRemovalTransitionMissing?: (transitionPath: string) => void | Promise<void>;
212
+ afterForeignTransitionMissing?: (transitionPath: string) => void | Promise<void>;
213
+ beforeRemovalRecordUnlink?: (recordPath: string) => boolean | Promise<boolean>;
135
214
  beforeRemovalOwnerPublish?: (transitionPath: string) => void | Promise<void>;
215
+ cleanupPendingStaging?: (stagingPath: string) => Promise<void>;
216
+ stagingRootIdentity?: (stagingPath: string) => FileLockStagingRootIdentity | Promise<FileLockStagingRootIdentity>;
136
217
  nativePublicationBindings?: () => {
137
218
  renameNoReplacePathAsync: typeof renameNoReplacePathAsync;
138
219
  renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
139
220
  };
221
+ stagingIdentityEvidence?: () => {
222
+ processIncarnation: string | null;
223
+ startTime: string | null;
224
+ };
225
+ processIncarnation?: (pid: number) => string | null;
140
226
  nativeQuarantineBindings?: () => NativeFileLockBindings;
141
227
  nativeExactRemovalProbe?: () => boolean | Promise<boolean>;
142
228
  } = {};
143
229
 
230
+ type FileLockAcquisitionRetryBudget = {
231
+ remainingDelays: number;
232
+ retryDelayMs: number;
233
+ signal?: AbortSignal;
234
+ };
235
+
236
+ function throwIfFileLockAcquisitionAborted(signal?: AbortSignal): void {
237
+ if (signal?.aborted) throw signal.reason ?? new Error("File lock acquisition aborted");
238
+ }
239
+
240
+ async function sleepFileLockRetry(delayMs: number, signal?: AbortSignal): Promise<void> {
241
+ if (!signal) {
242
+ await Bun.sleep(delayMs);
243
+ return;
244
+ }
245
+ throwIfFileLockAcquisitionAborted(signal);
246
+ const settled = Promise.withResolvers<void>();
247
+ const onAbort = (): void => {
248
+ settled.reject(signal.reason ?? new Error("File lock acquisition aborted"));
249
+ };
250
+ signal.addEventListener("abort", onAbort, { once: true });
251
+ try {
252
+ if (signal.aborted) onAbort();
253
+ await Promise.race([Bun.sleep(Math.max(0, delayMs)), settled.promise]);
254
+ } finally {
255
+ signal.removeEventListener("abort", onAbort);
256
+ }
257
+ }
258
+
259
+ async function captureFileLockStagingRootIdentity(
260
+ stagingPath: string,
261
+ retryBudget: FileLockAcquisitionRetryBudget,
262
+ ): Promise<FileLockStagingRootIdentity> {
263
+ const capture = FileLockTestHooks.stagingRootIdentity;
264
+ if (capture) return await capture(stagingPath);
265
+ for (;;) {
266
+ try {
267
+ const root = await fs.lstat(stagingPath, { bigint: true });
268
+ if (!root.isDirectory() || root.isSymbolicLink())
269
+ throw Object.assign(new Error("Staged file-lock root is not a directory."), { code: "identity_mismatch" });
270
+ return {
271
+ rootDev: canonicalLockDeviceId(root.dev).toString(),
272
+ rootIno: canonicalLockFileId(root.ino).toString(),
273
+ };
274
+ } catch (error) {
275
+ if (!isTransientReleaseError(error) || retryBudget.remainingDelays === 0) throw error;
276
+ retryBudget.remainingDelays--;
277
+ await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
278
+ }
279
+ }
280
+ }
281
+
282
+ async function captureFileLockStagingTreeSnapshot(
283
+ stagingPath: string,
284
+ retryBudget: FileLockAcquisitionRetryBudget,
285
+ ): Promise<NativeDirectoryTreeSnapshot> {
286
+ for (;;) {
287
+ const captured = nativeFileLockBindings().snapshotDirectoryTree(stagingPath);
288
+ if (captured.ok && captured.snapshot) return captured.snapshot;
289
+ const code = captured.code ?? "snapshot_unavailable";
290
+ if (
291
+ code !== "access_denied" &&
292
+ code !== "io_error" &&
293
+ code !== "permission_denied" &&
294
+ code !== "sharing_violation"
295
+ ) {
296
+ throw Object.assign(new Error(`Failed to snapshot staged file lock: ${code}.`), { code });
297
+ }
298
+ if (retryBudget.remainingDelays === 0)
299
+ throw Object.assign(new Error(`Failed to snapshot staged file lock: ${code}.`), { code });
300
+ retryBudget.remainingDelays--;
301
+ await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
302
+ }
303
+ }
304
+
305
+ function bindFileLockStagingTreeSnapshot(
306
+ stagingPath: string,
307
+ identity: FileLockStagingRootIdentity,
308
+ snapshot: NativeDirectoryTreeSnapshot,
309
+ ): FileLockStagingRootIdentity {
310
+ if (snapshot.rootDev !== identity.rootDev || snapshot.rootIno !== identity.rootIno)
311
+ throw Object.assign(new Error(`Staged file-lock root identity changed while snapshotting ${stagingPath}.`), {
312
+ code: "identity_mismatch",
313
+ });
314
+ return { ...identity, treeSnapshot: snapshot };
315
+ }
316
+
317
+ function matchesExpectedFileLockStagingTree(
318
+ snapshot: NativeDirectoryTreeSnapshot,
319
+ identity: FileLockStagingRootIdentity,
320
+ ): boolean {
321
+ const root = snapshot.entries.find(entry => entry.relativePath === "");
322
+ if (
323
+ snapshot.rootDev !== identity.rootDev ||
324
+ snapshot.rootIno !== identity.rootIno ||
325
+ root?.kind !== "directory" ||
326
+ root.dev !== identity.rootDev ||
327
+ root.ino !== identity.rootIno
328
+ )
329
+ return false;
330
+ if (snapshot.entries.length === 1) return true;
331
+ if (snapshot.entries.length !== 2 || !identity.infoFileIdentity) return false;
332
+ const info = snapshot.entries.find(entry => entry.relativePath === "info");
333
+ return (
334
+ info?.kind === "file" &&
335
+ info.dev === identity.infoFileIdentity.dev &&
336
+ info.ino === identity.infoFileIdentity.ino &&
337
+ info.nlink === "1"
338
+ );
339
+ }
340
+
341
+ async function captureFileLockStagingCleanupIdentity(
342
+ stagingPath: string,
343
+ identity: FileLockStagingRootIdentity,
344
+ retryBudget: FileLockAcquisitionRetryBudget,
345
+ ): Promise<FileLockStagingRootIdentity> {
346
+ const snapshot = await captureFileLockStagingTreeSnapshot(stagingPath, retryBudget);
347
+ // A replay can authorize only the empty root or that root plus this attempt's exact info inode.
348
+ if (!matchesExpectedFileLockStagingTree(snapshot, identity))
349
+ throw Object.assign(
350
+ new Error("Staged file-lock contents changed before cleanup authority was captured; refusing removal."),
351
+ { code: "identity_unavailable" },
352
+ );
353
+ return bindFileLockStagingTreeSnapshot(stagingPath, identity, snapshot);
354
+ }
355
+
356
+ async function hasPendingStagingRootIdentity(
357
+ stagingPath: string,
358
+ identity: FileLockStagingRootIdentity,
359
+ ): Promise<boolean> {
360
+ let root: BigIntStats;
361
+ try {
362
+ root = await fs.lstat(stagingPath, { bigint: true });
363
+ } catch (error) {
364
+ if (isEnoent(error)) return false;
365
+ throw error;
366
+ }
367
+ if (
368
+ !root.isDirectory() ||
369
+ root.isSymbolicLink() ||
370
+ canonicalLockDeviceId(root.dev).toString() !== identity.rootDev ||
371
+ canonicalLockFileId(root.ino).toString() !== identity.rootIno
372
+ )
373
+ throw Object.assign(new Error("Staged file-lock root identity changed; refusing removal."), {
374
+ code: "identity_mismatch",
375
+ });
376
+ return true;
377
+ }
378
+
379
+ async function removePendingStaging(stagingPath: string, identity: FileLockStagingRootIdentity): Promise<void> {
380
+ const cleanup = FileLockTestHooks.cleanupPendingStaging;
381
+ if (cleanup) {
382
+ if (!(await hasPendingStagingRootIdentity(stagingPath, identity))) return;
383
+ await cleanup(stagingPath);
384
+ return;
385
+ }
386
+ if (!identity.infoFileIdentity && process.platform !== "win32") {
387
+ if (!(await hasPendingStagingRootIdentity(stagingPath, identity))) return;
388
+ const captured = nativeFileLockBindings().snapshotDirectoryTree(stagingPath);
389
+ const root = captured.snapshot?.entries.find(entry => entry.relativePath === "");
390
+ if (
391
+ !captured.ok ||
392
+ !captured.snapshot ||
393
+ captured.snapshot.entries.length !== 1 ||
394
+ captured.snapshot.rootDev !== identity.rootDev ||
395
+ captured.snapshot.rootIno !== identity.rootIno ||
396
+ root?.kind !== "directory" ||
397
+ root.dev !== identity.rootDev ||
398
+ root.ino !== identity.rootIno
399
+ )
400
+ throw Object.assign(
401
+ new Error("Staged file-lock root is not empty and its info identity is unavailable; refusing removal."),
402
+ { code: captured.code ?? "identity_unavailable" },
403
+ );
404
+ // Unlike recursive removal, rmdir cannot consume an entry that appears after
405
+ // the empty-tree proof; it fails with ENOTEMPTY instead.
406
+ await fs.rmdir(stagingPath);
407
+ return;
408
+ }
409
+ if (process.platform === "win32") {
410
+ if (!identity.treeSnapshot && !(await hasPendingStagingRootIdentity(stagingPath, identity))) return;
411
+ if (!identity.treeSnapshot)
412
+ throw Object.assign(new Error("Staged file-lock tree identity is unavailable; refusing removal."), {
413
+ code: "identity_unavailable",
414
+ });
415
+ // A pathname check followed by fs.rm can erase a replacement installed in
416
+ // between; Windows cleanup must revalidate the captured tree atomically.
417
+ const removal = nativeFileLockBindings().exactRemoveDirectoryTree(stagingPath, identity.treeSnapshot);
418
+ if (!removal.ok && removal.code !== "not_found") {
419
+ const detachedPath = validateDetachedStagingCleanupPath(stagingPath, removal.detachedPath);
420
+ throw Object.assign(new Error(`Failed to remove staged file-lock tree: ${removal.code ?? "unknown"}.`), {
421
+ code: removal.code ?? "cleanup_failed",
422
+ ...(detachedPath === undefined ? {} : { detachedPath }),
423
+ });
424
+ }
425
+ return;
426
+ }
427
+ if (await hasPendingStagingRootIdentity(stagingPath, identity))
428
+ await fs.rm(stagingPath, { recursive: true, force: true });
429
+ }
430
+
431
+ function sameFileLockPath(left: string, right: string): boolean {
432
+ const resolvedLeft = path.resolve(left);
433
+ const resolvedRight = path.resolve(right);
434
+ return process.platform === "win32"
435
+ ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
436
+ : resolvedLeft === resolvedRight;
437
+ }
438
+
439
+ function validateDetachedStagingCleanupPath(stagingPath: string, detachedPath: string | undefined): string | undefined {
440
+ if (detachedPath === undefined) return undefined;
441
+ if (!sameFileLockPath(detachedPath, stagingPath) && !sameFileLockPath(detachedPath, `${stagingPath}.removing`))
442
+ throw Object.assign(new Error("Native staging cleanup returned an unexpected detached path."), {
443
+ code: "identity_mismatch",
444
+ });
445
+ return detachedPath;
446
+ }
447
+
448
+ function detachedStagingCleanupPath(stagingPath: string, error: unknown): string {
449
+ if (typeof error !== "object" || error === null || !("detachedPath" in error)) return stagingPath;
450
+ const detachedPath = (error as { detachedPath?: unknown }).detachedPath;
451
+ try {
452
+ return (
453
+ validateDetachedStagingCleanupPath(stagingPath, typeof detachedPath === "string" ? detachedPath : undefined) ??
454
+ stagingPath
455
+ );
456
+ } catch {
457
+ return stagingPath;
458
+ }
459
+ }
460
+
461
+ function retainPendingStagingCleanup(
462
+ lockPath: string,
463
+ stagingPath: string,
464
+ identity: FileLockStagingRootIdentity | undefined,
465
+ error: unknown,
466
+ ): void {
467
+ pendingStagingCleanups.set(stagingPath, { lockPath, identity });
468
+ logger.debug("Retained file-lock staging cleanup", { stagingPath, error: String(error) });
469
+ }
470
+
471
+ async function abortStagingCleanup(
472
+ lockPath: string,
473
+ stagingPath: string,
474
+ identity: FileLockStagingRootIdentity,
475
+ signal: AbortSignal,
476
+ ): Promise<never> {
477
+ let cleanupPath = stagingPath;
478
+ try {
479
+ await removePendingStaging(cleanupPath, identity);
480
+ pendingStagingCleanups.delete(cleanupPath);
481
+ } catch (error) {
482
+ cleanupPath = detachedStagingCleanupPath(cleanupPath, error);
483
+ if (cleanupPath !== stagingPath) pendingStagingCleanups.delete(stagingPath);
484
+ // The stage is still owned by this acquisition. Preserve an in-process retry
485
+ // until another acquisition can clean it; after process exit the orphan reaper
486
+ // can safely reclaim it once this publisher is no longer live.
487
+ retainPendingStagingCleanup(lockPath, cleanupPath, identity, error);
488
+ }
489
+ throw signal.reason ?? new Error("File lock acquisition aborted");
490
+ }
491
+
492
+ async function retryPendingStagingCleanups(
493
+ lockPath: string,
494
+ retryBudget: FileLockAcquisitionRetryBudget,
495
+ ): Promise<void> {
496
+ const lockKey = await localLockKey(lockPath);
497
+ for (const [stagingPath, pending] of pendingStagingCleanups) {
498
+ if ((await localLockKey(pending.lockPath)) !== lockKey) continue;
499
+ if (!pending.identity) {
500
+ try {
501
+ await fs.lstat(stagingPath);
502
+ } catch (error) {
503
+ if (isEnoent(error)) {
504
+ pendingStagingCleanups.delete(stagingPath);
505
+ continue;
506
+ }
507
+ if (retryBudget.signal?.aborted) throw retryBudget.signal.reason ?? error;
508
+ throw new FileLockStagingCleanupError(lockPath, stagingPath, error);
509
+ }
510
+ const error = Object.assign(new Error("Retained staging cleanup has no captured identity."), {
511
+ code: "identity_unavailable",
512
+ });
513
+ throw new FileLockStagingCleanupError(lockPath, stagingPath, error);
514
+ }
515
+ let identity = pending.identity;
516
+ if (process.platform === "win32" && !identity.treeSnapshot) {
517
+ try {
518
+ if (!(await hasPendingStagingRootIdentity(stagingPath, identity))) {
519
+ pendingStagingCleanups.delete(stagingPath);
520
+ continue;
521
+ }
522
+ identity = await captureFileLockStagingCleanupIdentity(stagingPath, identity, retryBudget);
523
+ pendingStagingCleanups.set(stagingPath, { ...pending, identity });
524
+ } catch (error) {
525
+ if (retryBudget.signal?.aborted) throw retryBudget.signal.reason ?? error;
526
+ throw new FileLockStagingCleanupError(lockPath, stagingPath, error);
527
+ }
528
+ }
529
+ await cleanupPendingStagingOrThrow(lockPath, stagingPath, identity, retryBudget);
530
+ }
531
+ }
532
+
533
+ async function cleanupPendingStagingOrThrow(
534
+ lockPath: string,
535
+ stagingPath: string,
536
+ identity: FileLockStagingRootIdentity,
537
+ retryBudget: FileLockAcquisitionRetryBudget,
538
+ ): Promise<void> {
539
+ let cleanupPath = stagingPath;
540
+ for (;;) {
541
+ if (retryBudget.signal?.aborted) await abortStagingCleanup(lockPath, cleanupPath, identity, retryBudget.signal);
542
+ try {
543
+ await removePendingStaging(cleanupPath, identity);
544
+ pendingStagingCleanups.delete(cleanupPath);
545
+ return;
546
+ } catch (error) {
547
+ if (retryBudget.signal?.aborted)
548
+ await abortStagingCleanup(lockPath, cleanupPath, identity, retryBudget.signal);
549
+ const detachedPath = detachedStagingCleanupPath(cleanupPath, error);
550
+ if (detachedPath !== cleanupPath) {
551
+ pendingStagingCleanups.delete(cleanupPath);
552
+ cleanupPath = detachedPath;
553
+ }
554
+ if (!isTransientReleaseError(error) || retryBudget.remainingDelays <= 0) {
555
+ retainPendingStagingCleanup(lockPath, cleanupPath, identity, error);
556
+ throw new FileLockStagingCleanupError(lockPath, cleanupPath, error);
557
+ }
558
+ // An open child can deny both publication and removal on Windows. Keep the
559
+ // same staging identity and share the outer acquisition delay budget.
560
+ retainPendingStagingCleanup(lockPath, cleanupPath, identity, error);
561
+ retryBudget.remainingDelays--;
562
+ try {
563
+ await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
564
+ } catch (abortError) {
565
+ if (!retryBudget.signal?.aborted) throw abortError;
566
+ await abortStagingCleanup(lockPath, cleanupPath, identity, retryBudget.signal);
567
+ }
568
+ }
569
+ }
570
+ }
571
+
144
572
  /**
145
573
  * Returns the OS-provided process start timestamp for PID-reuse detection.
146
574
  * `ps` is available on the supported Unix hosts (macOS and Linux), unlike
@@ -182,7 +610,7 @@ function currentProcessStartTime(): string {
182
610
  return ownProcessStartTime;
183
611
  }
184
612
 
185
- function processIncarnation(pid: number): string | null {
613
+ function nativeProcessIncarnation(pid: number): string | null {
186
614
  try {
187
615
  const incarnation = nativeProcessBindings().Process.fromPid(pid)?.incarnation;
188
616
  return typeof incarnation === "string" && incarnation.length > 0 ? incarnation : null;
@@ -191,8 +619,17 @@ function processIncarnation(pid: number): string | null {
191
619
  }
192
620
  }
193
621
 
622
+ function processIncarnation(pid: number): string | null {
623
+ try {
624
+ const testHook = FileLockTestHooks.processIncarnation;
625
+ return testHook ? testHook(pid) : nativeProcessIncarnation(pid);
626
+ } catch {
627
+ return null;
628
+ }
629
+ }
630
+
194
631
  function currentProcessIncarnation(): string | null {
195
- if (ownProcessIncarnation === undefined) ownProcessIncarnation = processIncarnation(process.pid);
632
+ if (ownProcessIncarnation === undefined) ownProcessIncarnation = nativeProcessIncarnation(process.pid);
196
633
  return ownProcessIncarnation;
197
634
  }
198
635
 
@@ -222,6 +659,17 @@ function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, st
222
659
  return owner.start_time_format !== PROCESS_START_TIME_FORMAT;
223
660
  }
224
661
 
662
+ function ownerIdentityIsProven(owner: FileLockOwnerToken): boolean {
663
+ if (owner.process_incarnation) {
664
+ const currentIncarnation = processIncarnation(owner.pid);
665
+ if (currentIncarnation !== null) return currentIncarnation === owner.process_incarnation;
666
+ }
667
+ if (owner.start_time_format !== PROCESS_START_TIME_FORMAT || !owner.start_time || owner.start_time === "unknown")
668
+ return false;
669
+ const currentStartTime = cachedProcessStartTime(owner);
670
+ return currentStartTime !== null && currentStartTime === owner.start_time;
671
+ }
672
+
225
673
  function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo {
226
674
  const incarnation = currentProcessIncarnation();
227
675
  return {
@@ -235,13 +683,70 @@ function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo
235
683
  };
236
684
  }
237
685
 
238
- function writeLockInfo(lockPath: string, info: LockInfo): Promise<LockInfo> {
686
+ async function writeLockInfo(
687
+ lockPath: string,
688
+ info: LockInfo,
689
+ retryBudget: FileLockAcquisitionRetryBudget,
690
+ onFileCreated: (identity: FileLockStagingFileIdentity) => void,
691
+ ): Promise<LockInfo> {
239
692
  // Owner metadata must stay readable by its own process under a restrictive
240
693
  // umask: release re-reads this record to authorize removal, and an info file
241
694
  // born mode 000 under umask 0777 would wedge the lock at first release.
242
- return Bun.write(`${lockPath}/info`, JSON.stringify(info), { mode: 0o600 })
243
- .then(() => fs.chmod(`${lockPath}/info`, 0o600))
244
- .then(() => info);
695
+ // Keep the created file's identity through setup so an early cleanup can reject
696
+ // a same-name replacement instead of treating a later tree snapshot as authority.
697
+ const handle = await fs.open(`${lockPath}/info`, "wx", 0o600);
698
+ try {
699
+ let identity: FileLockStagingFileIdentity;
700
+ try {
701
+ for (;;) {
702
+ try {
703
+ const captureIdentity = async (): Promise<FileLockStagingFileIdentity> => {
704
+ const stats = await handle.stat({ bigint: true });
705
+ return {
706
+ dev: canonicalLockDeviceId(stats.dev).toString(),
707
+ ino: canonicalLockFileId(stats.ino).toString(),
708
+ };
709
+ };
710
+ const testCaptureIdentity = FileLockTestHooks.stagingInfoIdentity;
711
+ identity = testCaptureIdentity
712
+ ? await testCaptureIdentity(`${lockPath}/info`, captureIdentity)
713
+ : await captureIdentity();
714
+ break;
715
+ } catch (error) {
716
+ if (!isTransientReleaseError(error) || retryBudget.remainingDelays === 0) throw error;
717
+ retryBudget.remainingDelays--;
718
+ await sleepFileLockRetry(retryBudget.retryDelayMs, retryBudget.signal);
719
+ }
720
+ }
721
+ } catch (error) {
722
+ // Without the handle's inode identity, retaining this empty info entry would
723
+ // make both immediate cleanup and the later orphan reaper fail closed.
724
+ try {
725
+ const infoPath = `${lockPath}/info`;
726
+ const quarantineName = `${path.basename(infoPath)}.cleanup.${crypto.randomUUID()}`;
727
+ const cleanup = await (FileLockTestHooks.stagingInfoCleanup
728
+ ? FileLockTestHooks.stagingInfoCleanup(infoPath, handle.fd, quarantineName)
729
+ : exactUnlinkOpenFile(infoPath, handle.fd, quarantineName));
730
+ if (!cleanup.ok && cleanup.code !== "not_found")
731
+ throw Object.assign(
732
+ new Error(`Identity-bound cleanup of staged file-lock info failed: ${cleanup.code ?? "unknown"}.`),
733
+ { code: cleanup.code ?? "identity_unavailable" },
734
+ );
735
+ } catch (cleanupError) {
736
+ throw new AggregateError(
737
+ [error, cleanupError],
738
+ `Failed to remove staged file-lock info after identity capture failed at ${lockPath}.`,
739
+ );
740
+ }
741
+ throw error;
742
+ }
743
+ onFileCreated(identity);
744
+ await handle.writeFile(JSON.stringify(info));
745
+ await handle.chmod(0o600);
746
+ return info;
747
+ } finally {
748
+ await handle.close();
749
+ }
245
750
  }
246
751
 
247
752
  type LockInfoFileState = {
@@ -563,10 +1068,24 @@ async function removeDetachedLockQuarantineOnDisk(
563
1068
  ) {
564
1069
  return false;
565
1070
  }
1071
+ if ((await FileLockTestHooks.beforeDetachedLockCleanup?.(detachedPath)) === false) return false;
566
1072
  await fs.rm(detachedPath, { recursive: true, force: true });
567
1073
  return true;
568
1074
  }
569
1075
 
1076
+ function markLocalRemovalResumable(transitionPath: string, rootDev: string, rootIno: string): void {
1077
+ const recordPath = removalRecordPath(transitionPath);
1078
+ const local = localRemovalRecords.get(recordPath);
1079
+ if (!local) return;
1080
+ try {
1081
+ const record = JSON.parse(local.bytes) as Partial<RemovalTransitionOwnerRecord>;
1082
+ if (record.rootDev === rootDev && record.rootIno === rootIno)
1083
+ localRemovalRecords.set(recordPath, { ...local, resumable: true });
1084
+ } catch {
1085
+ // A malformed local receipt cannot authorize resuming a detached tree.
1086
+ }
1087
+ }
1088
+
570
1089
  async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boolean> {
571
1090
  const pending = pendingDetachedLockCleanups.get(owner);
572
1091
  if (!pending) return false;
@@ -585,7 +1104,7 @@ async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boo
585
1104
  // finish the quarantine its own fallback created, so finish on disk instead.
586
1105
  if (process.platform === "win32" && (await isNativeExactRemovalUsable())) {
587
1106
  const removal = nativeFileLockBindings().exactRemoveDirectoryTree(pending.path, pending.snapshot);
588
- if (removal.code === "cleanup_pending") return false;
1107
+ if (removal.code === "cleanup_pending" || (!removal.ok && isTransientReleaseError(removal))) return false;
589
1108
  if (!removal.ok && removal.code !== "not_found")
590
1109
  throw new Error(`Failed to finish detached file lock cleanup: ${removal.code ?? "unknown"}.`);
591
1110
  } else {
@@ -604,7 +1123,22 @@ function getLockPath(filePath: string): string {
604
1123
  }
605
1124
 
606
1125
  function fileLockRemovalTransitionPath(lockPath: string): string {
607
- return `${lockPath}.removing`;
1126
+ return derivedFileLockPath(lockPath, ".removing");
1127
+ }
1128
+
1129
+ /** Keep derived siblings bounded without changing the canonical lock identity. */
1130
+ function derivedFileLockPath(sourcePath: string, suffix: string): string {
1131
+ const name = path.basename(sourcePath);
1132
+ const derived = `${name}${suffix}`;
1133
+ const bounded =
1134
+ Buffer.byteLength(derived, "utf8") <= 255
1135
+ ? derived
1136
+ : `.gjc-lock-${crypto.createHash("sha256").update(name).digest("hex")}${suffix}`;
1137
+ return path.join(path.dirname(sourcePath), bounded);
1138
+ }
1139
+
1140
+ function removalRecordPath(transitionPath: string): string {
1141
+ return derivedFileLockPath(transitionPath, ".owner");
608
1142
  }
609
1143
 
610
1144
  type RemovalTransitionOwnerRecord = {
@@ -613,7 +1147,9 @@ type RemovalTransitionOwnerRecord = {
613
1147
  rootIno: string;
614
1148
  };
615
1149
 
616
- const localRemovalRecords = new Map<string, { bytes: string; resumable: boolean }>();
1150
+ type LocalRemovalRecord = { bytes: string; resumable: boolean; inFlight: boolean };
1151
+
1152
+ const localRemovalRecords = new Map<string, LocalRemovalRecord>();
617
1153
 
618
1154
  function sameRemovalRecordBytes(left: string, right: string): boolean {
619
1155
  try {
@@ -634,19 +1170,50 @@ function sameRemovalRecordBytes(left: string, right: string): boolean {
634
1170
  }
635
1171
 
636
1172
  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));
1173
+ const local = localRemovalRecords.get(removalRecordPath(transitionPath));
1174
+ return local?.resumable === true && !local.inFlight && sameRemovalRecordBytes(local.bytes, JSON.stringify(record));
1175
+ }
1176
+
1177
+ function isLocalRemovalInFlight(transitionPath: string, record: RemovalTransitionOwnerRecord): boolean {
1178
+ const local = localRemovalRecords.get(removalRecordPath(transitionPath));
1179
+ return local?.inFlight === true && sameRemovalRecordBytes(local.bytes, JSON.stringify(record));
1180
+ }
1181
+
1182
+ function markLocalRemovalCompleted(transitionPath: string, expectedBytes?: string): void {
1183
+ const recordPath = removalRecordPath(transitionPath);
1184
+ const local = localRemovalRecords.get(recordPath);
1185
+ if (!local?.inFlight || (expectedBytes !== undefined && !sameRemovalRecordBytes(local.bytes, expectedBytes))) return;
1186
+ localRemovalRecords.set(recordPath, { ...local, inFlight: false });
1187
+ }
1188
+
1189
+ async function readBoundedRemovalRecordBytes(recordPath: string): Promise<Buffer | null> {
1190
+ const bytes = Buffer.from(
1191
+ await Bun.file(recordPath)
1192
+ .slice(0, MAX_FILE_LOCK_REMOVAL_RECORD_BYTES + 1)
1193
+ .arrayBuffer(),
1194
+ );
1195
+ return bytes.length > MAX_FILE_LOCK_REMOVAL_RECORD_BYTES ? null : bytes;
639
1196
  }
640
1197
 
641
1198
  type FileLockRemovalTransitionState = "active" | "abandoned" | "orphan_transition";
642
1199
  type FileLockOrphanTransition = { kind: "orphan_transition"; path: string };
643
- type FileLockAcquisitionResult = LockInfo | FileLockOrphanTransition | null;
1200
+ type FileLockPublicationRetry = { kind: "publication_retry"; code: FileLockPublicationFailureCode };
1201
+ type FileLockAcquisitionResult = LockInfo | FileLockOrphanTransition | FileLockPublicationRetry | null;
644
1202
 
645
1203
  async function readRemovalTransitionOwnerRecord(transitionPath: string): Promise<RemovalTransitionOwnerRecord | null> {
646
1204
  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;
1205
+ const recordPath = removalRecordPath(transitionPath);
1206
+ const stat = await fs.lstat(recordPath, { bigint: true });
1207
+ if (
1208
+ !stat.isFile() ||
1209
+ stat.isSymbolicLink() ||
1210
+ stat.nlink !== 1n ||
1211
+ stat.size > BigInt(MAX_FILE_LOCK_REMOVAL_RECORD_BYTES)
1212
+ )
1213
+ return null;
1214
+ const bytes = await readBoundedRemovalRecordBytes(recordPath);
1215
+ if (!bytes) return null;
1216
+ const record = JSON.parse(bytes.toString("utf8")) as RemovalTransitionOwnerRecord;
650
1217
  const owner = parseLockInfoBytes(JSON.stringify(record.owner));
651
1218
  return owner && typeof record.rootDev === "string" && typeof record.rootIno === "string"
652
1219
  ? { owner, rootDev: record.rootDev, rootIno: record.rootIno }
@@ -670,30 +1237,60 @@ async function classifyFileLockRemovalTransition(
670
1237
  transition = await fs.lstat(transitionPath, { bigint: true });
671
1238
  if (!transition.isDirectory() || transition.isSymbolicLink()) return "active";
672
1239
  } catch (error) {
673
- if (isEnoent(error)) return null;
1240
+ if (isEnoent(error)) {
1241
+ await FileLockTestHooks.afterRemovalTransitionMissing?.(transitionPath);
1242
+ const recordPath = removalRecordPath(transitionPath);
1243
+ const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
1244
+ if (!recordedOwner) {
1245
+ try {
1246
+ await fs.lstat(recordPath);
1247
+ return "active";
1248
+ } catch (recordError) {
1249
+ return isEnoent(recordError) ? null : "active";
1250
+ }
1251
+ }
1252
+ const localRecord = localRemovalRecords.get(recordPath);
1253
+ if (localRecord && sameRemovalRecordBytes(localRecord.bytes, JSON.stringify(recordedOwner))) {
1254
+ if (localRecord.inFlight) return "active";
1255
+ if (!(await clearCompletedLocalRemovalRecord(lockPath, transitionPath, recordedOwner, localRecord)))
1256
+ return "active";
1257
+ return null;
1258
+ }
1259
+ if (lockRecordIsForeignHost(recordedOwner.owner, ownerHostId, previousOwnerHostIds)) {
1260
+ // Never probe a foreign PID. Absence of both captured names proves
1261
+ // retirement completed, rather than a live remover awaiting detach.
1262
+ return (await clearCompletedForeignRemovalRecord(lockPath, transitionPath, recordedOwner))
1263
+ ? null
1264
+ : "active";
1265
+ }
1266
+ // Another same-host process may have completed native removal but been
1267
+ // unable to unlink its receipt. Prove the captured generation is absent
1268
+ // before treating its still-live publisher PID as an active retirement.
1269
+ if (await removalTransitionIsComplete(lockPath, transitionPath, recordedOwner))
1270
+ return (await removeRemovalRecord(recordPath, JSON.stringify(recordedOwner))) ? null : "active";
1271
+ if (ownerIsAlive(recordedOwner.owner) || ownerLiveness(recordedOwner.owner.pid) === "unknown") return "active";
1272
+ return (await removeRemovalRecord(recordPath, JSON.stringify(recordedOwner))) ? null : "active";
1273
+ }
674
1274
  throw error;
675
1275
  }
676
1276
 
677
1277
  // A sibling record survives rename and scrub without mutating the captured tree.
678
1278
  const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
679
1279
  if (recordedOwner) {
680
- if (
681
- recordedOwner.owner.owner_host_id !== undefined &&
682
- lockRecordIsForeignHost(recordedOwner.owner, ownerHostId, previousOwnerHostIds)
683
- )
684
- return "active";
1280
+ if (lockRecordIsForeignHost(recordedOwner.owner, ownerHostId, previousOwnerHostIds)) return "active";
685
1281
  if (
686
1282
  canonicalLockDeviceId(transition.dev).toString() !== recordedOwner.rootDev ||
687
1283
  canonicalLockFileId(transition.ino).toString() !== recordedOwner.rootIno
688
1284
  )
689
1285
  return "active";
1286
+ if (isLocalRemovalInFlight(transitionPath, recordedOwner)) return "active";
690
1287
  if (isResumableLocalRemoval(transitionPath, recordedOwner)) return "orphan_transition"; // The remover itself may resume its own interrupted cleanup.
691
1288
  return ownerIsAlive(recordedOwner.owner) || ownerLiveness(recordedOwner.owner.pid) === "unknown"
692
1289
  ? "active"
693
1290
  : "orphan_transition";
694
1291
  }
695
1292
  try {
696
- await fs.lstat(`${transitionPath}.owner`);
1293
+ await fs.lstat(removalRecordPath(transitionPath));
697
1294
  return "active";
698
1295
  } catch (error) {
699
1296
  if (!isEnoent(error)) return "active";
@@ -724,6 +1321,10 @@ function isFileLockOrphanTransition(value: FileLockAcquisitionResult): value is
724
1321
  return value !== null && "kind" in value && value.kind === "orphan_transition";
725
1322
  }
726
1323
 
1324
+ function isFileLockPublicationRetry(value: FileLockAcquisitionResult): value is FileLockPublicationRetry {
1325
+ return value !== null && "kind" in value && value.kind === "publication_retry";
1326
+ }
1327
+
727
1328
  /** Without an owner, only an aged, completely empty directory is reclaimable. */
728
1329
  function isEmptyRemovalTransition(snapshot: NativeDirectoryTreeSnapshot, graceMs: number): boolean {
729
1330
  const root = snapshot.entries[0];
@@ -735,9 +1336,11 @@ function isEmptyRemovalTransition(snapshot: NativeDirectoryTreeSnapshot, graceMs
735
1336
  async function removeRemovalRecord(recordPath: string, expectedBytes: string): Promise<boolean> {
736
1337
  try {
737
1338
  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;
1339
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > BigInt(MAX_FILE_LOCK_REMOVAL_RECORD_BYTES))
1340
+ return false;
1341
+ const bytes = await readBoundedRemovalRecordBytes(recordPath);
1342
+ if (!bytes || !sameRemovalRecordBytes(bytes.toString("utf8"), expectedBytes)) return false;
1343
+ if ((await FileLockTestHooks.beforeRemovalRecordUnlink?.(recordPath)) === false) return false;
741
1344
  return exactUnlinkDirect(recordPath, {
742
1345
  dev: canonicalLockDeviceId(stat.dev),
743
1346
  ino: canonicalLockFileId(stat.ino),
@@ -745,7 +1348,9 @@ async function removeRemovalRecord(recordPath: string, expectedBytes: string): P
745
1348
  mtimeNs: stat.mtimeNs,
746
1349
  nlink: stat.nlink,
747
1350
  sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
748
- quarantineName: `${path.basename(recordPath)}.retired.${crypto.randomUUID()}`,
1351
+ quarantineName: path.basename(
1352
+ derivedFileLockPath(recordPath, `.retired.${crypto.randomBytes(12).toString("base64url")}`),
1353
+ ),
749
1354
  }).ok;
750
1355
  } catch (error) {
751
1356
  if (isEnoent(error)) return true;
@@ -754,43 +1359,145 @@ async function removeRemovalRecord(recordPath: string, expectedBytes: string): P
754
1359
  }
755
1360
 
756
1361
  async function clearRemovalRecord(transitionPath: string): Promise<void> {
757
- const recordPath = `${transitionPath}.owner`;
758
- const bytes = localRemovalRecords.get(recordPath)?.bytes;
759
- if (!bytes) return;
1362
+ const recordPath = removalRecordPath(transitionPath);
1363
+ const local = localRemovalRecords.get(recordPath);
1364
+ if (!local || local.inFlight) return;
1365
+ const bytes = local.bytes;
760
1366
  try {
761
- await fs.lstat(transitionPath);
762
- return;
1367
+ const transition = await fs.lstat(transitionPath, { bigint: true });
1368
+ const recorded = JSON.parse(bytes) as Partial<RemovalTransitionOwnerRecord>;
1369
+ if (
1370
+ transition.isDirectory() &&
1371
+ typeof recorded.rootDev === "string" &&
1372
+ typeof recorded.rootIno === "string" &&
1373
+ canonicalLockDeviceId(transition.dev).toString() === recorded.rootDev &&
1374
+ canonicalLockFileId(transition.ino).toString() === recorded.rootIno
1375
+ )
1376
+ return;
763
1377
  } catch (error) {
764
1378
  if (!isEnoent(error)) throw error;
765
1379
  }
766
1380
  if (await removeRemovalRecord(recordPath, bytes)) localRemovalRecords.delete(recordPath);
767
1381
  }
768
1382
 
1383
+ async function removalTransitionIsComplete(
1384
+ lockPath: string,
1385
+ transitionPath: string,
1386
+ record: RemovalTransitionOwnerRecord,
1387
+ afterTransitionMissing?: (transitionPath: string) => void | Promise<void>,
1388
+ ): Promise<boolean> {
1389
+ try {
1390
+ await fs.lstat(transitionPath);
1391
+ return false;
1392
+ } catch (error) {
1393
+ if (!isEnoent(error)) throw error;
1394
+ }
1395
+ await afterTransitionMissing?.(transitionPath);
1396
+ try {
1397
+ const canonical = await fs.lstat(lockPath, { bigint: true });
1398
+ if (
1399
+ canonicalLockDeviceId(canonical.dev).toString() === record.rootDev &&
1400
+ canonicalLockFileId(canonical.ino).toString() === record.rootIno
1401
+ )
1402
+ return false;
1403
+ } catch (error) {
1404
+ if (!isEnoent(error)) throw error;
1405
+ }
1406
+ try {
1407
+ await fs.lstat(transitionPath);
1408
+ return false;
1409
+ } catch (error) {
1410
+ if (!isEnoent(error)) throw error;
1411
+ }
1412
+ return true;
1413
+ }
1414
+
1415
+ async function clearCompletedLocalRemovalRecord(
1416
+ lockPath: string,
1417
+ transitionPath: string,
1418
+ record: RemovalTransitionOwnerRecord,
1419
+ localRecord: LocalRemovalRecord,
1420
+ ): Promise<boolean> {
1421
+ const recordPath = removalRecordPath(transitionPath);
1422
+ const current = localRemovalRecords.get(recordPath);
1423
+ if (
1424
+ current !== localRecord ||
1425
+ current.inFlight ||
1426
+ !(await removalTransitionIsComplete(lockPath, transitionPath, record))
1427
+ )
1428
+ return false;
1429
+ const latest = localRemovalRecords.get(recordPath);
1430
+ if (
1431
+ !latest ||
1432
+ latest.inFlight ||
1433
+ !sameRemovalRecordBytes(latest.bytes, localRecord.bytes) ||
1434
+ !(await removeRemovalRecord(recordPath, localRecord.bytes))
1435
+ )
1436
+ return false;
1437
+ const afterRemoval = localRemovalRecords.get(recordPath);
1438
+ if (afterRemoval?.inFlight || (afterRemoval && !sameRemovalRecordBytes(afterRemoval.bytes, localRecord.bytes)))
1439
+ return false;
1440
+ if (afterRemoval) localRemovalRecords.delete(recordPath);
1441
+ return true;
1442
+ }
1443
+
1444
+ async function clearCompletedForeignRemovalRecord(
1445
+ lockPath: string,
1446
+ transitionPath: string,
1447
+ record: RemovalTransitionOwnerRecord,
1448
+ ): Promise<boolean> {
1449
+ if (
1450
+ !(await removalTransitionIsComplete(
1451
+ lockPath,
1452
+ transitionPath,
1453
+ record,
1454
+ FileLockTestHooks.afterForeignTransitionMissing,
1455
+ ))
1456
+ )
1457
+ return false;
1458
+ return removeRemovalRecord(removalRecordPath(transitionPath), JSON.stringify(record));
1459
+ }
1460
+
769
1461
  async function publishRemovalRecord(
770
1462
  transitionPath: string,
771
1463
  snapshot: NativeDirectoryTreeSnapshot,
772
1464
  ownerHostId?: string,
773
1465
  resumable = false,
774
1466
  ): Promise<boolean> {
775
- const recordPath = `${transitionPath}.owner`;
1467
+ const recordPath = removalRecordPath(transitionPath);
1468
+ // A pre-detach refusal can leave a completed local receipt whose exact
1469
+ // unlink was temporarily refused. Release itself must retry that cleanup.
1470
+ await clearRemovalRecord(transitionPath);
776
1471
  if (localRemovalRecords.has(recordPath)) return false;
777
1472
  const bytes = JSON.stringify({
778
1473
  owner: lockInfo(ownerHostId, crypto.randomUUID()),
779
1474
  rootDev: snapshot.rootDev,
780
1475
  rootIno: snapshot.rootIno,
781
1476
  });
782
- const staged = `${recordPath}.${process.pid}.${crypto.randomUUID()}`;
1477
+ if (Buffer.byteLength(bytes, "utf8") > MAX_FILE_LOCK_REMOVAL_RECORD_BYTES) return false;
1478
+ const staged = derivedFileLockPath(recordPath, `.${process.pid}.${crypto.randomBytes(12).toString("base64url")}`);
783
1479
  await Bun.write(staged, bytes, { mode: 0o600 });
1480
+ let publishedLocally = false;
784
1481
  try {
785
1482
  await fs.chmod(staged, 0o600);
786
1483
  await FileLockTestHooks.beforeRemovalOwnerPublish?.(transitionPath);
787
1484
  const published = await renameNoReplacePathAsync(staged, recordPath);
788
- if (!published.ok) return false;
789
- localRemovalRecords.set(recordPath, { bytes, resumable });
790
- return true;
791
- } finally {
1485
+ if (published.ok) {
1486
+ localRemovalRecords.set(recordPath, { bytes, resumable, inFlight: true });
1487
+ publishedLocally = true;
1488
+ }
1489
+ } catch (error) {
1490
+ if (publishedLocally) markLocalRemovalCompleted(transitionPath, bytes);
1491
+ await fs.rm(staged, { force: true }).catch(() => undefined);
1492
+ throw error;
1493
+ }
1494
+ try {
792
1495
  await fs.rm(staged, { force: true });
1496
+ } catch (error) {
1497
+ if (publishedLocally) markLocalRemovalCompleted(transitionPath, bytes);
1498
+ throw error;
793
1499
  }
1500
+ return publishedLocally;
794
1501
  }
795
1502
 
796
1503
  /** Publish outside the captured tree, atomically and before native rename. */
@@ -801,14 +1508,32 @@ async function exactRemoveLockTree(
801
1508
  parentIdentity?: { dev: bigint; ino: bigint },
802
1509
  detachOnly?: boolean,
803
1510
  ): Promise<NativeExactUnlinkResult> {
804
- if (process.platform === "win32" || lockPath.endsWith(".removing"))
1511
+ if (lockPath.endsWith(".removing"))
805
1512
  return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
806
1513
  const transitionPath = fileLockRemovalTransitionPath(lockPath);
807
- const recordPath = `${transitionPath}.owner`;
1514
+ if (process.platform === "win32") {
1515
+ try {
1516
+ await fs.lstat(transitionPath);
1517
+ // Native removal can resume this generation when the canonical name is
1518
+ // already detached. A different generation at `.removing` is left for
1519
+ // acquisition-time adoption; never attach this owner record to it.
1520
+ try {
1521
+ await fs.lstat(lockPath);
1522
+ return { ok: false, code: "quarantine_collision" };
1523
+ } catch (error) {
1524
+ if (!isEnoent(error)) throw error;
1525
+ }
1526
+ return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
1527
+ } catch (error) {
1528
+ if (!isEnoent(error)) throw error;
1529
+ }
1530
+ }
1531
+ const recordPath = removalRecordPath(transitionPath);
808
1532
  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") {
1533
+ if (prior && lockRecordIsForeignHost(prior.owner, ownerHostId, [])) {
1534
+ if (!(await clearCompletedForeignRemovalRecord(lockPath, transitionPath, prior)))
1535
+ return { ok: false, code: "removal_record_busy" };
1536
+ } else if (prior && !ownerIsAlive(prior.owner) && ownerLiveness(prior.owner.pid) !== "unknown") {
812
1537
  try {
813
1538
  await fs.lstat(transitionPath);
814
1539
  return { ok: false, code: "identity_mismatch" };
@@ -818,17 +1543,54 @@ async function exactRemoveLockTree(
818
1543
  if (!(await removeRemovalRecord(recordPath, JSON.stringify(prior))))
819
1544
  return { ok: false, code: "identity_mismatch" };
820
1545
  }
1546
+ // A live remover still owns the sibling record, usually the previous lock
1547
+ // generation whose tree is gone but whose record clear is still in flight.
1548
+ // That is contention, not a changed owner: identity_mismatch makes a release
1549
+ // final and strands this live owner's lock for every other process.
821
1550
  if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId)))
822
- return { ok: false, code: "identity_mismatch" };
1551
+ return { ok: false, code: "removal_record_busy" };
1552
+ const publishedRecordBytes = localRemovalRecords.get(recordPath)?.bytes;
823
1553
  try {
824
- return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
1554
+ await FileLockTestHooks.afterRemovalOwnerPublish?.(transitionPath);
1555
+ const bindings = nativeFileLockBindings();
1556
+ if (process.platform === "win32" && !detachOnly) {
1557
+ const detached = bindings.exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, true);
1558
+ if (!detached.ok) return detached;
1559
+ if (
1560
+ detached.code !== undefined ||
1561
+ detached.detachedPath !== transitionPath ||
1562
+ Object.keys(detached).some(key => key !== "ok" && key !== "detachedPath")
1563
+ )
1564
+ return { ok: false, code: "identity_mismatch", detachedPath: detached.detachedPath };
1565
+ try {
1566
+ const removeResult = bindings.exactRemoveDirectoryTree(transitionPath, snapshot, parentIdentity);
1567
+ // Handle returned transient failures (not thrown exceptions):
1568
+ // when native pre-open fails with e.g. sharing_violation, the result
1569
+ // is returned without detachedPath. Mark the receipt resumable before
1570
+ // returning so that in-process retry or cleanup can complete the removal.
1571
+ if (!removeResult.ok && isResultTransientFailure(removeResult)) {
1572
+ markLocalRemovalResumable(transitionPath, snapshot.rootDev, snapshot.rootIno);
1573
+ // Transitionpath was successfully detached; include it in the result
1574
+ // so cleanup can continue in-process or by a later invocation.
1575
+ return { ...removeResult, detachedPath: transitionPath };
1576
+ }
1577
+ return removeResult;
1578
+ } catch (error) {
1579
+ // Also handle thrown errors (if implementation changes in future)
1580
+ if (isTransientReleaseError(error))
1581
+ markLocalRemovalResumable(transitionPath, snapshot.rootDev, snapshot.rootIno);
1582
+ throw error;
1583
+ }
1584
+ }
1585
+ return bindings.exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
825
1586
  } finally {
1587
+ markLocalRemovalCompleted(transitionPath, publishedRecordBytes);
826
1588
  await clearRemovalRecord(transitionPath);
827
1589
  }
828
1590
  }
829
1591
 
830
1592
  /**
831
- * Adopt-and-finish a provably orphaned POSIX removal transition.
1593
+ * Adopt-and-finish a provably orphaned removal transition.
832
1594
  *
833
1595
  * The prepublished sibling carries the remover's process generation and the
834
1596
  * captured root identity until cleanup finishes. A dead remover permits replay
@@ -841,7 +1603,7 @@ async function adoptOrphanedFileLockRemovalTransition(
841
1603
  orphanAgeMs: number,
842
1604
  ownerHostId?: string,
843
1605
  previousOwnerHostIds: readonly string[] = [],
844
- ): Promise<boolean> {
1606
+ ): Promise<boolean | "retry"> {
845
1607
  const transitionPath = fileLockRemovalTransitionPath(lockPath);
846
1608
  const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
847
1609
  if (recordedOwner && ownerIsAlive(recordedOwner.owner) && !isResumableLocalRemoval(transitionPath, recordedOwner))
@@ -859,53 +1621,100 @@ async function adoptOrphanedFileLockRemovalTransition(
859
1621
  if (!recordedOwner && !isEmptyRemovalTransition(snapshot, orphanAgeMs)) return false;
860
1622
  if (recordedOwner && (snapshot.rootDev !== recordedOwner.rootDev || snapshot.rootIno !== recordedOwner.rootIno))
861
1623
  return false;
1624
+ const recordPath = removalRecordPath(transitionPath);
862
1625
  // Claim an ownerless generation without replacing any live publisher's record.
863
1626
  // The snapshot binds both the grace verdict and subsequent native removal.
864
- if (!recordedOwner && !(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) return false;
1627
+ if (recordedOwner) {
1628
+ const current = localRemovalRecords.get(recordPath);
1629
+ if (current?.inFlight) return false;
1630
+ localRemovalRecords.set(recordPath, {
1631
+ bytes: JSON.stringify(recordedOwner),
1632
+ resumable: true,
1633
+ inFlight: true,
1634
+ });
1635
+ } else if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) {
1636
+ return false;
1637
+ }
1638
+ const removalRecordBytes = localRemovalRecords.get(recordPath)?.bytes;
1639
+ if (!removalRecordBytes) return false;
865
1640
  const finish = async (): Promise<boolean> => {
866
- if (recordedOwner) {
867
- localRemovalRecords.set(`${transitionPath}.owner`, { bytes: JSON.stringify(recordedOwner), resumable: true });
868
- }
1641
+ markLocalRemovalCompleted(transitionPath, removalRecordBytes);
869
1642
  await clearRemovalRecord(transitionPath);
870
1643
  logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
871
1644
  return true;
872
1645
  };
873
- let removal: NativeExactUnlinkResult;
874
1646
  try {
875
- removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
876
- } catch (error) {
877
- if (isTransientReleaseError(error)) return false;
878
- throw error;
879
- }
880
- if (removal.ok === true) {
881
- return removal.code === undefined && Object.keys(removal).every(key => key === "ok") && (await finish());
882
- }
883
- if (
884
- removal.ok === false &&
885
- removal.code === "not_found" &&
886
- Object.keys(removal).every(key => key === "ok" || key === "code")
887
- )
888
- return await finish();
889
- // POSIX cannot bind a namespace unlink to the verified descriptor, so the
890
- // primitive retains the scrubbed tree under its deterministic name and the
891
- // caller finishes the on-disk removal. Finish only the exact tree this call
892
- // scrubbed; a retained successor/placeholder/unknown path is refused so a
893
- // replacement is never deleted.
894
- if (
895
- removal.ok === false &&
896
- removal.code === "cleanup_pending" &&
897
- removal.payloadDurable === true &&
898
- removal.detachedPath !== undefined &&
899
- path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
900
- removal.retainedSuccessorPath === undefined &&
901
- removal.retainedPlaceholderPath === undefined &&
902
- removal.retainedUnknownPath === undefined &&
903
- Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
904
- ) {
905
- const finished = await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
906
- return finished && (await finish());
1647
+ let removal: NativeExactUnlinkResult;
1648
+ try {
1649
+ removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
1650
+ } catch (error) {
1651
+ if (isTransientReleaseError(error)) return "retry";
1652
+ throw error;
1653
+ }
1654
+ if (!removal.ok && isTransientReleaseError(removal)) return "retry";
1655
+ if (removal.ok === true) {
1656
+ return removal.code === undefined && Object.keys(removal).every(key => key === "ok") && (await finish());
1657
+ }
1658
+ if (
1659
+ removal.ok === false &&
1660
+ removal.code === "not_found" &&
1661
+ Object.keys(removal).every(key => key === "ok" || key === "code")
1662
+ )
1663
+ return await finish();
1664
+ // POSIX cannot bind a namespace unlink to the verified descriptor, so the
1665
+ // primitive retains the scrubbed tree under its deterministic name and the
1666
+ // caller finishes the on-disk removal. Finish only the exact tree this call
1667
+ // scrubbed; a retained successor/placeholder/unknown path is refused so a
1668
+ // replacement is never deleted.
1669
+ if (
1670
+ removal.ok === false &&
1671
+ removal.code === "cleanup_pending" &&
1672
+ removal.payloadDurable === true &&
1673
+ removal.detachedPath !== undefined &&
1674
+ path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
1675
+ removal.retainedSuccessorPath === undefined &&
1676
+ removal.retainedPlaceholderPath === undefined &&
1677
+ removal.retainedUnknownPath === undefined &&
1678
+ Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
1679
+ ) {
1680
+ try {
1681
+ const finished = await removeDetachedLockQuarantineOnDisk(
1682
+ transitionPath,
1683
+ snapshot.rootDev,
1684
+ snapshot.rootIno,
1685
+ );
1686
+ return finished && (await finish());
1687
+ } catch (error) {
1688
+ if (isTransientReleaseError(error)) return "retry";
1689
+ throw error;
1690
+ }
1691
+ }
1692
+ return false;
1693
+ } finally {
1694
+ markLocalRemovalCompleted(transitionPath, removalRecordBytes);
907
1695
  }
908
- return false;
1696
+ }
1697
+
1698
+ /**
1699
+ * Whether an abandoned transition's captured tree is safe to adopt: the root is
1700
+ * the transition directory, `info` is the only file, and every other entry is a
1701
+ * directory or an empty file. Any other payload means adoption refuses the
1702
+ * generation, so it must be reported as a block instead of a silent retry.
1703
+ */
1704
+ function abandonedTransitionPayloadIsAdoptable(snapshot: NativeDirectoryTreeSnapshot): boolean {
1705
+ const root = snapshot.entries.find(entry => entry.relativePath === "");
1706
+ const infoEntry = snapshot.entries.find(entry => entry.relativePath === "info");
1707
+ return (
1708
+ root?.kind === "directory" &&
1709
+ infoEntry?.kind === "file" &&
1710
+ !snapshot.entries.some(
1711
+ entry =>
1712
+ entry.relativePath !== "" &&
1713
+ entry.relativePath !== "info" &&
1714
+ entry.kind !== "directory" &&
1715
+ (entry.kind !== "file" || entry.size !== "0"),
1716
+ )
1717
+ );
909
1718
  }
910
1719
 
911
1720
  async function adoptAbandonedFileLockRemovalTransition(
@@ -929,20 +1738,9 @@ async function adoptAbandonedFileLockRemovalTransition(
929
1738
  }
930
1739
  if (!captured.ok || !captured.snapshot) return false;
931
1740
  const snapshot = captured.snapshot;
932
- const root = snapshot.entries.find(entry => entry.relativePath === "");
1741
+ if (!abandonedTransitionPayloadIsAdoptable(snapshot)) return false;
933
1742
  const infoEntry = snapshot.entries.find(entry => entry.relativePath === "info");
934
- if (
935
- root?.kind !== "directory" ||
936
- infoEntry?.kind !== "file" ||
937
- snapshot.entries.some(
938
- entry =>
939
- entry.relativePath !== "" &&
940
- entry.relativePath !== "info" &&
941
- entry.kind !== "directory" &&
942
- (entry.kind !== "file" || entry.size !== "0"),
943
- )
944
- )
945
- return false;
1743
+ if (infoEntry?.kind !== "file") return false;
946
1744
  const currentIdentity = await captureFileLockDirIdentity(transitionPath);
947
1745
  if (!currentIdentity || !sameStableFileLockIdentity(currentIdentity, stale.identity)) return false;
948
1746
  if (
@@ -953,38 +1751,45 @@ async function adoptAbandonedFileLockRemovalTransition(
953
1751
  // Legacy info proves the old owner dead, but native scrub destroys that info.
954
1752
  // Commit this remover's sibling identity before allowing any payload mutation.
955
1753
  if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) return false;
956
-
957
- let removal: NativeExactUnlinkResult;
1754
+ const publishedRecordBytes = localRemovalRecords.get(removalRecordPath(transitionPath))?.bytes;
1755
+ if (!publishedRecordBytes) return false;
958
1756
  try {
959
- removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
960
- } catch (error) {
961
- if (isTransientReleaseError(error)) return false;
962
- throw error;
963
- }
964
- let finished = removal.ok === true && removal.code === undefined && Object.keys(removal).every(key => key === "ok");
965
- if (
966
- removal.ok === false &&
967
- removal.code === "cleanup_pending" &&
968
- removal.payloadDurable === true &&
969
- removal.detachedPath !== undefined &&
970
- path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
971
- removal.retainedSuccessorPath === undefined &&
972
- removal.retainedPlaceholderPath === undefined &&
973
- removal.retainedUnknownPath === undefined &&
974
- Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
975
- )
976
- finished = await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
977
- if (
978
- removal.ok === false &&
979
- removal.code === "not_found" &&
980
- Object.keys(removal).every(key => key === "ok" || key === "code")
981
- )
982
- finished = true;
983
- if (finished) {
984
- await clearRemovalRecord(transitionPath);
985
- logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
1757
+ let removal: NativeExactUnlinkResult;
1758
+ try {
1759
+ removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
1760
+ } catch (error) {
1761
+ if (isTransientReleaseError(error)) return false;
1762
+ throw error;
1763
+ }
1764
+ let finished =
1765
+ removal.ok === true && removal.code === undefined && Object.keys(removal).every(key => key === "ok");
1766
+ if (
1767
+ removal.ok === false &&
1768
+ removal.code === "cleanup_pending" &&
1769
+ removal.payloadDurable === true &&
1770
+ removal.detachedPath !== undefined &&
1771
+ path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
1772
+ removal.retainedSuccessorPath === undefined &&
1773
+ removal.retainedPlaceholderPath === undefined &&
1774
+ removal.retainedUnknownPath === undefined &&
1775
+ Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
1776
+ )
1777
+ finished = await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
1778
+ if (
1779
+ removal.ok === false &&
1780
+ removal.code === "not_found" &&
1781
+ Object.keys(removal).every(key => key === "ok" || key === "code")
1782
+ )
1783
+ finished = true;
1784
+ if (finished) {
1785
+ markLocalRemovalCompleted(transitionPath, publishedRecordBytes);
1786
+ await clearRemovalRecord(transitionPath);
1787
+ logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
1788
+ }
1789
+ return finished;
1790
+ } finally {
1791
+ markLocalRemovalCompleted(transitionPath, publishedRecordBytes);
986
1792
  }
987
- return finished;
988
1793
  }
989
1794
 
990
1795
  function sameFileLockTreeAfterPublication(
@@ -1037,6 +1842,7 @@ async function rollbackPublishedFileLock(
1037
1842
  lockPath: string,
1038
1843
  pendingPath: string,
1039
1844
  stagedSnapshot: NativeDirectoryTreeSnapshot,
1845
+ parentIdentity: { dev: bigint; ino: bigint },
1040
1846
  ): Promise<void> {
1041
1847
  if (process.platform !== "linux") {
1042
1848
  // RecoveryFsRoot is Linux-only. POSIX exact restore retains the parent
@@ -1046,6 +1852,8 @@ async function rollbackPublishedFileLock(
1046
1852
  const rollback = exactRestore(lockPath, pendingPath, {
1047
1853
  dev: BigInt(stagedSnapshot.rootDev),
1048
1854
  ino: BigInt(stagedSnapshot.rootIno),
1855
+ parentDev: parentIdentity.dev,
1856
+ parentIno: parentIdentity.ino,
1049
1857
  size: BigInt(root.size),
1050
1858
  mtimeNs: BigInt(root.mtimeNs),
1051
1859
  directory: true,
@@ -1186,12 +1994,14 @@ function isValidNativeNoReplaceResult(value: unknown): value is NativeNoReplaceR
1186
1994
  "atomic_unavailable",
1187
1995
  "cross_device",
1188
1996
  "permission_denied",
1997
+ "access_denied",
1189
1998
  "io_failure",
1190
1999
  "invalid_request",
1191
2000
  "interrupted",
1192
2001
  "identity_violation",
1193
2002
  "durability_not_provable",
1194
2003
  "sharing_violation",
2004
+ "access_denied",
1195
2005
  "unknown",
1196
2006
  ] as const
1197
2007
  ).includes(value.reason as never) ||
@@ -1302,30 +2112,56 @@ function isPreMutationUnsupportedRenameResult(value: unknown): value is NativeNo
1302
2112
  return true;
1303
2113
  }
1304
2114
 
1305
- function isPreMutationSharingViolation(value: unknown): value is NativeNoReplaceResult {
2115
+ function isPreMutationPublicationContention(value: unknown): boolean {
1306
2116
  return (
1307
2117
  isValidNativeNoReplaceResult(value) &&
1308
2118
  !value.ok &&
1309
2119
  value.code === "sharing_violation" &&
1310
2120
  value.mutationState === "not_committed" &&
1311
2121
  value.durabilityState === "not_attempted" &&
1312
- value.reason === "sharing_violation" &&
2122
+ value.reason === value.code &&
2123
+ value.phase === "rename"
2124
+ );
2125
+ }
2126
+
2127
+ function isPreMutationAccessDeniedPublication(value: unknown): boolean {
2128
+ return (
2129
+ isValidNativeNoReplaceResult(value) &&
2130
+ !value.ok &&
2131
+ value.code === "access_denied" &&
2132
+ value.mutationState === "not_committed" &&
2133
+ value.durabilityState === "not_attempted" &&
2134
+ value.reason === "access_denied" &&
2135
+ value.primitive === "windows_rename_noreplace" &&
1313
2136
  value.phase === "rename"
1314
2137
  );
1315
2138
  }
1316
2139
 
1317
- async function publishNoReplaceWithSharingRetry(
2140
+ function isPreMutationPublicationRetryable(value: unknown): boolean {
2141
+ return isPreMutationPublicationContention(value) || isPreMutationAccessDeniedPublication(value);
2142
+ }
2143
+
2144
+ function preMutationPublicationFailureCode(value: unknown): FileLockPublicationFailureCode | undefined {
2145
+ if (isPreMutationPublicationContention(value)) return "sharing_violation";
2146
+ if (isPreMutationAccessDeniedPublication(value)) return "access_denied";
2147
+ return undefined;
2148
+ }
2149
+
2150
+ async function publishNoReplaceWithTransientRetry(
1318
2151
  publish: (sourcePath: string, destinationPath: string) => Promise<NativeNoReplaceResult>,
1319
2152
  sourcePath: string,
1320
2153
  destinationPath: string,
2154
+ signal?: AbortSignal,
1321
2155
  ): Promise<NativeNoReplaceResult> {
2156
+ throwIfFileLockAcquisitionAborted(signal);
1322
2157
  let result = await publish(sourcePath, destinationPath);
1323
2158
  for (
1324
2159
  let attempt = 1;
1325
- attempt < PUBLICATION_SHARING_RETRY_ATTEMPTS && isPreMutationSharingViolation(result);
2160
+ attempt < PUBLICATION_TRANSIENT_RETRY_ATTEMPTS && isPreMutationPublicationRetryable(result);
1326
2161
  attempt++
1327
2162
  ) {
1328
- await Bun.sleep(PUBLICATION_SHARING_RETRY_DELAY_MS * attempt);
2163
+ await sleepFileLockRetry(PUBLICATION_TRANSIENT_RETRY_DELAY_MS * attempt, signal);
2164
+ throwIfFileLockAcquisitionAborted(signal);
1329
2165
  result = await publish(sourcePath, destinationPath);
1330
2166
  }
1331
2167
  return result;
@@ -1394,12 +2230,14 @@ export interface GenericFileLockDirIdentity {
1394
2230
  }
1395
2231
 
1396
2232
  function sameStableFileLockIdentity(left: GenericFileLockDirIdentity, right: GenericFileLockDirIdentity): boolean {
2233
+ const birthtimeMatches =
2234
+ left.infoBirthtimeNs === "0" || right.infoBirthtimeNs === "0" || left.infoBirthtimeNs === right.infoBirthtimeNs;
1397
2235
  return (
1398
2236
  left.rootDev === right.rootDev &&
1399
2237
  left.rootIno === right.rootIno &&
1400
2238
  left.infoDev === right.infoDev &&
1401
2239
  left.infoIno === right.infoIno &&
1402
- left.infoBirthtimeNs === right.infoBirthtimeNs
2240
+ birthtimeMatches
1403
2241
  );
1404
2242
  }
1405
2243
 
@@ -1417,6 +2255,25 @@ function sameGenericFileLockDirIdentity(left: GenericFileLockDirIdentity, right:
1417
2255
  return sameStableFileLockIdentity(left, right) && sameFileLockContentEvidence(left, right);
1418
2256
  }
1419
2257
 
2258
+ function fileLockDirIdentityFromNativeSnapshot(snapshot: NativeDirectoryTreeSnapshot): GenericFileLockDirIdentity {
2259
+ const info = snapshot.entries.find(entry => entry.relativePath === "info");
2260
+ if (info?.kind !== "file" || !info.sha256) throw new Error("file_lock_snapshot_missing_owner_identity");
2261
+ return {
2262
+ rootDev: snapshot.rootDev,
2263
+ rootIno: snapshot.rootIno,
2264
+ infoDev: info.dev,
2265
+ infoIno: info.ino,
2266
+ infoNlink: info.nlink,
2267
+ infoSize: info.size,
2268
+ infoMtimeNs: info.mtimeNs,
2269
+ infoCtimeNs: info.ctimeNs,
2270
+ // Native snapshots do not expose birthtime; the remaining inode and content
2271
+ // evidence still binds this exact staged generation across publication.
2272
+ infoBirthtimeNs: "0",
2273
+ infoSha256: info.sha256,
2274
+ };
2275
+ }
2276
+
1420
2277
  let nativeExactRemovalUsable: boolean | undefined;
1421
2278
  let nativeExactRemovalProbePromise: Promise<boolean> | undefined;
1422
2279
 
@@ -1510,8 +2367,22 @@ async function removeVerifiedLockDirWithoutNative(
1510
2367
  removal.retainedUnknownPath !== undefined
1511
2368
  )
1512
2369
  return "cleanup_failed";
1513
- if (!(await removeDetachedLockQuarantineOnDisk(removal.detachedPath, expected.rootDev, expected.rootIno)))
2370
+ let detachedCleanupCompleted: boolean;
2371
+ try {
2372
+ detachedCleanupCompleted = await removeDetachedLockQuarantineOnDisk(
2373
+ removal.detachedPath,
2374
+ expected.rootDev,
2375
+ expected.rootIno,
2376
+ );
2377
+ } catch (error) {
2378
+ markLocalRemovalResumable(removal.detachedPath, expected.rootDev, expected.rootIno);
2379
+ if (!isTransientReleaseError(error)) throw error;
2380
+ return "cleanup_failed";
2381
+ }
2382
+ if (!detachedCleanupCompleted) {
2383
+ markLocalRemovalResumable(removal.detachedPath, expected.rootDev, expected.rootIno);
1514
2384
  return "cleanup_failed";
2385
+ }
1515
2386
  await clearRemovalRecord(removal.detachedPath);
1516
2387
  return "removed";
1517
2388
  }
@@ -1724,7 +2595,20 @@ export async function removeFileLockDirForGc(
1724
2595
  rootIno: captured.snapshot.rootIno,
1725
2596
  snapshot: captured.snapshot,
1726
2597
  });
1727
- return (await finishDetachedLockCleanup(expected)) ? "removed" : "cleanup_failed";
2598
+ let cleanupCompleted = false;
2599
+ try {
2600
+ cleanupCompleted = await finishDetachedLockCleanup(expected);
2601
+ } catch (error) {
2602
+ // Treat an OS sharing denial like a cleanup_pending receipt: both leave
2603
+ // this exact detached generation for a later local retry.
2604
+ if (!isTransientReleaseError(error)) throw error;
2605
+ }
2606
+ if (cleanupCompleted) return "removed";
2607
+ // Stale-owner cleanup has no LocalLockState release handle to drive another
2608
+ // retry. Mark the receipt resumable only after the native attempt returns;
2609
+ // other processes still see its live owner and remain fenced from adoption.
2610
+ markLocalRemovalResumable(detachedPath, captured.snapshot.rootDev, captured.snapshot.rootIno);
2611
+ return "cleanup_failed";
1728
2612
  }
1729
2613
  if (removed.code === "not_found") return "removed";
1730
2614
  if (removed.code === "identity_mismatch") return "owner_changed";
@@ -1808,9 +2692,10 @@ async function staleLockSnapshot(
1808
2692
  throw error;
1809
2693
  }
1810
2694
  if (!info) {
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.
2695
+ // This verdict can authorize cleanup only from a recorded owner generation.
2696
+ // Windows acquisition has a separate age- and identity-bound path for a
2697
+ // completely empty legacy directory; that path never treats it as a dead
2698
+ // owner or claims the existing directory as a new lock.
1814
2699
  return { stale: false };
1815
2700
  }
1816
2701
 
@@ -1998,19 +2883,43 @@ async function tryAcquireLock(
1998
2883
  orphanTransitionAgeMs: number,
1999
2884
  previousOwnerHostIds: readonly string[],
2000
2885
  ownerToken = crypto.randomUUID(),
2001
- onAcquired?: () => void,
2886
+ onAcquired: (() => void) | undefined,
2887
+ retryBudget: FileLockAcquisitionRetryBudget,
2888
+ onPublished?: (owner: LockInfo) => void,
2889
+ onPublicationRolledBack?: (owner: LockInfo) => void,
2890
+ onPublicationFailed?: (owner: LockInfo, identity: GenericFileLockDirIdentity, releasePath: string) => void,
2002
2891
  ): Promise<FileLockAcquisitionResult> {
2003
2892
  await ensureLockParent(path.dirname(lockPath));
2004
2893
  const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
2005
2894
  if (afterParentMkdir) await afterParentMkdir(lockPath);
2006
- const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
2895
+ const stagingIdentity = currentFileLockStagingIdentity();
2896
+ const pendingPath = derivedFileLockPath(
2897
+ lockPath,
2898
+ stagingIdentity
2899
+ ? `.pending.${process.pid}.${stagingIdentity}.${crypto.randomBytes(12).toString("base64url")}`
2900
+ : `.pending.${process.pid}.${crypto.randomUUID()}`,
2901
+ );
2007
2902
  const owner = lockInfo(ownerHostId, ownerToken);
2008
2903
  let removePending = false;
2904
+ let pendingStagingIdentity: FileLockStagingRootIdentity | undefined;
2905
+ let failedPublicationPath = lockPath;
2009
2906
  try {
2010
2907
  await fs.mkdir(pendingPath, { mode: 0o700 });
2011
2908
  removePending = true;
2909
+ try {
2910
+ pendingStagingIdentity = await captureFileLockStagingRootIdentity(pendingPath, retryBudget);
2911
+ } catch (error) {
2912
+ removePending = false;
2913
+ retainPendingStagingCleanup(lockPath, pendingPath, undefined, error);
2914
+ if (retryBudget.signal?.aborted) throw retryBudget.signal.reason ?? error;
2915
+ throw new FileLockStagingCleanupError(lockPath, pendingPath, error);
2916
+ }
2012
2917
  await fs.chmod(pendingPath, 0o700);
2013
- await writeLockInfo(pendingPath, owner);
2918
+ await writeLockInfo(pendingPath, owner, retryBudget, infoFileIdentity => {
2919
+ if (!pendingStagingIdentity) throw new Error("Staged file-lock root identity is unavailable.");
2920
+ pendingStagingIdentity = { ...pendingStagingIdentity, infoFileIdentity };
2921
+ });
2922
+ await FileLockTestHooks.afterStagingInfoWrite?.(pendingPath);
2014
2923
  // A plain POSIX rename replaces an existing empty directory. The legacy
2015
2924
  // directory lock is a real holder, so publication must use the native
2016
2925
  // no-replace primitive rather than treating an empty destination as free.
@@ -2019,56 +2928,68 @@ async function tryAcquireLock(
2019
2928
  // retain the same lock identity as the ordinary path.
2020
2929
  const canonicalParent = await fs.realpath(path.dirname(lockPath));
2021
2930
  const destinationPath = path.join(canonicalParent, path.basename(lockPath));
2931
+ failedPublicationPath = destinationPath;
2932
+ const parent = await fs.lstat(canonicalParent, { bigint: true });
2933
+ const parentIdentity = {
2934
+ dev: canonicalLockDeviceId(parent.dev),
2935
+ ino: canonicalLockFileId(parent.ino),
2936
+ };
2022
2937
  // Resolve only the stable parent: resolving the mutable staging final
2023
2938
  // component would follow an attacker-replaced symlink before native no-follow
2024
2939
  // validation gets a chance to reject it.
2025
2940
  const canonicalPendingPath = path.join(canonicalParent, path.basename(pendingPath));
2026
2941
  let stagedSnapshot: NativeDirectoryTreeSnapshot | undefined;
2027
- if (process.platform !== "win32") {
2028
- // POSIX exact tree removal owns this deterministic sibling from detach
2029
- // until cleanup. The outer acquisition loop supplies the existing bounded
2030
- // contention wait while the predecessor retains that namespace.
2031
- let transitionState = await classifyFileLockRemovalTransition(
2942
+ // A predecessor may retain this deterministic sibling while native cleanup
2943
+ // is in progress on either platform. Do not let a successor publish until
2944
+ // the transition is gone or its dead owner has been safely adopted.
2945
+ let transitionState = await classifyFileLockRemovalTransition(
2946
+ destinationPath,
2947
+ orphanTransitionAgeMs,
2948
+ ownerHostId,
2949
+ previousOwnerHostIds,
2950
+ );
2951
+ if (transitionState === "orphan_transition") {
2952
+ // A scrubbed, aged transition is the residue of a removal whose owner died
2953
+ // before its final on-disk cleanup. Adopt and finish it here so the wedged
2954
+ // namespace heals instead of aborting (or re-spinning the whole budget).
2955
+ const adopted = await adoptOrphanedFileLockRemovalTransition(
2032
2956
  destinationPath,
2033
2957
  orphanTransitionAgeMs,
2034
2958
  ownerHostId,
2035
2959
  previousOwnerHostIds,
2036
2960
  );
2037
- if (transitionState === "orphan_transition") {
2038
- // A scrubbed, aged transition is the residue of a removal whose owner died
2039
- // before its final on-disk cleanup. Adopt and finish it here so the wedged
2040
- // namespace heals instead of aborting (or re-spinning the whole budget).
2041
- if (
2042
- !(await adoptOrphanedFileLockRemovalTransition(
2043
- destinationPath,
2044
- orphanTransitionAgeMs,
2045
- ownerHostId,
2046
- previousOwnerHostIds,
2047
- ))
2048
- )
2049
- return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
2961
+ if (adopted === "retry") return null;
2962
+ if (!adopted) return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
2963
+ transitionState = await classifyFileLockRemovalTransition(
2964
+ destinationPath,
2965
+ orphanTransitionAgeMs,
2966
+ ownerHostId,
2967
+ previousOwnerHostIds,
2968
+ );
2969
+ }
2970
+ if (transitionState === "orphan_transition")
2971
+ return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
2972
+ if (transitionState === "abandoned") {
2973
+ if (await adoptAbandonedFileLockRemovalTransition(destinationPath, ownerHostId, previousOwnerHostIds)) {
2050
2974
  transitionState = await classifyFileLockRemovalTransition(
2051
2975
  destinationPath,
2052
2976
  orphanTransitionAgeMs,
2053
2977
  ownerHostId,
2054
2978
  previousOwnerHostIds,
2055
2979
  );
2980
+ } else {
2981
+ return null;
2056
2982
  }
2057
- if (transitionState === "orphan_transition")
2058
- return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
2059
- if (transitionState === "abandoned") {
2060
- if (await adoptAbandonedFileLockRemovalTransition(destinationPath, ownerHostId, previousOwnerHostIds)) {
2061
- transitionState = await classifyFileLockRemovalTransition(
2062
- destinationPath,
2063
- orphanTransitionAgeMs,
2064
- ownerHostId,
2065
- previousOwnerHostIds,
2066
- );
2067
- } else {
2068
- return null;
2069
- }
2983
+ }
2984
+ if (transitionState !== null) return null;
2985
+ let stagedTree: NativeDirectoryTreeSnapshot;
2986
+ if (process.platform === "win32") {
2987
+ try {
2988
+ stagedTree = await captureFileLockStagingTreeSnapshot(canonicalPendingPath, retryBudget);
2989
+ } catch (error) {
2990
+ throw new FileLockStagingCleanupError(lockPath, pendingPath, error);
2070
2991
  }
2071
- if (transitionState !== null) return null;
2992
+ } else {
2072
2993
  const staged = snapshotDirectoryTree(canonicalPendingPath);
2073
2994
  if (!staged.ok || !staged.snapshot) {
2074
2995
  const failure = new Error(
@@ -2077,29 +2998,40 @@ async function tryAcquireLock(
2077
2998
  if (staged.code) failure.code = staged.code;
2078
2999
  throw failure;
2079
3000
  }
2080
- stagedSnapshot = staged.snapshot;
3001
+ stagedTree = staged.snapshot;
2081
3002
  }
3003
+ stagedSnapshot = stagedTree;
3004
+ if (pendingStagingIdentity)
3005
+ pendingStagingIdentity = bindFileLockStagingTreeSnapshot(
3006
+ canonicalPendingPath,
3007
+ pendingStagingIdentity,
3008
+ stagedTree,
3009
+ );
3010
+ const stagedIdentity = fileLockDirIdentityFromNativeSnapshot(stagedTree);
2082
3011
  const publication = FileLockTestHooks.nativePublicationBindings?.() ?? {
2083
3012
  renameNoReplacePathAsync,
2084
3013
  renameDirectoryNoReplacePathAsync,
2085
3014
  };
2086
- const published = await publishNoReplaceWithSharingRetry(
3015
+ const published = await publishNoReplaceWithTransientRetry(
2087
3016
  publication.renameNoReplacePathAsync,
2088
3017
  canonicalPendingPath,
2089
3018
  destinationPath,
3019
+ retryBudget.signal,
2090
3020
  );
2091
3021
  let publishedSuccessfully = isSuccessfulNativePublication(published, "primary");
2092
3022
  if (published.ok && !publishedSuccessfully)
2093
3023
  throw new Error("Failed to publish file lock: invalid primary success receipt.");
2094
3024
  if (!published.ok && isPreMutationUnsupportedRenameResult(published)) {
2095
- const fallback = await publishNoReplaceWithSharingRetry(
3025
+ const fallback = await publishNoReplaceWithTransientRetry(
2096
3026
  publication.renameDirectoryNoReplacePathAsync,
2097
3027
  canonicalPendingPath,
2098
3028
  destinationPath,
3029
+ retryBudget.signal,
2099
3030
  );
2100
3031
  const fallbackSuccessfully = isSuccessfulNativePublication(fallback, "directory");
2101
3032
  if (fallback.ok && !fallbackSuccessfully)
2102
3033
  throw new Error("Failed to publish file lock: invalid directory success receipt.");
3034
+ const fallbackFailureCode = preMutationPublicationFailureCode(fallback);
2103
3035
  let verifiedCommittedPublication = false;
2104
3036
  if (isCommittedDirectoryVerificationFailure(fallback)) {
2105
3037
  // The staged name has been consumed. Relinquish pathname cleanup even
@@ -2119,6 +3051,10 @@ async function tryAcquireLock(
2119
3051
  publishedSuccessfully = true;
2120
3052
  } else if (fallback.reason === "destination_exists") {
2121
3053
  return null;
3054
+ } else if (fallbackFailureCode) {
3055
+ removePending = false;
3056
+ await cleanupPendingStagingOrThrow(lockPath, pendingPath, pendingStagingIdentity, retryBudget);
3057
+ return { kind: "publication_retry", code: fallbackFailureCode };
2122
3058
  } else {
2123
3059
  const failure = new Error(
2124
3060
  `Failed to publish file lock: ${fallback.code ?? fallback.reason ?? "unknown"}.`,
@@ -2131,6 +3067,12 @@ async function tryAcquireLock(
2131
3067
  if (published.reason === "destination_exists") {
2132
3068
  return null;
2133
3069
  }
3070
+ const publicationFailureCode = preMutationPublicationFailureCode(published);
3071
+ if (publicationFailureCode) {
3072
+ removePending = false;
3073
+ await cleanupPendingStagingOrThrow(lockPath, pendingPath, pendingStagingIdentity, retryBudget);
3074
+ return { kind: "publication_retry", code: publicationFailureCode };
3075
+ }
2134
3076
  const failure = new Error(
2135
3077
  `Failed to publish file lock: ${published.code ?? published.reason ?? "unknown"}.`,
2136
3078
  ) as NodeJS.ErrnoException;
@@ -2138,55 +3080,110 @@ async function tryAcquireLock(
2138
3080
  throw failure;
2139
3081
  }
2140
3082
  removePending = false;
2141
- // The transition can appear after the pre-publication check only when the
2142
- // predecessor atomically detaches its lock before this no-replace publish.
2143
- // Roll this exact staged tree back to its UUID path before reporting
2144
- // contention; never delete or rename the predecessor's `.removing` tree.
2145
- if (stagedSnapshot) {
2146
- const transitionState = await classifyFileLockRemovalTransition(
2147
- destinationPath,
2148
- orphanTransitionAgeMs,
2149
- ownerHostId,
2150
- previousOwnerHostIds,
2151
- );
2152
- if (transitionState !== null) {
2153
- const publishedSnapshot = snapshotDirectoryTree(destinationPath);
2154
- if (
2155
- !publishedSnapshot.ok ||
2156
- !publishedSnapshot.snapshot ||
2157
- !sameFileLockTreeAfterPublication(stagedSnapshot, publishedSnapshot.snapshot)
2158
- ) {
2159
- const failure = new Error(
2160
- `Failed to verify published file lock before transition rollback: ${publishedSnapshot.code ?? "identity_mismatch"}.`,
2161
- ) as NodeJS.ErrnoException;
2162
- if (publishedSnapshot.code) failure.code = publishedSnapshot.code;
2163
- throw failure;
2164
- }
2165
- await rollbackPublishedFileLock(
2166
- canonicalParent,
3083
+ try {
3084
+ // Preserve exact-generation release authority before post-publication checks
3085
+ // can throw; otherwise a live canonical owner can be left untracked locally.
3086
+ onPublished?.(owner);
3087
+ // The transition can appear after the pre-publication check only when the
3088
+ // predecessor atomically detaches its lock before this no-replace publish.
3089
+ // Roll this exact staged tree back to its UUID path before reporting
3090
+ // contention; never delete or rename the predecessor's `.removing` tree.
3091
+ if (stagedSnapshot) {
3092
+ const transitionState = await classifyFileLockRemovalTransition(
2167
3093
  destinationPath,
2168
- canonicalPendingPath,
2169
- publishedSnapshot.snapshot,
3094
+ orphanTransitionAgeMs,
3095
+ ownerHostId,
3096
+ previousOwnerHostIds,
2170
3097
  );
2171
- removePending = true;
2172
- return transitionState === "orphan_transition"
2173
- ? { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) }
2174
- : null;
3098
+ if (transitionState !== null) {
3099
+ const publishedSnapshot = snapshotDirectoryTree(destinationPath);
3100
+ if (
3101
+ !publishedSnapshot.ok ||
3102
+ !publishedSnapshot.snapshot ||
3103
+ !sameFileLockTreeAfterPublication(stagedSnapshot, publishedSnapshot.snapshot)
3104
+ ) {
3105
+ const failure = new Error(
3106
+ `Failed to verify published file lock before transition rollback: ${publishedSnapshot.code ?? "identity_mismatch"}.`,
3107
+ ) as NodeJS.ErrnoException;
3108
+ if (publishedSnapshot.code) failure.code = publishedSnapshot.code;
3109
+ throw failure;
3110
+ }
3111
+ try {
3112
+ await rollbackPublishedFileLock(
3113
+ canonicalParent,
3114
+ destinationPath,
3115
+ canonicalPendingPath,
3116
+ publishedSnapshot.snapshot,
3117
+ parentIdentity,
3118
+ );
3119
+ } catch (error) {
3120
+ // Rollback can commit before a later verification or authority-close
3121
+ // failure. Release the staged generation at the path that actually
3122
+ // retains it, rather than retrying against the now-vacant canonical name.
3123
+ try {
3124
+ const stagedAgain = snapshotDirectoryTree(canonicalPendingPath);
3125
+ if (
3126
+ stagedAgain.ok &&
3127
+ stagedAgain.snapshot &&
3128
+ sameFileLockTreeAfterPublication(publishedSnapshot.snapshot, stagedAgain.snapshot)
3129
+ )
3130
+ failedPublicationPath = canonicalPendingPath;
3131
+ } catch {
3132
+ // Keep the canonical target when the staging identity cannot be proven.
3133
+ }
3134
+ throw error;
3135
+ }
3136
+ removePending = true;
3137
+ onPublicationRolledBack?.(owner);
3138
+ return transitionState === "orphan_transition"
3139
+ ? { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) }
3140
+ : null;
3141
+ }
2175
3142
  }
3143
+ // Published and transition-free above, so an onAcquired failure must
3144
+ // propagate instead of retrying an acquisition that already owns the lock.
3145
+ onAcquired?.();
3146
+ return owner;
3147
+ } catch (error) {
3148
+ onPublicationFailed?.(owner, stagedIdentity, failedPublicationPath);
3149
+ throw error;
2176
3150
  }
2177
- // Published and transition-free above, so an onAcquired failure must
2178
- // propagate instead of retrying an acquisition that already owns the lock.
2179
- onAcquired?.();
2180
- return owner;
2181
3151
  } finally {
2182
- if (removePending) await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
3152
+ if (removePending && pendingStagingIdentity) {
3153
+ try {
3154
+ if (process.platform === "win32" && !pendingStagingIdentity.treeSnapshot)
3155
+ pendingStagingIdentity = await captureFileLockStagingCleanupIdentity(
3156
+ pendingPath,
3157
+ pendingStagingIdentity,
3158
+ retryBudget,
3159
+ );
3160
+ await removePendingStaging(pendingPath, pendingStagingIdentity);
3161
+ pendingStagingCleanups.delete(pendingPath);
3162
+ } catch (cleanupError) {
3163
+ const cleanupPath = detachedStagingCleanupPath(pendingPath, cleanupError);
3164
+ if (cleanupPath !== pendingPath) pendingStagingCleanups.delete(pendingPath);
3165
+ retainPendingStagingCleanup(lockPath, cleanupPath, pendingStagingIdentity, cleanupError);
3166
+ }
3167
+ }
2183
3168
  }
2184
3169
  }
2185
3170
 
3171
+ function isResultTransientFailure(result: { code?: string }): boolean {
3172
+ const code = result.code;
3173
+ return (
3174
+ code === "EBUSY" || code === "EPERM" || code === "EACCES" || code === "ENOTEMPTY" || code === "sharing_violation"
3175
+ );
3176
+ }
3177
+
2186
3178
  function isTransientReleaseError(error: unknown): boolean {
2187
3179
  const code = (error as NodeJS.ErrnoException).code;
2188
3180
  return (
2189
- code === "EBUSY" || code === "EPERM" || code === "EACCES" || code === "ENOTEMPTY" || code === "sharing_violation"
3181
+ code === "EBUSY" ||
3182
+ code === "EPERM" ||
3183
+ code === "EACCES" ||
3184
+ code === "ENOTEMPTY" ||
3185
+ code === "access_denied" ||
3186
+ code === "sharing_violation"
2190
3187
  );
2191
3188
  }
2192
3189
 
@@ -2308,14 +3305,23 @@ async function quarantineReleasedLock(
2308
3305
  return false;
2309
3306
  }
2310
3307
 
2311
- async function releaseOwnedLock(lockPath: string, owner: FileLockOwnerToken): Promise<void> {
3308
+ async function releaseOwnedLock(
3309
+ lockPath: string,
3310
+ owner: FileLockOwnerToken,
3311
+ captureInitialIdentity = false,
3312
+ ): Promise<void> {
2312
3313
  if (await finishDetachedLockCleanup(owner)) return;
2313
- let preVerdictIdentity: GenericFileLockDirIdentity | undefined;
2314
- try {
2315
- preVerdictIdentity = (await captureFileLockDirIdentity(lockPath)) ?? undefined;
2316
- if (preVerdictIdentity) fileLockDirIdentities.set(owner, preVerdictIdentity);
2317
- } catch (error) {
2318
- if (!isTransientReleaseError(error)) throw error;
3314
+ // Only the initial release may establish removal authority. Pending retries
3315
+ // must retain that evidence, even if a replacement carries identical info.
3316
+ // If the initial capture failed, a later pathname cannot supply authority.
3317
+ let preVerdictIdentity = fileLockDirIdentities.get(owner);
3318
+ if (captureInitialIdentity && !preVerdictIdentity) {
3319
+ try {
3320
+ preVerdictIdentity = (await captureFileLockDirIdentity(lockPath)) ?? undefined;
3321
+ if (preVerdictIdentity) fileLockDirIdentities.set(owner, preVerdictIdentity);
3322
+ } catch (error) {
3323
+ if (!isTransientReleaseError(error)) throw error;
3324
+ }
2319
3325
  }
2320
3326
  let lastTransientError: unknown;
2321
3327
  for (let attempt = 0; attempt < FILE_LOCK_RELEASE_RETRY_ATTEMPTS; attempt++) {
@@ -2346,7 +3352,7 @@ async function retryPendingLocalRelease(lockPath: string, knownKey?: string): Pr
2346
3352
  return;
2347
3353
  }
2348
3354
  state.status = "releasing";
2349
- const releasePromise = releaseOwnedLock(lockPath, state.owner);
3355
+ const releasePromise = releaseOwnedLock(state.releasePath ?? lockPath, state.owner);
2350
3356
  state.releasePromise = releasePromise;
2351
3357
  try {
2352
3358
  await releasePromise;
@@ -2392,43 +3398,75 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken, knownKey
2392
3398
  return;
2393
3399
  }
2394
3400
  state.status = "releasing";
2395
- const releasePromise = releaseOwnedLock(lockPath, owner);
3401
+ const releasePromise = releaseOwnedLock(lockPath, owner, true);
2396
3402
  state.releasePromise = releasePromise;
2397
3403
  try {
2398
3404
  await releasePromise;
2399
3405
  if (localLockStates.get(key) === state) localLockStates.delete(key);
2400
3406
  } catch (error) {
2401
3407
  state.status = "release_pending";
3408
+ scheduleReleasePendingRetry(lockPath, key, state);
2402
3409
  throw error;
2403
3410
  } finally {
2404
3411
  if (state.releasePromise === releasePromise) state.releasePromise = undefined;
2405
3412
  }
2406
3413
  }
3414
+
3415
+ const RELEASE_PENDING_RETRY_DELAYS_MS = [250, 1_000, 5_000, 15_000, 60_000];
3416
+
3417
+ /**
3418
+ * A failed release otherwise waits for this process's next acquisition of the
3419
+ * same path. An idle process may never acquire again and other processes never
3420
+ * displace a live owner, so retry in the background. Each retry re-runs the full
3421
+ * guarded release and can only remove this owner generation.
3422
+ */
3423
+ function scheduleReleasePendingRetry(lockPath: string, key: string, state: LocalLockState, attempt = 0): void {
3424
+ const delay = RELEASE_PENDING_RETRY_DELAYS_MS[attempt];
3425
+ if (delay === undefined) {
3426
+ logger.warn("File lock release is still pending after background retries", { lockPath });
3427
+ return;
3428
+ }
3429
+ const timer = setTimeout(async () => {
3430
+ if (localLockStates.get(key) !== state) return;
3431
+ await retryPendingLocalRelease(lockPath, key).catch(() => undefined);
3432
+ if (localLockStates.get(key) === state) scheduleReleasePendingRetry(lockPath, key, state, attempt + 1);
3433
+ }, delay);
3434
+ timer.unref?.();
3435
+ }
2407
3436
  /**
2408
3437
  * Bounded, actionable description of who holds `lockPath` at exhaustion time.
2409
3438
  * Never a stealing authority: purely diagnostic, read once after the last retry.
2410
3439
  */
3440
+ interface FileLockHolderDescription {
3441
+ description: string;
3442
+ isLive: boolean;
3443
+ }
3444
+
2411
3445
  async function lockHolderDescription(
2412
3446
  lockPath: string,
2413
3447
  orphanTransitionAgeMs: number,
2414
3448
  ownerHostId?: string,
2415
3449
  previousOwnerHostIds: readonly string[] = [],
2416
- ): Promise<string> {
3450
+ ): Promise<FileLockHolderDescription> {
2417
3451
  try {
2418
- if (process.platform !== "win32") {
2419
- const transitionState = await classifyFileLockRemovalTransition(
2420
- lockPath,
2421
- orphanTransitionAgeMs,
2422
- ownerHostId,
2423
- previousOwnerHostIds,
2424
- );
2425
- if (transitionState === "orphan_transition")
2426
- return `orphan_transition at ${fileLockRemovalTransitionPath(lockPath)}`;
2427
- if (transitionState === "abandoned")
2428
- return `blocked by abandoned removal transition at ${fileLockRemovalTransitionPath(lockPath)}; inspect and remove the directory manually once no publisher remains`;
2429
- if (transitionState === "active")
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`;
2431
- }
3452
+ const transitionState = await classifyFileLockRemovalTransition(
3453
+ lockPath,
3454
+ orphanTransitionAgeMs,
3455
+ ownerHostId,
3456
+ previousOwnerHostIds,
3457
+ );
3458
+ if (transitionState === "orphan_transition")
3459
+ return { description: `orphan_transition at ${fileLockRemovalTransitionPath(lockPath)}`, isLive: false };
3460
+ if (transitionState === "abandoned")
3461
+ return {
3462
+ description: `blocked by abandoned removal transition at ${fileLockRemovalTransitionPath(lockPath)}; inspect and remove the directory manually once no publisher remains`,
3463
+ isLive: false,
3464
+ };
3465
+ if (transitionState === "active")
3466
+ return {
3467
+ description: `blocked by retained removal transition ${fileLockRemovalTransitionPath(lockPath)}; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed`,
3468
+ isLive: false,
3469
+ };
2432
3470
  let info = await readLockInfo(lockPath);
2433
3471
  let bytes: string | null = null;
2434
3472
  if (!info) {
@@ -2450,41 +3488,58 @@ async function lockHolderDescription(
2450
3488
  // is missing instead of naming a host it does not have.
2451
3489
  const provenance =
2452
3490
  info.owner_host_id === undefined ? "on an unrecorded host" : `on host ${info.owner_host_id}`;
2453
- return (
2454
- `held by pid ${info.pid} ${provenance} (liveness unknown from this host)` +
2455
- ` since ${new Date(info.timestamp).toISOString()}`
2456
- );
3491
+ return {
3492
+ description:
3493
+ `held by pid ${info.pid} ${provenance} (liveness unknown from this host)` +
3494
+ ` since ${new Date(info.timestamp).toISOString()}`,
3495
+ isLive: false,
3496
+ };
2457
3497
  }
2458
- // Same-host holder: use the full liveness proof (pid alive AND, when the
2459
- // record carries a start_time, the start-time identity match) so a dead
2460
- // holder whose pid was already reused is not mislabeled "(live)".
2461
- const alive = ownerIsAlive(info);
2462
- const liveness = alive
2463
- ? "live"
2464
- : ownerLiveness(info.pid) === "dead"
2465
- ? "dead but not reaped"
2466
- : "liveness unknown";
2467
- return (
2468
- `held by pid ${info.pid}` +
2469
- // Keep the host on a host-qualified local record: on a shared volume the
2470
- // same pathname is contended from several hosts, so naming the one whose
2471
- // pid space this verdict came from is what makes it actionable.
2472
- (info.owner_host_id === undefined ? "" : " on this host") +
2473
- ` (${liveness})` +
2474
- ` since ${new Date(info.timestamp).toISOString()}`
2475
- );
3498
+ // Same-host holder: label it live only when the PID is alive and its
3499
+ // recorded process identity matches exactly. Conservative uncertainty
3500
+ // still prevents reclamation, but is not proof for operator diagnostics.
3501
+ const pidLiveness = ownerLiveness(info.pid);
3502
+ const alive = pidLiveness === "alive" && ownerIdentityIsProven(info);
3503
+ const liveness = alive ? "live" : pidLiveness === "dead" ? "dead but not reaped" : "liveness unknown";
3504
+ return {
3505
+ description:
3506
+ `held by pid ${info.pid}` +
3507
+ // Keep the host on a host-qualified local record: on a shared volume the
3508
+ // same pathname is contended from several hosts, so naming the one whose
3509
+ // pid space this verdict came from is what makes it actionable.
3510
+ (info.owner_host_id === undefined ? "" : " on this host") +
3511
+ ` (${liveness})` +
3512
+ ` since ${new Date(info.timestamp).toISOString()}`,
3513
+ isLive: alive,
3514
+ };
2476
3515
  }
2477
3516
  try {
2478
3517
  if (bytes !== null)
2479
- return "held by an owner record that never became readable (empty, truncated, or non-JSON info); malformed records carry no liveness proof and are never reclaimed — inspect and remove the directory manually once no publisher remains";
3518
+ return {
3519
+ description:
3520
+ "held by an owner record that never became readable (empty, truncated, or non-JSON info); malformed records carry no liveness proof and are never reclaimed — inspect and remove the directory manually once no publisher remains",
3521
+ isLive: false,
3522
+ };
2480
3523
  await fs.stat(path.join(lockPath, "info"));
2481
- return "held by an owner whose metadata is not yet readable";
3524
+ return { description: "held by an owner whose metadata is not yet readable", isLive: false };
2482
3525
  } catch (error) {
2483
3526
  if (!isEnoent(error)) throw error;
2484
3527
  }
2485
- return "held by an unrecognized owner record";
3528
+ try {
3529
+ if ((await fs.readdir(lockPath)).length === 0) {
3530
+ return {
3531
+ description:
3532
+ "held by an ownerless empty lock directory; inspect and remove this exact directory manually only after " +
3533
+ `confirming no publisher remains: ${manualLockCleanupCommand(lockPath)}`,
3534
+ isLive: false,
3535
+ };
3536
+ }
3537
+ } catch (error) {
3538
+ if (!isEnoent(error)) throw error;
3539
+ }
3540
+ return { description: "held by an unrecognized owner record", isLive: false };
2486
3541
  } catch (error) {
2487
- return `held by an unreadable owner (${(error as Error).message})`;
3542
+ return { description: `held by an unreadable owner (${(error as Error).message})`, isLive: false };
2488
3543
  }
2489
3544
  }
2490
3545
 
@@ -2497,11 +3552,23 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2497
3552
  if (options.previousOwnerHostIds?.some(hostId => !hostId))
2498
3553
  throw new Error("previousOwnerHostIds must contain only non-empty identities");
2499
3554
  const opts = { ...DEFAULT_OPTIONS, ...options };
2500
- const orphanTransitionAgeMs = Math.max(REMOVAL_TRANSITION_GRACE_MS, opts.retries * opts.retryDelayMs);
3555
+ if (opts.retries !== Number.POSITIVE_INFINITY && (!Number.isSafeInteger(opts.retries) || opts.retries < 1))
3556
+ throw new RangeError("retries must be a positive safe integer");
3557
+ const orphanTransitionAgeMs = options.orphanTransitionAgeMs ?? options.staleMs ?? DEFAULT_OPTIONS.staleMs;
3558
+ if (!Number.isFinite(orphanTransitionAgeMs) || orphanTransitionAgeMs < 0)
3559
+ throw new RangeError("orphanTransitionAgeMs must be a finite, non-negative number");
2501
3560
 
2502
3561
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
2503
3562
  const lockPath = getLockPath(filePath);
3563
+ // Bound staging cleanup across the outer retry budget: all staging cleanup retries
3564
+ // use a shared delay budget and are subject to the same abort signal as the lock acquisition.
3565
+ const retryBudget: FileLockAcquisitionRetryBudget = {
3566
+ remainingDelays: opts.retries - 1,
3567
+ retryDelayMs: opts.retryDelayMs,
3568
+ signal: opts.signal,
3569
+ };
2504
3570
  await ensureLockParent(path.dirname(lockPath));
3571
+ await retryPendingStagingCleanups(lockPath, retryBudget);
2505
3572
  try {
2506
3573
  await reapOrphanedLockStagingDirs(lockPath);
2507
3574
  } catch (error) {
@@ -2511,12 +3578,36 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2511
3578
  const contentionStartTimes = new Map<string, string | null>();
2512
3579
  let contentionObserved = false;
2513
3580
  let staleRemovalFailure: RecordedStaleRemovalFailure | undefined;
2514
- for (let attempt = 0; attempt < opts.retries; attempt++) {
2515
- if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
3581
+ let publicationFailureCode: FileLockPublicationFailureCode | undefined;
3582
+ let accessDeniedPublicationAttempts = 0;
3583
+ let attemptsMade = 0;
3584
+ let attemptBudget = opts.retries;
3585
+ let recoveryExtensions = 0;
3586
+ const extendAttemptBudgetAfterCleanup = (): void => {
3587
+ // Reaping is progress, but it still needs a fresh publication attempt. Bound
3588
+ // extensions so a stream of dead generations cannot make acquisition unbounded.
3589
+ if (recoveryExtensions >= opts.retries) return;
3590
+ recoveryExtensions++;
3591
+ attemptBudget++;
3592
+ };
3593
+ let attempt = 0;
3594
+ for (; attempt < attemptBudget; attempt++) {
3595
+ attemptsMade = attempt + 1;
3596
+ throwIfFileLockAcquisitionAborted(opts.signal);
3597
+ await retryPendingStagingCleanups(lockPath, retryBudget);
2516
3598
  const localKey = await localLockKey(lockPath);
3599
+ let publishedState: LocalLockState | undefined;
2517
3600
  const priorRelease = localLockStates.get(localKey);
2518
- if (priorRelease && priorRelease.status !== "held" && pendingDetachedLockCleanups.has(priorRelease.owner)) {
3601
+ if (
3602
+ priorRelease &&
3603
+ priorRelease.status !== "held" &&
3604
+ (pendingDetachedLockCleanups.has(priorRelease.owner) ||
3605
+ (priorRelease.status === "release_pending" &&
3606
+ priorRelease.releasePath !== undefined &&
3607
+ !priorRelease.releasePromise))
3608
+ ) {
2519
3609
  await retryPendingLocalRelease(lockPath, localKey);
3610
+ extendAttemptBudgetAfterCleanup();
2520
3611
  continue;
2521
3612
  }
2522
3613
  const result = await tryAcquireLock(
@@ -2526,21 +3617,60 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2526
3617
  opts.previousOwnerHostIds ?? [],
2527
3618
  ownerToken,
2528
3619
  opts.onAcquired,
3620
+ retryBudget,
3621
+ owner => {
3622
+ const state: LocalLockState = { owner, status: "held" };
3623
+ publishedState = state;
3624
+ localLockStates.set(localKey, state);
3625
+ },
3626
+ owner => {
3627
+ const state = publishedState;
3628
+ if (!state || state.owner.owner_token !== owner.owner_token) return;
3629
+ if (localLockStates.get(localKey) === state) localLockStates.delete(localKey);
3630
+ if (publishedState === state) publishedState = undefined;
3631
+ },
3632
+ (owner, identity, releasePath) => {
3633
+ const state = publishedState;
3634
+ if (!state || state.owner.owner_token !== owner.owner_token || localLockStates.get(localKey) !== state)
3635
+ return;
3636
+ fileLockDirIdentities.set(owner, identity);
3637
+ state.releasePath = releasePath;
3638
+ state.status = "release_pending";
3639
+ scheduleReleasePendingRetry(lockPath, localKey, state);
3640
+ },
2529
3641
  );
2530
- if (isFileLockOrphanTransition(result))
2531
- throw new FileLockAcquireError(
2532
- filePath,
2533
- lockPath,
2534
- attempt + 1,
2535
- `orphan transition retained at ${result.path}`,
2536
- "orphan_transition",
2537
- result.path,
2538
- );
2539
- if (result) {
2540
- localLockStates.set(localKey, { owner: result, status: "held" });
2541
- return () => releaseLock(lockPath, result, localKey);
3642
+ if (isFileLockOrphanTransition(result)) {
3643
+ // Adoption is identity-bound and can be refused by a transient cleanup race.
3644
+ // Keep using the caller's bounded contention budget; the same generation is
3645
+ // revalidated before every later adoption attempt.
3646
+ publicationFailureCode = undefined;
3647
+ accessDeniedPublicationAttempts = 0;
3648
+ if (attempt + 1 === attemptBudget)
3649
+ throw new FileLockAcquireError(
3650
+ filePath,
3651
+ lockPath,
3652
+ opts.retries,
3653
+ `orphan transition retained at ${result.path}`,
3654
+ "orphan_transition",
3655
+ result.path,
3656
+ );
3657
+ } else if (isFileLockPublicationRetry(result)) {
3658
+ publicationFailureCode = result.code;
3659
+ accessDeniedPublicationAttempts = result.code === "access_denied" ? accessDeniedPublicationAttempts + 1 : 0;
3660
+ } else {
3661
+ publicationFailureCode = undefined;
3662
+ accessDeniedPublicationAttempts = 0;
3663
+ if (result) {
3664
+ const state = publishedState ?? { owner: result, status: "held" as const };
3665
+ state.status = "held";
3666
+ localLockStates.set(localKey, state);
3667
+ return () => releaseLock(lockPath, result, localKey);
3668
+ }
2542
3669
  }
2543
- if (!contentionObserved) {
3670
+ throwIfFileLockAcquisitionAborted(opts.signal);
3671
+ if (accessDeniedPublicationAttempts >= MAX_ACCESS_DENIED_PUBLICATION_ACQUISITION_ATTEMPTS) break;
3672
+ const publicationIsProvenContention = isFileLockPublicationRetry(result) && result.code === "sharing_violation";
3673
+ if (!contentionObserved && (!isFileLockPublicationRetry(result) || publicationIsProvenContention)) {
2544
3674
  contentionObserved = true;
2545
3675
  opts.onContended?.();
2546
3676
  }
@@ -2549,6 +3679,7 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2549
3679
  if (pendingKey !== undefined && localState?.status !== "held" && localState?.owner.owner_token !== undefined) {
2550
3680
  try {
2551
3681
  await retryPendingLocalRelease(lockPath, pendingKey);
3682
+ extendAttemptBudgetAfterCleanup();
2552
3683
  continue;
2553
3684
  } catch {
2554
3685
  // Keep contending below. A failed local retry is not authority to steal a
@@ -2565,44 +3696,102 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2565
3696
  const staleRemoval = await removeStaleLockForAcquire(lockPath, stale);
2566
3697
  if (staleRemoval.removed) {
2567
3698
  staleRemovalFailure = undefined;
3699
+ extendAttemptBudgetAfterCleanup();
2568
3700
  continue;
2569
3701
  }
2570
3702
  staleRemovalFailure =
2571
3703
  stale.stale && staleRemoval.failure
2572
3704
  ? { owner: stale.owner, identity: stale.identity, failure: staleRemoval.failure }
2573
3705
  : undefined;
2574
- if (!opts.signal) {
2575
- await Bun.sleep(opts.retryDelayMs);
2576
- continue;
2577
- }
2578
- const { promise, resolve, reject } = Promise.withResolvers<void>();
2579
- const onAbort = (): void => reject(opts.signal?.reason ?? new Error("File lock acquisition aborted"));
2580
- opts.signal.addEventListener("abort", onAbort, { once: true });
2581
- void Bun.sleep(opts.retryDelayMs).then(resolve);
2582
- try {
2583
- await promise;
2584
- } finally {
2585
- opts.signal.removeEventListener("abort", onAbort);
3706
+ if (retryBudget.remainingDelays > 0) {
3707
+ retryBudget.remainingDelays--;
3708
+ await sleepFileLockRetry(opts.retryDelayMs, opts.signal);
2586
3709
  }
2587
3710
  }
3711
+ throwIfFileLockAcquisitionAborted(opts.signal);
3712
+ const holder = await lockHolderDescription(
3713
+ lockPath,
3714
+ orphanTransitionAgeMs,
3715
+ opts.ownerHostId,
3716
+ opts.previousOwnerHostIds ?? [],
3717
+ );
3718
+ const transitionState = await classifyFileLockRemovalTransition(
3719
+ lockPath,
3720
+ orphanTransitionAgeMs,
3721
+ opts.ownerHostId,
3722
+ opts.previousOwnerHostIds ?? [],
3723
+ );
3724
+ const transitionPath = transitionState ? fileLockRemovalTransitionPath(lockPath) : undefined;
2588
3725
  throw new FileLockAcquireError(
2589
3726
  filePath,
2590
3727
  lockPath,
2591
- opts.retries,
2592
- await lockHolderDescription(lockPath, orphanTransitionAgeMs, opts.ownerHostId, opts.previousOwnerHostIds ?? []),
2593
- "acquire_timeout",
2594
- (await classifyFileLockRemovalTransition(
2595
- lockPath,
2596
- orphanTransitionAgeMs,
2597
- opts.ownerHostId,
2598
- opts.previousOwnerHostIds ?? [],
2599
- ))
2600
- ? fileLockRemovalTransitionPath(lockPath)
2601
- : undefined,
3728
+ attemptsMade,
3729
+ holder.description,
3730
+ transitionState === "orphan_transition" ? "orphan_transition" : "acquire_timeout",
3731
+ transitionPath,
2602
3732
  await staleRemovalFailureForCurrentGeneration(lockPath, staleRemovalFailure),
3733
+ publicationFailureCode,
3734
+ holder.isLive,
3735
+ );
3736
+ }
3737
+ /**
3738
+ * Describes the retained removal transition blocking `filePath`, if one exists.
3739
+ *
3740
+ * `acquireFileLock` reports this on exhaustion, but a caller whose own budget
3741
+ * aborts the acquisition first would otherwise never learn that a stale
3742
+ * transition — not ordinary contention — is what blocked it. Such a caller can
3743
+ * ask directly after an abort so the block is surfaced instead of swallowed.
3744
+ */
3745
+ export async function describeRetainedLockTransition(
3746
+ filePath: string,
3747
+ options: FileLockOptions = {},
3748
+ ): Promise<FileLockAcquireError | undefined> {
3749
+ const opts = { ...DEFAULT_OPTIONS, ...options };
3750
+ const orphanTransitionAgeMs = options.orphanTransitionAgeMs ?? options.staleMs ?? DEFAULT_OPTIONS.staleMs;
3751
+ const lockPath = getLockPath(filePath);
3752
+ const previousOwnerHostIds = opts.previousOwnerHostIds ?? [];
3753
+ const transitionState = await classifyFileLockRemovalTransition(
3754
+ lockPath,
3755
+ orphanTransitionAgeMs,
3756
+ opts.ownerHostId,
3757
+ previousOwnerHostIds,
3758
+ );
3759
+ const reportsOrphanTransition = transitionState === "orphan_transition";
3760
+ // An abandoned generation with payload outside the adoption shape is never
3761
+ // reclaimable by this path, so it blocks renewal exactly like a retained orphan
3762
+ // transition. Reporting only `orphan_transition` would leave that case idle.
3763
+ const reportsUnadoptableAbandoned =
3764
+ transitionState === "abandoned" && (await abandonedTransitionIsUnadoptable(lockPath));
3765
+ if (!reportsOrphanTransition && !reportsUnadoptableAbandoned) return undefined;
3766
+ const holder = await lockHolderDescription(lockPath, orphanTransitionAgeMs, opts.ownerHostId, previousOwnerHostIds);
3767
+ return new FileLockAcquireError(
3768
+ filePath,
3769
+ lockPath,
3770
+ opts.retries,
3771
+ holder.description,
3772
+ "orphan_transition",
3773
+ fileLockRemovalTransitionPath(lockPath),
2603
3774
  );
2604
3775
  }
2605
3776
 
3777
+ /** A proven-dead abandoned generation whose payload adoption refuses on sight. */
3778
+ async function abandonedTransitionIsUnadoptable(lockPath: string): Promise<boolean> {
3779
+ const transitionPath = fileLockRemovalTransitionPath(lockPath);
3780
+ if (await readRemovalTransitionOwnerRecord(transitionPath)) return false;
3781
+ if (!(await isNativeExactRemovalUsable())) return false;
3782
+ let captured: NativeDirectoryTreeResult;
3783
+ try {
3784
+ captured = nativeFileLockBindings().snapshotDirectoryTree(transitionPath);
3785
+ } catch (error) {
3786
+ if (isTransientReleaseError(error)) return false;
3787
+ throw error;
3788
+ }
3789
+ if (!captured.ok || !captured.snapshot) return false;
3790
+ // A shape adoption accepts is retried by the ordinary remover; only a refused
3791
+ // payload is a permanent block.
3792
+ return !abandonedTransitionPayloadIsAdoptable(captured.snapshot);
3793
+ }
3794
+
2606
3795
  /**
2607
3796
  * Serializes all contenders, including callers in the same process. Because this
2608
3797
  * API exposes no ownership token, recursive acquisition is indistinguishable
@@ -2630,14 +3819,59 @@ export async function withFileLock<T>(
2630
3819
  return result;
2631
3820
  }
2632
3821
 
2633
- /** Strictly recognize the staging names emitted by tryAcquireLock. */
2634
- export function fileLockStagingOwnerPid(name: string): number | null {
2635
- const match = /^.+\.lock\.pending\.([1-9]\d*)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.exec(
2636
- name,
2637
- );
3822
+ type FileLockStagingIdentity = { kind: "process_incarnation" | "start_time"; value: string };
3823
+ type FileLockStagingOwner = { pid: number; identity?: FileLockStagingIdentity };
3824
+
3825
+ const FILE_LOCK_STAGING_UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
3826
+ const FILE_LOCK_STAGING_NAME_WITH_IDENTITY =
3827
+ /^(?:.+\.lock|\.gjc-lock-[0-9a-f]{64})\.pending\.([1-9]\d*)\.([is][A-Za-z0-9_-]{16})\.([A-Za-z0-9_-]{16})$/;
3828
+ const LEGACY_FILE_LOCK_STAGING_NAME = new RegExp(
3829
+ `^(?:.+\\.lock|\\.gjc-lock-[0-9a-f]{64})\\.pending\\.([1-9]\\d*)\\.${FILE_LOCK_STAGING_UUID}$`,
3830
+ );
3831
+ const FILE_LOCK_STAGING_REMOVING_SUFFIX = ".removing";
3832
+ function fileLockStagingOwner(name: string): FileLockStagingOwner | null {
3833
+ const stagingName = name.endsWith(FILE_LOCK_STAGING_REMOVING_SUFFIX)
3834
+ ? name.slice(0, -FILE_LOCK_STAGING_REMOVING_SUFFIX.length)
3835
+ : name;
3836
+ const current = FILE_LOCK_STAGING_NAME_WITH_IDENTITY.exec(stagingName);
3837
+ const legacy = current ? null : LEGACY_FILE_LOCK_STAGING_NAME.exec(stagingName);
3838
+ const match = current ?? legacy;
2638
3839
  if (!match) return null;
2639
3840
  const pid = Number(match[1]);
2640
- return Number.isSafeInteger(pid) ? pid : null;
3841
+ if (!Number.isSafeInteger(pid)) return null;
3842
+ if (!current) return { pid };
3843
+ const encodedIdentity = current[2] ?? "";
3844
+ return {
3845
+ pid,
3846
+ identity: {
3847
+ kind: encodedIdentity.startsWith("i") ? "process_incarnation" : "start_time",
3848
+ value: encodedIdentity.slice(1),
3849
+ },
3850
+ };
3851
+ }
3852
+
3853
+ function fileLockStagingIdentityHash(identity: string): string {
3854
+ // Ninety-six bits are sufficient to disambiguate process generations while
3855
+ // keeping the sibling name within the old component-length envelope.
3856
+ return crypto.createHash("sha256").update(identity, "utf8").digest("base64url").slice(0, 16);
3857
+ }
3858
+
3859
+ /**
3860
+ * A missing generation is represented by a legacy UUID name that forces a
3861
+ * liveness probe; never hash a synthetic "unknown" as if it were identity evidence.
3862
+ */
3863
+ function currentFileLockStagingIdentity(): string | null {
3864
+ const evidence = FileLockTestHooks.stagingIdentityEvidence?.();
3865
+ const incarnation = evidence ? evidence.processIncarnation : currentProcessIncarnation();
3866
+ if (incarnation) return `i${fileLockStagingIdentityHash(incarnation)}`;
3867
+ const startTime = evidence ? evidence.startTime : currentProcessStartTime();
3868
+ if (!startTime || startTime === "unknown") return null;
3869
+ return `s${fileLockStagingIdentityHash(startTime)}`;
3870
+ }
3871
+
3872
+ /** Return a name-encoded staging PID for GC discovery. */
3873
+ export function fileLockStagingOwnerPid(name: string): number | null {
3874
+ return fileLockStagingOwner(name)?.pid ?? null;
2641
3875
  }
2642
3876
 
2643
3877
  export interface FileLockStagingResult {
@@ -2660,8 +3894,9 @@ export async function inspectFileLockStagingDir(
2660
3894
  removed: false,
2661
3895
  reason: "unverified_staging_directory",
2662
3896
  };
2663
- const namePid = fileLockStagingOwnerPid(path.basename(stagingPath));
2664
- if (namePid === null) return kept;
3897
+ const nameOwner = fileLockStagingOwner(path.basename(stagingPath));
3898
+ if (!nameOwner) return kept;
3899
+ const namePid = nameOwner.pid;
2665
3900
  const canonical = await canonicalLockPathPreservingFinal(stagingPath);
2666
3901
  // A winning acquirer publishes (renames) its staging directory concurrently, so the
2667
3902
  // candidate can disappear between the caller's readdir and this observation.
@@ -2673,6 +3908,36 @@ export async function inspectFileLockStagingDir(
2673
3908
  return { ...kept, reason: "enoent_already_gone" };
2674
3909
  }
2675
3910
  if (!root.isDirectory() || root.isSymbolicLink()) return kept;
3911
+ let namePidStatus: OwnerLiveness;
3912
+ if (nameOwner.identity) {
3913
+ const currentIdentity =
3914
+ nameOwner.identity.kind === "process_incarnation" ? processIncarnation(namePid) : processStartTime(namePid);
3915
+ if (currentIdentity !== null && fileLockStagingIdentityHash(currentIdentity) === nameOwner.identity.value) {
3916
+ return { ...kept, pid: namePid, status: "alive", reason: "live_staging_name_owner" };
3917
+ }
3918
+ const syntheticStartTimeFallback =
3919
+ nameOwner.identity.kind === "start_time" &&
3920
+ (nameOwner.identity.value === fileLockStagingIdentityHash(`${namePid}:unknown`) ||
3921
+ (currentIdentity !== null &&
3922
+ nameOwner.identity.value === fileLockStagingIdentityHash(`${namePid}:${currentIdentity}`)));
3923
+ // Older publishers hashed `${pid}:${startTime}` or `${pid}:unknown` when
3924
+ // start-time evidence was unavailable. A later raw start-time mismatch is
3925
+ // not proof those already-running staging owners are dead.
3926
+ namePidStatus = currentIdentity === null || syntheticStartTimeFallback ? probe(namePid) : "dead";
3927
+ } else {
3928
+ namePidStatus = probe(namePid);
3929
+ }
3930
+ if (namePidStatus !== "dead") {
3931
+ // Do not open the publication source while its owner may be live. On Windows an
3932
+ // info-file handle can prevent the owner's atomic directory rename. Legacy names
3933
+ // without generation evidence and unknown liveness fail closed.
3934
+ return {
3935
+ ...kept,
3936
+ pid: namePid,
3937
+ status: namePidStatus,
3938
+ reason: namePidStatus === "alive" ? "live_staging_name_owner" : "staging_name_owner_liveness_unknown",
3939
+ };
3940
+ }
2676
3941
  const captured = nativeFileLockBindings().snapshotDirectoryTree(canonical);
2677
3942
  if (
2678
3943
  !captured.ok ||
@@ -2702,10 +3967,8 @@ export async function inspectFileLockStagingDir(
2702
3967
  if (captured.snapshot.entries.some(entry => entry.relativePath !== "")) return kept;
2703
3968
  pid = namePid;
2704
3969
  }
2705
- if (pid === process.pid || namePid === process.pid) {
2706
- return { ...kept, pid, status: "alive", reason: "in_process_staging_owner" };
2707
- }
2708
- let status = probe(pid);
3970
+ let status = pid === namePid ? namePidStatus : probe(pid);
3971
+ if (pid === process.pid && observation && !ownerIncarnationChanged(observation.info)) status = "alive";
2709
3972
  if (status === "alive" && observation && ownerIncarnationChanged(observation.info)) status = "dead";
2710
3973
  const result: FileLockStagingResult = {
2711
3974
  path: stagingPath,
@@ -2715,6 +3978,28 @@ export async function inspectFileLockStagingDir(
2715
3978
  reason: `file_lock_staging_owner_${status}`,
2716
3979
  };
2717
3980
  if (status !== "dead" || !remove) return result;
3981
+ if (path.basename(canonical).endsWith(FILE_LOCK_STAGING_REMOVING_SUFFIX)) {
3982
+ const removal = await exactRemoveLockTree(canonical, captured.snapshot);
3983
+ if (removal.ok || removal.code === "not_found") return { ...result, removed: true, reason: "removed" };
3984
+ const detachedTreeWasDurablyScrubbed =
3985
+ removal.code === "cleanup_pending" &&
3986
+ removal.payloadDurable === true &&
3987
+ removal.detachedPath !== undefined &&
3988
+ sameFileLockPath(removal.detachedPath, canonical) &&
3989
+ removal.retainedSuccessorPath === undefined &&
3990
+ removal.retainedPlaceholderPath === undefined &&
3991
+ removal.retainedUnknownPath === undefined &&
3992
+ Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key));
3993
+ if (detachedTreeWasDurablyScrubbed) {
3994
+ const removed = await removeDetachedLockQuarantineOnDisk(
3995
+ canonical,
3996
+ captured.snapshot.rootDev,
3997
+ captured.snapshot.rootIno,
3998
+ );
3999
+ return { ...result, removed, reason: removed ? "removed" : "cleanup_pending" };
4000
+ }
4001
+ return { ...result, reason: removal.code ?? "cleanup_failed" };
4002
+ }
2718
4003
  if (observation) {
2719
4004
  const removal = await removeFileLockDirForGc(canonical, observation.info, observation.identity);
2720
4005
  return { ...result, removed: removal === "removed", reason: removal };
@@ -2764,10 +4049,12 @@ export async function reapOrphanedLockStagingDirs(lockPath: string): Promise<{
2764
4049
  const summary: { removed: string[]; retained: FileLockStagingResult[] } = { removed: [], retained: [] };
2765
4050
  const parent = path.dirname(lockPath);
2766
4051
  const prefix = `${path.basename(lockPath)}.pending.`;
4052
+ const boundedPrefix = `.gjc-lock-${crypto.createHash("sha256").update(path.basename(lockPath)).digest("hex")}.pending.`;
2767
4053
  const entries = await fs.readdir(parent);
2768
4054
  let candidates = 0;
2769
4055
  for (const entry of entries) {
2770
- if (!entry.startsWith(prefix) || fileLockStagingOwnerPid(entry) === null) continue;
4056
+ if ((!entry.startsWith(prefix) && !entry.startsWith(boundedPrefix)) || fileLockStagingOwnerPid(entry) === null)
4057
+ continue;
2771
4058
  if (++candidates > 64) break;
2772
4059
  const result = await inspectFileLockStagingDir(path.join(parent, entry), ownerLiveness, true);
2773
4060
  if (result.removed) summary.removed.push(result.path);