@gajae-code/coding-agent 0.16.7 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -1,6 +1,7 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import type { BigIntStats } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
+ import * as os from "node:os";
4
5
  import * as path from "node:path";
5
6
  import type {
6
7
  NativeDirectoryTreeResult,
@@ -10,6 +11,7 @@ import type {
10
11
  RecoveryFsPublishResult,
11
12
  } from "@gajae-code/natives";
12
13
  import {
14
+ exactRestore,
13
15
  openRecoveryFsRoot,
14
16
  renameDirectoryNoReplacePathAsync,
15
17
  renameNoReplacePathAsync,
@@ -31,23 +33,56 @@ export interface FileLockOptions {
31
33
  previousOwnerHostIds?: readonly string[];
32
34
  }
33
35
 
36
+ export type FileLockAcquireReason = "acquire_timeout" | "orphan_transition";
37
+
38
+ /**
39
+ * Why the acquire path could not retire a lock directory whose owner it had already
40
+ * proven dead. Attached to `FileLockAcquireError` so a host whose lock-removal path
41
+ * refuses the directory is not misreported as a live or unreaped owner. The acquire
42
+ * loop still contends after a refusal (a concurrent reclaimer may finish the same dead
43
+ * generation); this records the cause that exhaustion must report.
44
+ */
45
+ export interface FileLockStaleRemovalFailure {
46
+ /** Guarded-removal outcome the acquire path can record, or `"error"` when the removal attempt threw. */
47
+ outcome: "cleanup_failed" | "error";
48
+ /** Native/errno code carried by the refusal, when one was present. */
49
+ code?: string;
50
+ /** Human-readable cause surfaced to the operator at exhaustion. */
51
+ message: string;
52
+ }
53
+
34
54
  export class FileLockAcquireError extends Error {
35
- readonly code = "acquire_timeout";
55
+ readonly code: FileLockAcquireReason;
36
56
 
37
57
  constructor(
38
58
  readonly filePath: string,
39
59
  readonly lockPath: string,
40
60
  readonly attempts: number,
41
61
  readonly holder: string,
62
+ readonly reason: FileLockAcquireReason = "acquire_timeout",
63
+ readonly orphanPath?: string,
64
+ readonly removalFailure?: FileLockStaleRemovalFailure,
42
65
  ) {
66
+ const detail = reason === "orphan_transition" && orphanPath ? `orphan_transition at ${orphanPath}` : holder;
67
+ const removalDetail = removalFailure
68
+ ? ` The dead owner's lock directory could not be reaped on this host (${removalFailure.message}${
69
+ removalFailure.code ? ` [${removalFailure.code}]` : ""
70
+ })`
71
+ : "";
43
72
  super(
44
- `Failed to acquire lock for ${filePath} after ${attempts} attempts: ${holder} (${lockPath}); ` +
45
- `a live owner is never displaced — if this is an SDK broker (gjc sdk status), it must finish or be stopped before retrying`,
73
+ `Failed to acquire lock for ${filePath} after ${attempts} attempts: ${detail}${removalDetail} (${lockPath}); ` +
74
+ `a live owner is never displaced — if this is an SDK broker (gjc sdk session list), it must finish or be stopped before retrying; ` +
75
+ `the lock is a directory, remove it only by deleting the directory (${lockPath}) once no live owner remains`,
46
76
  );
77
+ this.code = reason;
47
78
  this.name = "FileLockAcquireError";
48
79
  }
49
80
  }
50
81
 
82
+ export function isFileLockAcquireTimeout(error: unknown): error is FileLockAcquireError {
83
+ return error instanceof FileLockAcquireError && error.code === "acquire_timeout";
84
+ }
85
+
51
86
  const DEFAULT_OPTIONS: Required<
52
87
  Omit<FileLockOptions, "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired">
53
88
  > = {
@@ -83,6 +118,7 @@ export const FileLockTestHooks: {
83
118
  renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
84
119
  };
85
120
  nativeQuarantineBindings?: () => NativeFileLockBindings;
121
+ nativeExactRemovalProbe?: () => boolean | Promise<boolean>;
86
122
  } = {};
87
123
 
88
124
  /**
@@ -453,7 +489,41 @@ export interface FileLockOwnerToken {
453
489
  * another process may copy into a new lock generation.
454
490
  */
455
491
  const fileLockDirIdentities = new WeakMap<object, GenericFileLockDirIdentity>();
456
- const pendingDetachedLockCleanups = new WeakMap<object, { path: string; rootDev: string; rootIno: string }>();
492
+ const pendingDetachedLockCleanups = new WeakMap<
493
+ object,
494
+ { path: string; rootDev: string; rootIno: string; snapshot: NativeDirectoryTreeSnapshot }
495
+ >();
496
+
497
+ /**
498
+ * Complete a detached lock quarantine without the native exact-removal primitive.
499
+ * The tree was already retired through a handle-bound no-replace detach, so the
500
+ * parked root identity is re-verified immediately before the filesystem removes
501
+ * it; an identity mismatch leaves the quarantine untouched for a later attempt.
502
+ * Returns true when the quarantine is gone (or was already absent).
503
+ */
504
+ async function removeDetachedLockQuarantineOnDisk(
505
+ detachedPath: string,
506
+ rootDev: string,
507
+ rootIno: string,
508
+ ): Promise<boolean> {
509
+ let current: BigIntStats;
510
+ try {
511
+ current = await fs.lstat(detachedPath, { bigint: true });
512
+ } catch (error) {
513
+ if (isEnoent(error)) return true;
514
+ throw error;
515
+ }
516
+ if (
517
+ !current.isDirectory() ||
518
+ current.isSymbolicLink() ||
519
+ current.dev.toString() !== rootDev ||
520
+ current.ino.toString() !== rootIno
521
+ ) {
522
+ return false;
523
+ }
524
+ await fs.rm(detachedPath, { recursive: true, force: true });
525
+ return true;
526
+ }
457
527
 
458
528
  async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boolean> {
459
529
  const pending = pendingDetachedLockCleanups.get(owner);
@@ -468,7 +538,17 @@ async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boo
468
538
  ) {
469
539
  throw new Error("Detached file lock cleanup identity changed; refusing removal");
470
540
  }
471
- await fs.rm(pending.path, { recursive: true, force: true });
541
+ // The native replay only exists where the native exact-removal primitive is
542
+ // usable. A host whose minifilter rejects that primitive would otherwise never
543
+ // finish the quarantine its own fallback created, so finish on disk instead.
544
+ if (process.platform === "win32" && (await isNativeExactRemovalUsable())) {
545
+ const removal = nativeFileLockBindings().exactRemoveDirectoryTree(pending.path, pending.snapshot);
546
+ if (removal.code === "cleanup_pending") return false;
547
+ if (!removal.ok && removal.code !== "not_found")
548
+ throw new Error(`Failed to finish detached file lock cleanup: ${removal.code ?? "unknown"}.`);
549
+ } else {
550
+ await fs.rm(pending.path, { recursive: true, force: true });
551
+ }
472
552
  } catch (error) {
473
553
  if (!isEnoent(error)) throw error;
474
554
  }
@@ -484,14 +564,115 @@ function fileLockRemovalTransitionPath(lockPath: string): string {
484
564
  return `${lockPath}.removing`;
485
565
  }
486
566
 
487
- async function fileLockRemovalTransitionExists(lockPath: string): Promise<boolean> {
567
+ type FileLockRemovalTransitionState = "active" | "abandoned" | "orphan_transition";
568
+ type FileLockOrphanTransition = { kind: "orphan_transition"; path: string };
569
+ type FileLockAcquisitionResult = LockInfo | FileLockOrphanTransition | null;
570
+
571
+ async function classifyFileLockRemovalTransition(
572
+ lockPath: string,
573
+ orphanAgeMs: number,
574
+ ownerHostId?: string,
575
+ previousOwnerHostIds: readonly string[] = [],
576
+ ): Promise<FileLockRemovalTransitionState | null> {
577
+ const transitionPath = fileLockRemovalTransitionPath(lockPath);
488
578
  try {
489
- await fs.lstat(fileLockRemovalTransitionPath(lockPath));
490
- return true;
579
+ const transition = await fs.lstat(transitionPath);
580
+ if (!transition.isDirectory() || transition.isSymbolicLink()) return "active";
491
581
  } catch (error) {
492
- if (isEnoent(error)) return false;
582
+ if (isEnoent(error)) return null;
493
583
  throw error;
494
584
  }
585
+
586
+ const observation = await readLockInfoObservation(transitionPath);
587
+ if (!observation) return "active";
588
+ const info = parseLockInfoBytes(observation.bytes);
589
+ if (info) {
590
+ const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds);
591
+ return stale.stale ? "abandoned" : "active";
592
+ }
593
+
594
+ const infoAgeMs = Date.now() - Number(observation.state.file.mtimeNs / 1_000_000n);
595
+ return Number.isFinite(infoAgeMs) && infoAgeMs >= orphanAgeMs ? "orphan_transition" : "active";
596
+ }
597
+
598
+ function isFileLockOrphanTransition(value: FileLockAcquisitionResult): value is FileLockOrphanTransition {
599
+ return value !== null && "kind" in value && value.kind === "orphan_transition";
600
+ }
601
+
602
+ /**
603
+ * Whether `snapshot` has the exact shape the native removal primitive leaves
604
+ * behind after its payload scrub: a directory tree in which every file entry
605
+ * has already been truncated to zero bytes. Only the identity-bound native
606
+ * exact-removal primitive produces that residue, and it does so after every
607
+ * authorized payload mutation is complete, so the shape itself is the proof
608
+ * that no live owner can still be using the tree.
609
+ */
610
+ function isScrubbedRemovalTransition(snapshot: NativeDirectoryTreeSnapshot): boolean {
611
+ if (snapshot.entries.length === 0) return false;
612
+ const root = snapshot.entries.find(entry => entry.relativePath === "");
613
+ if (root?.kind !== "directory") return false;
614
+ return snapshot.entries.every(entry => entry.kind === "directory" || entry.size === "0");
615
+ }
616
+
617
+ /**
618
+ * Adopt-and-finish a provably orphaned POSIX removal transition.
619
+ *
620
+ * On POSIX the native exact-removal primitive detaches the verified tree to its
621
+ * deterministic `<lock>.removing` sibling, scrubs every authorized file payload
622
+ * (truncating each to zero bytes), and hands the retained tree to the caller for
623
+ * the final on-disk unlink. A process SIGKILLed in that gap leaves the scrubbed
624
+ * tree behind with a zero-byte `info`, so the original owner record is gone and
625
+ * no token or liveness proof can identify the owner. Ownership is therefore
626
+ * proven by the object: a plain `.removing` directory whose snapshot is the
627
+ * native scrub residue and whose `info` mtime is at least the full acquisition
628
+ * budget old. Finishing that removal deletes only already-scrubbed residue and
629
+ * never live lock state; any successor, placeholder, unscrubbed tree, or
630
+ * transient native refusal is returned unadopted so the caller keeps the typed
631
+ * `orphan_transition` diagnostic.
632
+ */
633
+ async function adoptOrphanedFileLockRemovalTransition(lockPath: string, orphanAgeMs: number): Promise<boolean> {
634
+ const transitionPath = fileLockRemovalTransitionPath(lockPath);
635
+ // Re-validate at the moment of adoption: a successor that published a parsed
636
+ // owner or replaced the tree must never inherit an earlier orphan verdict.
637
+ if ((await classifyFileLockRemovalTransition(lockPath, orphanAgeMs)) !== "orphan_transition") return false;
638
+ const captured = snapshotDirectoryTree(transitionPath);
639
+ if (!captured.ok || !captured.snapshot) return false;
640
+ const snapshot = captured.snapshot;
641
+ if (!isScrubbedRemovalTransition(snapshot)) return false;
642
+ let removal: NativeExactUnlinkResult;
643
+ try {
644
+ removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
645
+ } catch (error) {
646
+ if (isTransientReleaseError(error)) return false;
647
+ throw error;
648
+ }
649
+ if (removal.ok === true) {
650
+ return removal.code === undefined && Object.keys(removal).every(key => key === "ok");
651
+ }
652
+ if (
653
+ removal.ok === false &&
654
+ removal.code === "not_found" &&
655
+ Object.keys(removal).every(key => key === "ok" || key === "code")
656
+ )
657
+ return true;
658
+ // POSIX cannot bind a namespace unlink to the verified descriptor, so the
659
+ // primitive retains the scrubbed tree under its deterministic name and the
660
+ // caller finishes the on-disk removal. Finish only the exact tree this call
661
+ // scrubbed; a retained successor/placeholder/unknown path is refused so a
662
+ // replacement is never deleted.
663
+ if (
664
+ removal.ok === false &&
665
+ removal.code === "cleanup_pending" &&
666
+ removal.payloadDurable === true &&
667
+ removal.detachedPath !== undefined &&
668
+ path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
669
+ removal.retainedSuccessorPath === undefined &&
670
+ removal.retainedPlaceholderPath === undefined &&
671
+ removal.retainedUnknownPath === undefined &&
672
+ Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
673
+ )
674
+ return await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
675
+ return false;
495
676
  }
496
677
 
497
678
  function sameFileLockTreeAfterPublication(
@@ -520,12 +701,84 @@ function sameFileLockTreeAfterPublication(
520
701
  );
521
702
  }
522
703
 
523
- function rollbackPublishedFileLock(
704
+ async function matchesCommittedFileLockPublication(
705
+ pendingPath: string,
706
+ lockPath: string,
707
+ staged: NativeDirectoryTreeSnapshot,
708
+ ): Promise<boolean> {
709
+ try {
710
+ // A replacement, including a dangling symlink, is not a consumed staging name.
711
+ await fs.lstat(pendingPath);
712
+ return false;
713
+ } catch (error) {
714
+ if (!isEnoent(error)) throw error;
715
+ }
716
+ const published = snapshotDirectoryTree(lockPath);
717
+ return (
718
+ published.ok && published.snapshot !== undefined && sameFileLockTreeAfterPublication(staged, published.snapshot)
719
+ );
720
+ }
721
+
722
+ async function rollbackPublishedFileLock(
524
723
  canonicalParent: string,
525
724
  lockPath: string,
526
725
  pendingPath: string,
527
726
  stagedSnapshot: NativeDirectoryTreeSnapshot,
528
- ): void {
727
+ ): Promise<void> {
728
+ if (process.platform !== "linux") {
729
+ // RecoveryFsRoot is Linux-only. POSIX exact restore retains the parent
730
+ // and checks the directory identity around a same-kind placeholder exchange.
731
+ const root = stagedSnapshot.entries.find(entry => entry.relativePath === "");
732
+ if (!root) throw new Error("File lock rollback snapshot has no root");
733
+ const rollback = exactRestore(lockPath, pendingPath, {
734
+ dev: BigInt(stagedSnapshot.rootDev),
735
+ ino: BigInt(stagedSnapshot.rootIno),
736
+ size: BigInt(root.size),
737
+ mtimeNs: BigInt(root.mtimeNs),
738
+ directory: true,
739
+ });
740
+ const placeholder = rollback.retainedPlaceholderPath;
741
+ const retainedPlaceholder =
742
+ !rollback.ok &&
743
+ rollback.code === "cleanup_pending" &&
744
+ placeholder !== undefined &&
745
+ rollback.detachedPath === undefined &&
746
+ rollback.retainedSuccessorPath === undefined &&
747
+ rollback.retainedUnknownPath === undefined;
748
+ if (!rollback.ok && !retainedPlaceholder) {
749
+ throw new Error(
750
+ `Failed to roll back file lock published during removal transition: ${rollback.code ?? "unknown"}.`,
751
+ );
752
+ }
753
+ // A root-only restore is not authority to delete substituted descendants.
754
+ // Leave the retained tree untouched unless the complete snapshot still matches.
755
+ const restored = snapshotDirectoryTree(pendingPath);
756
+ if (!restored.ok || !restored.snapshot || !sameFileLockTreeAfterPublication(stagedSnapshot, restored.snapshot)) {
757
+ throw new Error(`Failed to verify rolled back file lock: ${restored.code ?? "identity_mismatch"}.`);
758
+ }
759
+ if (retainedPlaceholder && placeholder !== undefined) {
760
+ // exactRestore returns a retained, empty exchange placeholder. Its native
761
+ // name encodes the identity it verified, not authority over an arbitrary path.
762
+ const identity = /^\.gjc-exact-unlink-placeholder-([0-9a-f]+)-([0-9a-f]+)$/.exec(placeholder);
763
+ if (!identity) throw new Error("File lock rollback placeholder identity is unavailable");
764
+ const placeholderPath = path.join(canonicalParent, placeholder);
765
+ try {
766
+ const current = await fs.lstat(placeholderPath, { bigint: true });
767
+ if (
768
+ !current.isDirectory() ||
769
+ current.isSymbolicLink() ||
770
+ current.dev !== BigInt(`0x${identity[1]}`) ||
771
+ current.ino !== BigInt(`0x${identity[2]}`)
772
+ ) {
773
+ throw new Error("File lock rollback placeholder identity changed; refusing removal");
774
+ }
775
+ await fs.rmdir(placeholderPath);
776
+ } catch (error) {
777
+ if (!isEnoent(error)) throw error;
778
+ }
779
+ }
780
+ return;
781
+ }
529
782
  const authority = openRecoveryFsRoot(canonicalParent);
530
783
  let rollback: RecoveryFsPublishResult | undefined;
531
784
  const errors: unknown[] = [];
@@ -668,15 +921,59 @@ function isValidNativeNoReplaceResult(value: unknown): value is NativeNoReplaceR
668
921
  return false;
669
922
  if (value.ok)
670
923
  return (
924
+ value.code === undefined &&
925
+ value.diagnostic.collectionState === "unavailable" &&
926
+ value.diagnostic.osCode === undefined &&
927
+ value.diagnostic.syncFailures === undefined &&
928
+ Object.keys(value.diagnostic).length === 2 &&
929
+ value.primitive !== "unsupported" &&
930
+ value.primitive !== "unknown" &&
671
931
  value.mutationState === "committed" &&
672
932
  value.reason === "none" &&
673
933
  value.phase === "complete" &&
674
- (value.durabilityState === "not_attempted" || value.durabilityState === "proven")
934
+ value.durabilityState === "not_attempted"
675
935
  );
676
936
  if (value.mutationState === "not_committed") return value.durabilityState === "not_attempted";
937
+ if (value.mutationState === "committed") {
938
+ // This is a committed namespace change, not authority to retry publication.
939
+ // DrvFS can hide the renamed directory until the native source handle closes.
940
+ return (
941
+ value.code === "destination_identity_changed" &&
942
+ value.durabilityState === "not_provable" &&
943
+ value.reason === "identity_violation" &&
944
+ value.primitive === "mkdirat_renameat_noreplace" &&
945
+ value.phase === "terminal_identity" &&
946
+ value.diagnostic.collectionState === "unavailable" &&
947
+ Object.keys(value.diagnostic).length === 2
948
+ );
949
+ }
677
950
  return value.mutationState === "unknown" && value.durabilityState === "not_provable";
678
951
  }
679
952
 
953
+ function isSuccessfulNativePublication(value: unknown, operation: "primary" | "directory"): boolean {
954
+ if (!isValidNativeNoReplaceResult(value) || !value.ok) return false;
955
+ if (operation === "directory") return value.primitive === "mkdirat_renameat_noreplace";
956
+ switch (process.platform) {
957
+ case "linux":
958
+ return value.primitive === "renameat2_noreplace";
959
+ case "darwin":
960
+ return value.primitive === "renameatx_np_excl";
961
+ case "win32":
962
+ return value.primitive === "windows_rename_noreplace";
963
+ default:
964
+ return false;
965
+ }
966
+ }
967
+
968
+ function isCommittedDirectoryVerificationFailure(value: unknown): value is NativeNoReplaceResult {
969
+ return (
970
+ process.platform === "linux" &&
971
+ isValidNativeNoReplaceResult(value) &&
972
+ !value.ok &&
973
+ value.mutationState === "committed"
974
+ );
975
+ }
976
+
680
977
  /**
681
978
  * Only a complete native envelope proving that no namespace mutation happened
682
979
  * may authorize the directory fallback. A legacy or malformed result is
@@ -763,6 +1060,136 @@ function sameGenericFileLockDirIdentity(left: GenericFileLockDirIdentity, right:
763
1060
  );
764
1061
  }
765
1062
 
1063
+ let nativeExactRemovalUsable: boolean | undefined;
1064
+ let nativeExactRemovalProbePromise: Promise<boolean> | undefined;
1065
+
1066
+ async function probeNativeExactRemoval(): Promise<boolean> {
1067
+ const testProbe = FileLockTestHooks.nativeExactRemovalProbe;
1068
+ if (testProbe) return await testProbe();
1069
+ if (process.platform !== "win32") return true;
1070
+
1071
+ let root: string;
1072
+ try {
1073
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-lock-probe-"));
1074
+ } catch {
1075
+ // There is no evidence that native exact removal is unusable. Keep the
1076
+ // identity-bound path rather than changing cleanup semantics on an
1077
+ // unrelated temporary-directory failure.
1078
+ return true;
1079
+ }
1080
+ const probeDir = path.join(root, "probe.lock");
1081
+ try {
1082
+ await fs.mkdir(probeDir, { recursive: true, mode: 0o700 });
1083
+ await fs.writeFile(path.join(probeDir, "info"), "probe");
1084
+ const captured = nativeFileLockBindings().snapshotDirectoryTree(probeDir);
1085
+ if (!captured.ok || !captured.snapshot) return false;
1086
+ return nativeFileLockBindings().exactRemoveDirectoryTree(probeDir, captured.snapshot).ok === true;
1087
+ } catch {
1088
+ return false;
1089
+ } finally {
1090
+ await fs.rm(root, { recursive: true, force: true }).catch(() => undefined);
1091
+ }
1092
+ }
1093
+
1094
+ async function isNativeExactRemovalUsable(): Promise<boolean> {
1095
+ if (FileLockTestHooks.nativeExactRemovalProbe) return await probeNativeExactRemoval();
1096
+ if (process.platform !== "win32") return true;
1097
+ if (FileLockTestHooks.nativeQuarantineBindings) return true;
1098
+ if (nativeExactRemovalUsable !== undefined) return nativeExactRemovalUsable;
1099
+ let probe = nativeExactRemovalProbePromise;
1100
+ if (!probe) {
1101
+ probe = probeNativeExactRemoval().then(usable => {
1102
+ nativeExactRemovalUsable = usable;
1103
+ return usable;
1104
+ });
1105
+ nativeExactRemovalProbePromise = probe;
1106
+ }
1107
+ try {
1108
+ return await probe;
1109
+ } finally {
1110
+ if (nativeExactRemovalProbePromise === probe) nativeExactRemovalProbePromise = undefined;
1111
+ }
1112
+ }
1113
+
1114
+ async function removeVerifiedLockDirWithoutNative(
1115
+ lockDir: string,
1116
+ expected: NonNullable<NativeDirectoryTreeResult["snapshot"]>,
1117
+ owner?: FileLockOwnerToken,
1118
+ ): Promise<FileLockGcRemoval> {
1119
+ let parent: BigIntStats;
1120
+ try {
1121
+ parent = await fs.lstat(path.dirname(lockDir), { bigint: true });
1122
+ } catch (error) {
1123
+ if (isEnoent(error)) return "owner_changed";
1124
+ if (isTransientReleaseError(error)) throw error;
1125
+ return "cleanup_failed";
1126
+ }
1127
+ const removal = nativeFileLockBindings().exactRemoveDirectoryTree(
1128
+ lockDir,
1129
+ expected,
1130
+ { dev: parent.dev, ino: parent.ino },
1131
+ true,
1132
+ );
1133
+ if (removal.ok && !removal.detachedPath) return "removed";
1134
+ if (removal.code === "not_found") return "removed";
1135
+ if (removal.code === "identity_mismatch" || removal.code === "parent_mismatch") return "owner_changed";
1136
+ if (!removal.detachedPath) return "cleanup_failed";
1137
+ if (owner) {
1138
+ pendingDetachedLockCleanups.set(owner, {
1139
+ path: removal.detachedPath,
1140
+ rootDev: expected.rootDev,
1141
+ rootIno: expected.rootIno,
1142
+ snapshot: expected,
1143
+ });
1144
+ return (await finishDetachedLockCleanup(owner)) ? "removed" : "cleanup_failed";
1145
+ }
1146
+ // The native exact-removal primitive is unavailable by definition on this path.
1147
+ // Replaying it here would recreate the very failure the fallback exists to avoid,
1148
+ // so complete the parked tree with an identity-checked filesystem removal instead.
1149
+ if (
1150
+ removal.retainedSuccessorPath !== undefined ||
1151
+ removal.retainedPlaceholderPath !== undefined ||
1152
+ removal.retainedUnknownPath !== undefined
1153
+ )
1154
+ return "cleanup_failed";
1155
+ return (await removeDetachedLockQuarantineOnDisk(removal.detachedPath, expected.rootDev, expected.rootIno))
1156
+ ? "removed"
1157
+ : "cleanup_failed";
1158
+ }
1159
+
1160
+ async function removeVerifiedOwnedLockDirWithoutNative(
1161
+ lockDir: string,
1162
+ expected: GenericFileLockDirIdentity,
1163
+ owner: FileLockOwnerToken,
1164
+ ): Promise<FileLockGcRemoval> {
1165
+ try {
1166
+ await fs.lstat(lockDir, { bigint: true });
1167
+ } catch (error) {
1168
+ if (isEnoent(error)) return "removed";
1169
+ if (isTransientReleaseError(error)) throw error;
1170
+ return "cleanup_failed";
1171
+ }
1172
+ const current = await captureFileLockDirIdentity(lockDir);
1173
+ if (!current || !sameGenericFileLockDirIdentity(current, expected)) return "owner_changed";
1174
+ const captured = nativeFileLockBindings().snapshotDirectoryTree(lockDir);
1175
+ if (!captured.ok || !captured.snapshot) return "owner_changed";
1176
+ const infoEntry = captured.snapshot.entries.find(entry => entry.relativePath === "info");
1177
+ if (
1178
+ captured.snapshot.rootDev !== expected.rootDev ||
1179
+ captured.snapshot.rootIno !== expected.rootIno ||
1180
+ !infoEntry ||
1181
+ infoEntry.dev !== expected.infoDev ||
1182
+ infoEntry.ino !== expected.infoIno ||
1183
+ infoEntry.nlink !== expected.infoNlink ||
1184
+ infoEntry.size !== expected.infoSize ||
1185
+ infoEntry.mtimeNs !== expected.infoMtimeNs ||
1186
+ infoEntry.ctimeNs !== expected.infoCtimeNs ||
1187
+ infoEntry.sha256 !== expected.infoSha256
1188
+ )
1189
+ return "owner_changed";
1190
+ return await removeVerifiedLockDirWithoutNative(lockDir, captured.snapshot, owner);
1191
+ }
1192
+
766
1193
  export type GenericFileLockDirStaleVerdict = { stale: false } | { stale: true; identity: GenericFileLockDirIdentity };
767
1194
 
768
1195
  /**
@@ -801,16 +1228,11 @@ export async function removeFileLockDirForGc(
801
1228
  const current = onDiskBytes === null ? null : parseLockInfoBytes(onDiskBytes);
802
1229
  if (!current || onDiskBytes === null) return "missing";
803
1230
  if (!expectedIdentity) return "owner_changed";
804
- if (
805
- current.pid !== expected.pid ||
806
- (expected.process_incarnation !== undefined && current.process_incarnation !== expected.process_incarnation) ||
807
- (expected.start_time !== undefined && current.start_time !== expected.start_time) ||
808
- current.owner_host_id !== expected.owner_host_id ||
809
- (expected.owner_token !== undefined && current.owner_token !== expected.owner_token) ||
810
- current.timestamp !== expected.timestamp
811
- ) {
1231
+ if (!sameFileLockOwnerToken(current, expected)) {
812
1232
  return "owner_changed";
813
1233
  }
1234
+ if (!(await isNativeExactRemovalUsable()))
1235
+ return await removeVerifiedOwnedLockDirWithoutNative(lockDir, expectedIdentity, expected);
814
1236
  // The token comparison above authorizes the content that was judged, not the
815
1237
  // pathname. When the caller carried pre-verdict root/info identity, require
816
1238
  // the post-verdict native snapshot to match that same object before removal;
@@ -847,6 +1269,45 @@ export async function removeFileLockDirForGc(
847
1269
  let removed: NativeExactUnlinkResult;
848
1270
  try {
849
1271
  removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
1272
+ const retainedPath = fileLockRemovalTransitionPath(nativeCapturePath);
1273
+ if (
1274
+ process.platform === "linux" &&
1275
+ !nativeCapturePath.endsWith(".removing") &&
1276
+ removed.ok === false &&
1277
+ removed.code === "identity_mismatch" &&
1278
+ removed.retainedSuccessorPath === retainedPath &&
1279
+ Object.keys(removed).every(key => ["ok", "code", "retainedSuccessorPath"].includes(key))
1280
+ ) {
1281
+ // DrvFS may hide the detached name while native code retains the source
1282
+ // handle. The receipt alone never authorizes deleting a reported successor:
1283
+ // require the complete original tree, then replay exact removal once at
1284
+ // the deterministic retained name, where no further rename is necessary.
1285
+ const retained = nativeFileLockBindings().snapshotDirectoryTree(retainedPath);
1286
+ if (
1287
+ retained.ok &&
1288
+ retained.snapshot &&
1289
+ sameFileLockTreeAfterPublication(captured.snapshot, retained.snapshot)
1290
+ ) {
1291
+ const replay = nativeFileLockBindings().exactRemoveDirectoryTree(retainedPath, retained.snapshot);
1292
+ if (
1293
+ replay.ok !== false ||
1294
+ replay.code !== "cleanup_pending" ||
1295
+ replay.payloadDurable !== true ||
1296
+ replay.detachedPath !== retainedPath ||
1297
+ Object.keys(replay).some(key => !["ok", "code", "payloadDurable", "detachedPath"].includes(key))
1298
+ ) {
1299
+ // A replay failure can carry detachedPath without authorizing payload
1300
+ // cleanup. Never forward such a receipt to the generic detach handler.
1301
+ logger.debug("Detached file lock replay did not prove durable cleanup", {
1302
+ originalCode: removed.code,
1303
+ replayCode: replay.code,
1304
+ });
1305
+ return "cleanup_failed";
1306
+ }
1307
+ removed = replay;
1308
+ logger.debug("Replayed identity-matched detached file lock removal", { code: removed.code });
1309
+ }
1310
+ }
850
1311
  } catch (error) {
851
1312
  // Keep the #2478 transient retry contract: sharing denials surface with
852
1313
  // their transient code so callers retry, everything else is a refusal.
@@ -873,9 +1334,9 @@ export async function removeFileLockDirForGc(
873
1334
  path: detachedPath,
874
1335
  rootDev: captured.snapshot.rootDev,
875
1336
  rootIno: captured.snapshot.rootIno,
1337
+ snapshot: captured.snapshot,
876
1338
  });
877
- await finishDetachedLockCleanup(expected);
878
- return "removed";
1339
+ return (await finishDetachedLockCleanup(expected)) ? "removed" : "cleanup_failed";
879
1340
  }
880
1341
  if (removed.code === "not_found") return "removed";
881
1342
  if (removed.code === "identity_mismatch") return "owner_changed";
@@ -930,9 +1391,11 @@ async function staleLockSnapshot(
930
1391
  }
931
1392
  if (!info) {
932
1393
  // A directory without a valid owner record is either a contender between
933
- // native mkdirat ownership and metadata publication, or malformed/foreign
934
- // state. Neither case carries enough identity evidence for stale removal;
935
- // elapsed mtime must never make this empty namespace reclaimable.
1394
+ // native directory ownership and metadata publication, or malformed state
1395
+ // with no PID/incarnation/host proof of any process generation. Neither
1396
+ // case carries liveness evidence, so elapsed time and byte stability must
1397
+ // never make this namespace reclaimable: only an independently committed
1398
+ // owner record proving its process generation dead authorizes removal.
936
1399
  return { stale: false };
937
1400
  }
938
1401
 
@@ -980,16 +1443,63 @@ async function staleLockSnapshot(
980
1443
  return { stale: false };
981
1444
  }
982
1445
 
983
- async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSnapshot): Promise<boolean> {
984
- if (!snapshot.stale) return false;
1446
+ type StaleLockRemovalAttempt = { removed: true } | { removed: false; failure?: FileLockStaleRemovalFailure };
1447
+
1448
+ type RecordedStaleRemovalFailure = { owner: FileLockOwnerToken; failure: FileLockStaleRemovalFailure };
1449
+
1450
+ /**
1451
+ * A recorded refusal describes exactly one dead owner generation. Re-read the pathname at
1452
+ * exhaustion and report it only while that same generation still owns the directory, so a
1453
+ * refusal recorded for a dead generation is never pinned onto a live successor that took
1454
+ * the pathname over before the budget ran out.
1455
+ */
1456
+ async function staleRemovalFailureForCurrentGeneration(
1457
+ lockPath: string,
1458
+ recorded: RecordedStaleRemovalFailure | undefined,
1459
+ ): Promise<FileLockStaleRemovalFailure | undefined> {
1460
+ if (!recorded) return undefined;
985
1461
  try {
986
- return (await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity)) === "removed";
1462
+ const current = await readLockInfo(lockPath);
1463
+ return current && sameFileLockOwnerToken(current, recorded.owner) ? recorded.failure : undefined;
987
1464
  } catch {
988
- // Exact removal refusal is not authority to fail or mutate by another path.
989
- // Keep contending: a concurrent reclaimer may already be completing the same
990
- // dead generation, while a persistent refusal remains fail-closed until the
991
- // normal retry budget reports the still-held lock.
992
- return false;
1465
+ return undefined;
1466
+ }
1467
+ }
1468
+
1469
+ async function removeStaleLockForAcquire(
1470
+ lockPath: string,
1471
+ snapshot: LockStaleSnapshot,
1472
+ ): Promise<StaleLockRemovalAttempt> {
1473
+ if (!snapshot.stale) return { removed: false };
1474
+ try {
1475
+ const outcome = await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity);
1476
+ if (outcome === "removed") return { removed: true };
1477
+ // `owner_changed`/`missing` mean a successor or a concurrent reclaimer already
1478
+ // owns the pathname; that is ordinary contention, not a host removal failure.
1479
+ if (outcome !== "cleanup_failed") return { removed: false };
1480
+ return {
1481
+ removed: false,
1482
+ failure: {
1483
+ outcome,
1484
+ message: (await isNativeExactRemovalUsable())
1485
+ ? "the identity-bound lock-removal path refused the dead owner's lock directory"
1486
+ : "the native exact-removal primitive is unavailable on this host and the verified detach fallback was refused",
1487
+ },
1488
+ };
1489
+ } catch (error) {
1490
+ // A removal refusal — transient or not — is not authority to fail or mutate by
1491
+ // another path. Keep contending, because a concurrent reclaimer may already be
1492
+ // completing the same dead generation, and record the cause so exhaustion
1493
+ // reports *why* a dead owner's lock could not be reaped instead of only
1494
+ // "dead but not reaped".
1495
+ return {
1496
+ removed: false,
1497
+ failure: {
1498
+ outcome: "error",
1499
+ code: (error as NodeJS.ErrnoException).code,
1500
+ message: (error as Error).message,
1501
+ },
1502
+ };
993
1503
  }
994
1504
  }
995
1505
 
@@ -1039,10 +1549,12 @@ export async function genericFileLockDirStaleVerdict(
1039
1549
 
1040
1550
  async function tryAcquireLock(
1041
1551
  lockPath: string,
1042
- ownerHostId?: string,
1552
+ ownerHostId: string | undefined,
1553
+ orphanTransitionAgeMs: number,
1554
+ previousOwnerHostIds: readonly string[],
1043
1555
  ownerToken = crypto.randomUUID(),
1044
1556
  onAcquired?: () => void,
1045
- ): Promise<LockInfo | null> {
1557
+ ): Promise<FileLockAcquisitionResult> {
1046
1558
  await ensureLockParent(path.dirname(lockPath));
1047
1559
  const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
1048
1560
  if (afterParentMkdir) await afterParentMkdir(lockPath);
@@ -1067,11 +1579,32 @@ async function tryAcquireLock(
1067
1579
  // validation gets a chance to reject it.
1068
1580
  const canonicalPendingPath = path.join(canonicalParent, path.basename(pendingPath));
1069
1581
  let stagedSnapshot: NativeDirectoryTreeSnapshot | undefined;
1070
- if (process.platform === "linux") {
1071
- // Linux exact tree removal owns this deterministic sibling from detach
1582
+ if (process.platform !== "win32") {
1583
+ // POSIX exact tree removal owns this deterministic sibling from detach
1072
1584
  // until cleanup. The outer acquisition loop supplies the existing bounded
1073
1585
  // contention wait while the predecessor retains that namespace.
1074
- if (await fileLockRemovalTransitionExists(destinationPath)) return null;
1586
+ let transitionState = await classifyFileLockRemovalTransition(
1587
+ destinationPath,
1588
+ orphanTransitionAgeMs,
1589
+ ownerHostId,
1590
+ previousOwnerHostIds,
1591
+ );
1592
+ if (transitionState === "orphan_transition") {
1593
+ // A scrubbed, aged transition is the residue of a removal whose owner died
1594
+ // before its final on-disk cleanup. Adopt and finish it here so the wedged
1595
+ // namespace heals instead of aborting (or re-spinning the whole budget).
1596
+ if (!(await adoptOrphanedFileLockRemovalTransition(destinationPath, orphanTransitionAgeMs)))
1597
+ return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
1598
+ transitionState = await classifyFileLockRemovalTransition(
1599
+ destinationPath,
1600
+ orphanTransitionAgeMs,
1601
+ ownerHostId,
1602
+ previousOwnerHostIds,
1603
+ );
1604
+ }
1605
+ if (transitionState === "orphan_transition")
1606
+ return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
1607
+ if (transitionState !== null) return null;
1075
1608
  const staged = snapshotDirectoryTree(canonicalPendingPath);
1076
1609
  if (!staged.ok || !staged.snapshot) {
1077
1610
  const failure = new Error(
@@ -1087,10 +1620,30 @@ async function tryAcquireLock(
1087
1620
  renameDirectoryNoReplacePathAsync,
1088
1621
  };
1089
1622
  const published = await publication.renameNoReplacePathAsync(canonicalPendingPath, destinationPath);
1090
- let publishedSuccessfully = published.ok;
1623
+ let publishedSuccessfully = isSuccessfulNativePublication(published, "primary");
1624
+ if (published.ok && !publishedSuccessfully)
1625
+ throw new Error("Failed to publish file lock: invalid primary success receipt.");
1091
1626
  if (!published.ok && isPreMutationUnsupportedRenameResult(published)) {
1092
1627
  const fallback = await publication.renameDirectoryNoReplacePathAsync(canonicalPendingPath, destinationPath);
1093
- if (fallback.ok) {
1628
+ const fallbackSuccessfully = isSuccessfulNativePublication(fallback, "directory");
1629
+ if (fallback.ok && !fallbackSuccessfully)
1630
+ throw new Error("Failed to publish file lock: invalid directory success receipt.");
1631
+ let verifiedCommittedPublication = false;
1632
+ if (isCommittedDirectoryVerificationFailure(fallback)) {
1633
+ // The staged name has been consumed. Relinquish pathname cleanup even
1634
+ // if completion is refused: a replacement there is not our staged tree.
1635
+ removePending = false;
1636
+ verifiedCommittedPublication =
1637
+ stagedSnapshot !== undefined &&
1638
+ (await matchesCommittedFileLockPublication(canonicalPendingPath, destinationPath, stagedSnapshot));
1639
+ if (verifiedCommittedPublication) {
1640
+ logger.debug("Verified committed file lock publication after native identity refusal", {
1641
+ code: fallback.code,
1642
+ primitive: fallback.primitive,
1643
+ });
1644
+ }
1645
+ }
1646
+ if (fallbackSuccessfully || verifiedCommittedPublication) {
1094
1647
  publishedSuccessfully = true;
1095
1648
  } else if (fallback.reason === "destination_exists") {
1096
1649
  return null;
@@ -1115,22 +1668,37 @@ async function tryAcquireLock(
1115
1668
  // predecessor atomically detaches its lock before this no-replace publish.
1116
1669
  // Roll this exact staged tree back to its UUID path before reporting
1117
1670
  // contention; never delete or rename the predecessor's `.removing` tree.
1118
- if (stagedSnapshot && (await fileLockRemovalTransitionExists(destinationPath))) {
1119
- const publishedSnapshot = snapshotDirectoryTree(destinationPath);
1120
- if (
1121
- !publishedSnapshot.ok ||
1122
- !publishedSnapshot.snapshot ||
1123
- !sameFileLockTreeAfterPublication(stagedSnapshot, publishedSnapshot.snapshot)
1124
- ) {
1125
- const failure = new Error(
1126
- `Failed to verify published file lock before transition rollback: ${publishedSnapshot.code ?? "identity_mismatch"}.`,
1127
- ) as NodeJS.ErrnoException;
1128
- if (publishedSnapshot.code) failure.code = publishedSnapshot.code;
1129
- throw failure;
1671
+ if (stagedSnapshot) {
1672
+ const transitionState = await classifyFileLockRemovalTransition(
1673
+ destinationPath,
1674
+ orphanTransitionAgeMs,
1675
+ ownerHostId,
1676
+ previousOwnerHostIds,
1677
+ );
1678
+ if (transitionState !== null) {
1679
+ const publishedSnapshot = snapshotDirectoryTree(destinationPath);
1680
+ if (
1681
+ !publishedSnapshot.ok ||
1682
+ !publishedSnapshot.snapshot ||
1683
+ !sameFileLockTreeAfterPublication(stagedSnapshot, publishedSnapshot.snapshot)
1684
+ ) {
1685
+ const failure = new Error(
1686
+ `Failed to verify published file lock before transition rollback: ${publishedSnapshot.code ?? "identity_mismatch"}.`,
1687
+ ) as NodeJS.ErrnoException;
1688
+ if (publishedSnapshot.code) failure.code = publishedSnapshot.code;
1689
+ throw failure;
1690
+ }
1691
+ await rollbackPublishedFileLock(
1692
+ canonicalParent,
1693
+ destinationPath,
1694
+ canonicalPendingPath,
1695
+ publishedSnapshot.snapshot,
1696
+ );
1697
+ removePending = true;
1698
+ return transitionState === "orphan_transition"
1699
+ ? { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) }
1700
+ : null;
1130
1701
  }
1131
- rollbackPublishedFileLock(canonicalParent, destinationPath, canonicalPendingPath, publishedSnapshot.snapshot);
1132
- removePending = true;
1133
- return null;
1134
1702
  }
1135
1703
  // Published and transition-free above, so an onAcquired failure must
1136
1704
  // propagate instead of retrying an acquisition that already owns the lock.
@@ -1152,11 +1720,28 @@ function throwTransientNativeResult(code: string): never {
1152
1720
  throw Object.assign(new Error(`Native lock operation is transiently unavailable: ${code}.`), { code });
1153
1721
  }
1154
1722
 
1723
+ /**
1724
+ * The owner-generation fields a guarded removal and a diagnostic both have to agree on
1725
+ * before either may act on, or speak about, that generation.
1726
+ */
1727
+ function sameFileLockOwnerToken(current: LockInfo, expected: FileLockOwnerToken): boolean {
1728
+ return (
1729
+ current.pid === expected.pid &&
1730
+ (expected.process_incarnation === undefined || current.process_incarnation === expected.process_incarnation) &&
1731
+ (expected.start_time === undefined || current.start_time === expected.start_time) &&
1732
+ current.owner_host_id === expected.owner_host_id &&
1733
+ (expected.owner_token === undefined || current.owner_token === expected.owner_token) &&
1734
+ current.timestamp === expected.timestamp
1735
+ );
1736
+ }
1737
+
1155
1738
  type NativeFileLockBindings = {
1156
1739
  snapshotDirectoryTree(lockPath: string): NativeDirectoryTreeResult;
1157
1740
  exactRemoveDirectoryTree(
1158
1741
  lockPath: string,
1159
1742
  snapshot: NonNullable<NativeDirectoryTreeResult["snapshot"]>,
1743
+ parentIdentity?: { dev: bigint; ino: bigint },
1744
+ detachOnly?: boolean,
1160
1745
  ): NativeExactUnlinkResult;
1161
1746
  };
1162
1747
 
@@ -1184,6 +1769,8 @@ async function quarantineReleasedLock(
1184
1769
  // judged; without pre-verdict evidence, refuse and let the caller surface the retry
1185
1770
  // failure instead of risking a successor lock.
1186
1771
  if (!expectedIdentity) return false;
1772
+ if (!(await isNativeExactRemovalUsable()))
1773
+ return (await removeVerifiedOwnedLockDirWithoutNative(lockPath, expectedIdentity, owner)) === "removed";
1187
1774
  let captured: NativeDirectoryTreeResult;
1188
1775
  const nativeCapturePath = await canonicalLockPathPreservingFinal(lockPath);
1189
1776
  try {
@@ -1237,9 +1824,9 @@ async function quarantineReleasedLock(
1237
1824
  path: removed.detachedPath,
1238
1825
  rootDev: captured.snapshot.rootDev,
1239
1826
  rootIno: captured.snapshot.rootIno,
1827
+ snapshot: captured.snapshot,
1240
1828
  });
1241
- await finishDetachedLockCleanup(owner);
1242
- return true;
1829
+ return await finishDetachedLockCleanup(owner);
1243
1830
  }
1244
1831
  throw error;
1245
1832
  }
@@ -1347,12 +1934,33 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken, knownKey
1347
1934
  * Bounded, actionable description of who holds `lockPath` at exhaustion time.
1348
1935
  * Never a stealing authority: purely diagnostic, read once after the last retry.
1349
1936
  */
1350
- async function lockHolderDescription(lockPath: string): Promise<string> {
1937
+ async function lockHolderDescription(
1938
+ lockPath: string,
1939
+ orphanTransitionAgeMs: number,
1940
+ ownerHostId?: string,
1941
+ previousOwnerHostIds: readonly string[] = [],
1942
+ ): Promise<string> {
1351
1943
  try {
1352
- if (process.platform === "linux" && (await fileLockRemovalTransitionExists(lockPath))) {
1353
- return "blocked by retained removal transition; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed";
1944
+ if (process.platform !== "win32") {
1945
+ const transitionState = await classifyFileLockRemovalTransition(
1946
+ lockPath,
1947
+ orphanTransitionAgeMs,
1948
+ ownerHostId,
1949
+ previousOwnerHostIds,
1950
+ );
1951
+ if (transitionState === "orphan_transition")
1952
+ return `orphan_transition at ${fileLockRemovalTransitionPath(lockPath)}`;
1953
+ if (transitionState === "abandoned")
1954
+ return `blocked by abandoned removal transition at ${fileLockRemovalTransitionPath(lockPath)}; inspect and remove the directory manually once no publisher remains`;
1955
+ if (transitionState === "active")
1956
+ return "blocked by retained removal transition; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed";
1957
+ }
1958
+ let info = await readLockInfo(lockPath);
1959
+ let bytes: string | null = null;
1960
+ if (!info) {
1961
+ bytes = await readLockInfoBytes(lockPath);
1962
+ info = bytes === null ? null : parseLockInfoBytes(bytes);
1354
1963
  }
1355
- const info = await readLockInfo(lockPath);
1356
1964
  if (info) {
1357
1965
  // A lock record carrying a foreign owner_host_id belongs to another
1358
1966
  // machine (shared-volume topic registry): its pid is meaningful only
@@ -1380,6 +1988,8 @@ async function lockHolderDescription(lockPath: string): Promise<string> {
1380
1988
  );
1381
1989
  }
1382
1990
  try {
1991
+ if (bytes !== null)
1992
+ 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";
1383
1993
  await fs.stat(path.join(lockPath, "info"));
1384
1994
  return "held by an owner whose metadata is not yet readable";
1385
1995
  } catch (error) {
@@ -1400,6 +2010,8 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
1400
2010
  if (options.previousOwnerHostIds?.some(hostId => !hostId))
1401
2011
  throw new Error("previousOwnerHostIds must contain only non-empty identities");
1402
2012
  const opts = { ...DEFAULT_OPTIONS, ...options };
2013
+ const orphanTransitionAgeMs = Math.max(0, opts.retries * opts.retryDelayMs);
2014
+
1403
2015
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
1404
2016
  const lockPath = getLockPath(filePath);
1405
2017
  await ensureLockParent(path.dirname(lockPath));
@@ -1410,6 +2022,7 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
1410
2022
  }
1411
2023
  const ownerToken = crypto.randomUUID();
1412
2024
  const contentionStartTimes = new Map<string, string | null>();
2025
+ let staleRemovalFailure: RecordedStaleRemovalFailure | undefined;
1413
2026
  for (let attempt = 0; attempt < opts.retries; attempt++) {
1414
2027
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
1415
2028
  const localKey = await localLockKey(lockPath);
@@ -1418,10 +2031,26 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
1418
2031
  await retryPendingLocalRelease(lockPath, localKey);
1419
2032
  continue;
1420
2033
  }
1421
- const owner = await tryAcquireLock(lockPath, opts.ownerHostId, ownerToken, opts.onAcquired);
1422
- if (owner) {
1423
- localLockStates.set(localKey, { owner, status: "held" });
1424
- return () => releaseLock(lockPath, owner, localKey);
2034
+ const result = await tryAcquireLock(
2035
+ lockPath,
2036
+ opts.ownerHostId,
2037
+ orphanTransitionAgeMs,
2038
+ opts.previousOwnerHostIds ?? [],
2039
+ ownerToken,
2040
+ opts.onAcquired,
2041
+ );
2042
+ if (isFileLockOrphanTransition(result))
2043
+ throw new FileLockAcquireError(
2044
+ filePath,
2045
+ lockPath,
2046
+ attempt + 1,
2047
+ `orphan transition retained at ${result.path}`,
2048
+ "orphan_transition",
2049
+ result.path,
2050
+ );
2051
+ if (result) {
2052
+ localLockStates.set(localKey, { owner: result, status: "held" });
2053
+ return () => releaseLock(lockPath, result, localKey);
1425
2054
  }
1426
2055
  const pendingKey = await pendingLocalReleaseKey(lockPath, localKey);
1427
2056
  const localState = localLockStates.get(pendingKey ?? localKey);
@@ -1441,7 +2070,13 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
1441
2070
  opts.previousOwnerHostIds,
1442
2071
  contentionStartTimes,
1443
2072
  );
1444
- if (await removeStaleLockForAcquire(lockPath, stale)) continue;
2073
+ const staleRemoval = await removeStaleLockForAcquire(lockPath, stale);
2074
+ if (staleRemoval.removed) {
2075
+ staleRemovalFailure = undefined;
2076
+ continue;
2077
+ }
2078
+ staleRemovalFailure =
2079
+ stale.stale && staleRemoval.failure ? { owner: stale.owner, failure: staleRemoval.failure } : undefined;
1445
2080
  if (!opts.signal) {
1446
2081
  await Bun.sleep(opts.retryDelayMs);
1447
2082
  continue;
@@ -1456,7 +2091,15 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
1456
2091
  opts.signal.removeEventListener("abort", onAbort);
1457
2092
  }
1458
2093
  }
1459
- throw new FileLockAcquireError(filePath, lockPath, opts.retries, await lockHolderDescription(lockPath));
2094
+ throw new FileLockAcquireError(
2095
+ filePath,
2096
+ lockPath,
2097
+ opts.retries,
2098
+ await lockHolderDescription(lockPath, orphanTransitionAgeMs, opts.ownerHostId, opts.previousOwnerHostIds ?? []),
2099
+ "acquire_timeout",
2100
+ undefined,
2101
+ await staleRemovalFailureForCurrentGeneration(lockPath, staleRemovalFailure),
2102
+ );
1460
2103
  }
1461
2104
 
1462
2105
  /**
@@ -1519,7 +2162,15 @@ export async function inspectFileLockStagingDir(
1519
2162
  const namePid = fileLockStagingOwnerPid(path.basename(stagingPath));
1520
2163
  if (namePid === null) return kept;
1521
2164
  const canonical = await canonicalLockPathPreservingFinal(stagingPath);
1522
- const root = await fs.lstat(canonical, { bigint: true });
2165
+ // A winning acquirer publishes (renames) its staging directory concurrently, so the
2166
+ // candidate can disappear between the caller's readdir and this observation.
2167
+ let root: BigIntStats;
2168
+ try {
2169
+ root = await fs.lstat(canonical, { bigint: true });
2170
+ } catch (error) {
2171
+ if (!isEnoent(error)) throw error;
2172
+ return { ...kept, reason: "enoent_already_gone" };
2173
+ }
1523
2174
  if (!root.isDirectory() || root.isSymbolicLink()) return kept;
1524
2175
  const captured = nativeFileLockBindings().snapshotDirectoryTree(canonical);
1525
2176
  if (
@@ -1567,6 +2218,10 @@ export async function inspectFileLockStagingDir(
1567
2218
  const removal = await removeFileLockDirForGc(canonical, observation.info, observation.identity);
1568
2219
  return { ...result, removed: removal === "removed", reason: removal };
1569
2220
  }
2221
+ if (!(await isNativeExactRemovalUsable())) {
2222
+ const removal = await removeVerifiedLockDirWithoutNative(canonical, captured.snapshot);
2223
+ return { ...result, removed: removal === "removed", reason: removal };
2224
+ }
1570
2225
  const removal = nativeFileLockBindings().exactRemoveDirectoryTree(canonical, captured.snapshot);
1571
2226
  if (
1572
2227
  removal.detachedPath &&
@@ -1575,14 +2230,24 @@ export async function inspectFileLockStagingDir(
1575
2230
  !removal.retainedPlaceholderPath &&
1576
2231
  !removal.retainedUnknownPath
1577
2232
  ) {
1578
- const detached = await fs.lstat(removal.detachedPath, { bigint: true });
2233
+ let detached: BigIntStats | null;
2234
+ try {
2235
+ detached = await fs.lstat(removal.detachedPath, { bigint: true });
2236
+ } catch (error) {
2237
+ if (!isEnoent(error)) throw error;
2238
+ detached = null;
2239
+ }
1579
2240
  if (
1580
- detached.isDirectory() &&
2241
+ detached?.isDirectory() &&
1581
2242
  !detached.isSymbolicLink() &&
1582
2243
  detached.dev.toString() === captured.snapshot.rootDev &&
1583
2244
  detached.ino.toString() === captured.snapshot.rootIno
1584
2245
  ) {
1585
- await fs.rmdir(removal.detachedPath);
2246
+ try {
2247
+ await fs.rmdir(removal.detachedPath);
2248
+ } catch (error) {
2249
+ if (!isEnoent(error)) throw error;
2250
+ }
1586
2251
  return { ...result, removed: true, reason: "removed" };
1587
2252
  }
1588
2253
  }