@gajae-code/coding-agent 0.18.7 → 0.18.8

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