@gajae-code/coding-agent 0.16.3 → 0.16.6

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 (277) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/args.d.ts +2 -2
  4. package/dist/types/cli/customize-doctor.d.ts +22 -0
  5. package/dist/types/cli/root-flags.d.ts +4 -2
  6. package/dist/types/cli.d.ts +1 -1
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  9. package/dist/types/config/file-lock.d.ts +31 -3
  10. package/dist/types/config/model-registry.d.ts +4 -0
  11. package/dist/types/config/model-resolver.d.ts +9 -0
  12. package/dist/types/config/settings-schema.d.ts +29 -1
  13. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  14. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  15. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  16. package/dist/types/discovery/helpers.d.ts +35 -0
  17. package/dist/types/exec/bash-executor.d.ts +17 -1
  18. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  19. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  20. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  21. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  22. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  23. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  24. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  25. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  26. package/dist/types/exec/isolated-shell.d.ts +19 -0
  27. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  28. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  29. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  30. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  31. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  32. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  33. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  34. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  35. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  36. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  37. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  38. package/dist/types/extensibility/runtime-skill-discovery.d.ts +5 -2
  39. package/dist/types/extensibility/shared-events.d.ts +1 -1
  40. package/dist/types/extensibility/skill-management.d.ts +9 -4
  41. package/dist/types/extensibility/skills.d.ts +2 -0
  42. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  43. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  44. package/dist/types/gjc-runtime/session-state-lock.d.ts +14 -0
  45. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  46. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  47. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  48. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  49. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  50. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  51. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  52. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  53. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  54. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  55. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  56. package/dist/types/modes/interactive-mode.d.ts +3 -0
  57. package/dist/types/modes/types.d.ts +7 -0
  58. package/dist/types/runtime-mcp/client.d.ts +9 -1
  59. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  60. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  61. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  62. package/dist/types/runtime-mcp/types.d.ts +22 -0
  63. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  64. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  65. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  66. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  68. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  69. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  70. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  71. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  72. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  73. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  74. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  75. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  76. package/dist/types/sdk/client/client.d.ts +6 -0
  77. package/dist/types/sdk/host/host.d.ts +3 -1
  78. package/dist/types/sdk/host/session-runtime.d.ts +26 -1
  79. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  80. package/dist/types/sdk/router/session-router.d.ts +10 -2
  81. package/dist/types/sdk/session-api-key.d.ts +26 -0
  82. package/dist/types/sdk/session.d.ts +5 -1
  83. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  84. package/dist/types/session/agent-session.d.ts +11 -0
  85. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  86. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  87. package/dist/types/session/messages.d.ts +1 -0
  88. package/dist/types/session/session-manager.d.ts +3 -1
  89. package/dist/types/setup/paseo/announce.d.ts +112 -0
  90. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  91. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  92. package/dist/types/system-prompt.d.ts +10 -0
  93. package/dist/types/task/executor.d.ts +5 -1
  94. package/dist/types/tools/bash.d.ts +1 -0
  95. package/dist/types/tools/fetch.d.ts +7 -0
  96. package/dist/types/tools/github-cache.d.ts +2 -1
  97. package/dist/types/tools/index.d.ts +10 -0
  98. package/dist/types/tools/path-utils.d.ts +2 -0
  99. package/dist/types/tools/search.d.ts +6 -0
  100. package/package.json +9 -7
  101. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  102. package/scripts/generate-sdk-operation-inventory.ts +11 -0
  103. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  104. package/src/capability/index.ts +14 -2
  105. package/src/capability/types.ts +4 -0
  106. package/src/cli/args.ts +20 -9
  107. package/src/cli/customize-doctor.ts +155 -12
  108. package/src/cli/root-flags.ts +5 -3
  109. package/src/cli.ts +22 -0
  110. package/src/commands/sdk.ts +74 -31
  111. package/src/config/autorouting-tier-map.ts +1 -0
  112. package/src/config/file-lock-gc.ts +40 -2
  113. package/src/config/file-lock.ts +379 -31
  114. package/src/config/model-preset-registry.ts +120 -19
  115. package/src/config/model-profiles.ts +40 -0
  116. package/src/config/model-registry.ts +73 -23
  117. package/src/config/model-resolver.ts +48 -0
  118. package/src/config/settings-schema.ts +33 -1
  119. package/src/config/settings.ts +1 -0
  120. package/src/coordinator-mcp/question-state.ts +33 -2
  121. package/src/coordinator-mcp/server.ts +477 -101
  122. package/src/crash/sanitize.ts +1 -1
  123. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  124. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  125. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  126. package/src/discovery/builtin.ts +52 -55
  127. package/src/discovery/claude-plugins.ts +12 -4
  128. package/src/discovery/helpers.ts +307 -34
  129. package/src/exec/bash-executor.ts +330 -50
  130. package/src/exec/bash-shell-guardian.ts +388 -0
  131. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  132. package/src/exec/bash-shell-signals.ts +29 -0
  133. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  134. package/src/exec/bash-shell-supervisor.ts +359 -0
  135. package/src/exec/bash-shell-worker-entry.ts +5 -0
  136. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  137. package/src/exec/bash-shell-worker.ts +116 -0
  138. package/src/exec/isolated-shell.ts +393 -0
  139. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  140. package/src/extensibility/extensions/function-hooks.ts +959 -0
  141. package/src/extensibility/extensions/index.ts +22 -0
  142. package/src/extensibility/extensions/loader.ts +53 -2
  143. package/src/extensibility/extensions/runner.ts +895 -264
  144. package/src/extensibility/extensions/types.ts +42 -0
  145. package/src/extensibility/extensions/wrapper.ts +56 -15
  146. package/src/extensibility/gjc-plugins/compiler.ts +228 -60
  147. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  148. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  149. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  150. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  151. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  152. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  153. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  154. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  155. package/src/extensibility/gjc-plugins/types.ts +25 -0
  156. package/src/extensibility/hooks/loader.ts +133 -16
  157. package/src/extensibility/hooks/types.ts +28 -0
  158. package/src/extensibility/runtime-skill-discovery.ts +149 -190
  159. package/src/extensibility/shared-events.ts +1 -1
  160. package/src/extensibility/skill-management.ts +200 -52
  161. package/src/extensibility/skills.ts +73 -25
  162. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  163. package/src/gjc-runtime/gc-runtime.ts +29 -0
  164. package/src/gjc-runtime/linux-proc.ts +14 -0
  165. package/src/gjc-runtime/repository-binding.ts +1 -1
  166. package/src/gjc-runtime/session-state-lock.ts +143 -10
  167. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  168. package/src/gjc-runtime/state-writer.ts +30 -29
  169. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  170. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  171. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  172. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  173. package/src/harness-control-plane/session-lease.ts +2 -2
  174. package/src/internal-urls/docs-index.generated.ts +12 -12
  175. package/src/lsp/config.ts +10 -3
  176. package/src/lsp/index.ts +1 -1
  177. package/src/lsp/path-trust.ts +69 -14
  178. package/src/main.ts +78 -35
  179. package/src/modes/DESIGN.md +54 -0
  180. package/src/modes/acp/acp-agent.ts +16 -3
  181. package/src/modes/components/assistant-message.ts +22 -9
  182. package/src/modes/components/bash-execution.ts +4 -1
  183. package/src/modes/components/execution-shared.ts +4 -1
  184. package/src/modes/components/model-selector.ts +166 -33
  185. package/src/modes/components/session-observer-overlay.ts +1 -1
  186. package/src/modes/components/settings-selector.ts +1075 -235
  187. package/src/modes/components/status-line/command.ts +190 -0
  188. package/src/modes/components/status-line/index.ts +1 -0
  189. package/src/modes/components/status-line/segments.ts +32 -5
  190. package/src/modes/components/status-line/types.ts +5 -0
  191. package/src/modes/components/tool-status-header.ts +396 -22
  192. package/src/modes/controllers/command-controller.ts +3 -0
  193. package/src/modes/controllers/event-controller.ts +151 -7
  194. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  195. package/src/modes/controllers/input-controller.ts +6 -3
  196. package/src/modes/controllers/selector-controller.ts +21 -0
  197. package/src/modes/interactive-mode.ts +53 -6
  198. package/src/modes/print-mode.ts +5 -1
  199. package/src/modes/prompt-action-autocomplete.ts +54 -27
  200. package/src/modes/runtime-init.ts +2 -0
  201. package/src/modes/types.ts +17 -3
  202. package/src/modes/utils/ui-helpers.ts +2 -0
  203. package/src/prompts/system/system-prompt.md +8 -0
  204. package/src/prompts/tools/search.md +2 -0
  205. package/src/prompts/tools/subagent.md +1 -0
  206. package/src/runtime-mcp/client.ts +60 -17
  207. package/src/runtime-mcp/manager.ts +189 -14
  208. package/src/runtime-mcp/pool.test.ts +218 -0
  209. package/src/runtime-mcp/pool.ts +224 -23
  210. package/src/runtime-mcp/transports/stdio.ts +158 -24
  211. package/src/runtime-mcp/types.ts +24 -0
  212. package/src/sdk/broker/broker.ts +124 -32
  213. package/src/sdk/broker/ensure.ts +248 -41
  214. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  215. package/src/sdk/broker/lifecycle.ts +56 -6
  216. package/src/sdk/broker/startup-failure.ts +15 -6
  217. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  218. package/src/sdk/bus/chat-daemon-runtime.ts +72 -6
  219. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  220. package/src/sdk/bus/conversation-store.ts +29 -0
  221. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  222. package/src/sdk/bus/slack-conversation.ts +1 -0
  223. package/src/sdk/bus/slack-daemon.ts +478 -49
  224. package/src/sdk/bus/slack-live-provider.ts +12 -0
  225. package/src/sdk/bus/slack-provider.ts +5 -0
  226. package/src/sdk/bus/telegram-daemon.ts +16 -2
  227. package/src/sdk/bus/topic-registry.ts +19 -0
  228. package/src/sdk/cli/master-cli.ts +1 -1
  229. package/src/sdk/cli/session-cli.ts +11 -8
  230. package/src/sdk/client/client.ts +66 -17
  231. package/src/sdk/host/host.ts +29 -9
  232. package/src/sdk/host/query/revision-store.ts +19 -13
  233. package/src/sdk/host/reverse-leases.ts +7 -4
  234. package/src/sdk/host/session-runtime.test.ts +52 -10
  235. package/src/sdk/host/session-runtime.ts +383 -44
  236. package/src/sdk/lifecycle/service.ts +4 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +77 -0
  238. package/src/sdk/router/session-router.ts +89 -38
  239. package/src/sdk/session-api-key.ts +94 -0
  240. package/src/sdk/session.ts +228 -55
  241. package/src/sdk/transport/relay.ts +35 -13
  242. package/src/sdk/transport/serve-cli.ts +3 -3
  243. package/src/session/agent-session.ts +240 -90
  244. package/src/session/contribution-prep.ts +214 -21
  245. package/src/session/fallback-chain-controller.ts +38 -0
  246. package/src/session/internal/managed-session-storage.ts +19 -5
  247. package/src/session/messages.ts +5 -0
  248. package/src/session/session-manager.ts +29 -5
  249. package/src/session-import/redact.ts +5 -1
  250. package/src/setup/paseo/announce.ts +456 -0
  251. package/src/setup/paseo/setup-deps.ts +15 -3
  252. package/src/skill-state/canonical-skills.ts +12 -0
  253. package/src/skills/skills.test.ts +41 -21
  254. package/src/slash-commands/builtin-registry.ts +1 -0
  255. package/src/system-prompt.ts +29 -3
  256. package/src/task/executor.ts +7 -1
  257. package/src/task/index.ts +21 -4
  258. package/src/tools/bash.ts +63 -19
  259. package/src/tools/cron.ts +1 -1
  260. package/src/tools/fetch.ts +29 -1
  261. package/src/tools/gh.ts +26 -3
  262. package/src/tools/github-cache.ts +68 -16
  263. package/src/tools/index.ts +10 -0
  264. package/src/tools/job.ts +21 -4
  265. package/src/tools/monitor.ts +8 -2
  266. package/src/tools/path-utils.ts +7 -0
  267. package/src/tools/read.ts +14 -2
  268. package/src/tools/render-utils.ts +1 -3
  269. package/src/tools/resource-gc.ts +28 -8
  270. package/src/tools/search.ts +116 -58
  271. package/src/tools/skill-discovery.ts +5 -0
  272. package/src/tools/skill.ts +37 -2
  273. package/src/tools/subagent.ts +6 -3
  274. package/src/tools/tool-catalog.generated.ts +10 -3
  275. package/src/tools/ultragoal-ask-guard.ts +2 -2
  276. package/src/web/insane/bridge.ts +113 -23
  277. package/src/web/search/providers/perplexity.ts +102 -41
@@ -2,8 +2,20 @@ import * as crypto from "node:crypto";
2
2
  import type { BigIntStats } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
- import type { NativeDirectoryTreeResult, NativeExactUnlinkResult, NativeNoReplaceResult } from "@gajae-code/natives";
6
- import { renameDirectoryNoReplacePathAsync, renameNoReplacePathAsync } from "@gajae-code/natives";
5
+ import type {
6
+ NativeDirectoryTreeResult,
7
+ NativeDirectoryTreeSnapshot,
8
+ NativeExactUnlinkResult,
9
+ NativeNoReplaceResult,
10
+ RecoveryFsPublishResult,
11
+ } from "@gajae-code/natives";
12
+ import {
13
+ openRecoveryFsRoot,
14
+ renameDirectoryNoReplacePathAsync,
15
+ renameNoReplacePathAsync,
16
+ snapshotDirectoryTree,
17
+ } from "@gajae-code/natives";
18
+ import { logger } from "@gajae-code/utils";
7
19
  import { isEnoent } from "@gajae-code/utils/fs-error";
8
20
  import { nativeProcessBindings } from "@gajae-code/utils/native-process";
9
21
 
@@ -19,6 +31,23 @@ export interface FileLockOptions {
19
31
  previousOwnerHostIds?: readonly string[];
20
32
  }
21
33
 
34
+ export class FileLockAcquireError extends Error {
35
+ readonly code = "acquire_timeout";
36
+
37
+ constructor(
38
+ readonly filePath: string,
39
+ readonly lockPath: string,
40
+ readonly attempts: number,
41
+ readonly holder: string,
42
+ ) {
43
+ 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`,
46
+ );
47
+ this.name = "FileLockAcquireError";
48
+ }
49
+ }
50
+
22
51
  const DEFAULT_OPTIONS: Required<
23
52
  Omit<FileLockOptions, "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired">
24
53
  > = {
@@ -27,9 +56,9 @@ const DEFAULT_OPTIONS: Required<
27
56
  retryDelayMs: 100,
28
57
  };
29
58
 
30
- /** Release retries cover transient Windows/Dropbox handle denial without extending the lock indefinitely. */
31
- export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 5;
32
- export const FILE_LOCK_RELEASE_RETRY_DELAY_MS = 10;
59
+ /** Release retries cover transient handle denial and a competing exact-removal quarantine cleanup. */
60
+ export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 20;
61
+ export const FILE_LOCK_RELEASE_RETRY_DELAY_MS = 25;
33
62
  const PROCESS_START_TIME_FORMAT = "utc-v1";
34
63
 
35
64
  type LocalLockState = {
@@ -90,12 +119,27 @@ export function processStartTime(pid: number): string | null {
90
119
  }
91
120
 
92
121
  let ownProcessStartTime: string | undefined;
122
+ let ownProcessIncarnation: string | null | undefined;
93
123
 
94
124
  function currentProcessStartTime(): string {
95
125
  if (ownProcessStartTime === undefined) ownProcessStartTime = processStartTime(process.pid) ?? "unknown";
96
126
  return ownProcessStartTime;
97
127
  }
98
128
 
129
+ function processIncarnation(pid: number): string | null {
130
+ try {
131
+ const incarnation = nativeProcessBindings().Process.fromPid(pid)?.incarnation;
132
+ return typeof incarnation === "string" && incarnation.length > 0 ? incarnation : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ function currentProcessIncarnation(): string | null {
139
+ if (ownProcessIncarnation === undefined) ownProcessIncarnation = processIncarnation(process.pid);
140
+ return ownProcessIncarnation;
141
+ }
142
+
99
143
  function cachedProcessStartTime(owner: FileLockOwnerToken, cache?: Map<string, string | null>): string | null {
100
144
  if (!cache) return processStartTime(owner.pid);
101
145
  const key = `${owner.pid}:${owner.start_time ?? ""}`;
@@ -108,6 +152,10 @@ function cachedProcessStartTime(owner: FileLockOwnerToken, cache?: Map<string, s
108
152
 
109
153
  function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
110
154
  if (ownerLiveness(owner.pid) !== "alive") return false;
155
+ if (owner.process_incarnation) {
156
+ const currentIncarnation = processIncarnation(owner.pid);
157
+ return currentIncarnation === null || currentIncarnation === owner.process_incarnation;
158
+ }
111
159
  if (!owner.start_time || owner.start_time === "unknown") return true;
112
160
  const currentStartTime = cachedProcessStartTime(owner, startTimeCache);
113
161
  if (currentStartTime === null || currentStartTime === owner.start_time) return true;
@@ -119,10 +167,12 @@ function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, st
119
167
  }
120
168
 
121
169
  function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo {
170
+ const incarnation = currentProcessIncarnation();
122
171
  return {
123
172
  pid: process.pid,
124
173
  start_time: currentProcessStartTime(),
125
174
  start_time_format: PROCESS_START_TIME_FORMAT,
175
+ ...(incarnation === null ? {} : { process_incarnation: incarnation }),
126
176
  timestamp: Date.now(),
127
177
  owner_token: ownerToken,
128
178
  ...(ownerHostId === undefined ? {} : { owner_host_id: ownerHostId }),
@@ -316,7 +366,8 @@ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
316
366
  }
317
367
 
318
368
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
319
- const { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token } = parsed as Partial<LockInfo>;
369
+ const { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token } =
370
+ parsed as Partial<LockInfo>;
320
371
  if (
321
372
  typeof pid !== "number" ||
322
373
  !Number.isInteger(pid) ||
@@ -325,11 +376,12 @@ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
325
376
  !Number.isFinite(timestamp) ||
326
377
  (start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
327
378
  (start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
379
+ (process_incarnation !== undefined && (typeof process_incarnation !== "string" || !process_incarnation)) ||
328
380
  (owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
329
381
  (owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
330
382
  )
331
383
  return null;
332
- return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
384
+ return { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token };
333
385
  }
334
386
 
335
387
  function parseLockInfoBytes(bytes: string): LockInfo | null {
@@ -340,7 +392,8 @@ function parseLockInfoBytes(bytes: string): LockInfo | null {
340
392
  return null;
341
393
  }
342
394
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
343
- const { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token } = parsed as Partial<LockInfo>;
395
+ const { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token } =
396
+ parsed as Partial<LockInfo>;
344
397
  if (
345
398
  typeof pid !== "number" ||
346
399
  !Number.isInteger(pid) ||
@@ -349,11 +402,12 @@ function parseLockInfoBytes(bytes: string): LockInfo | null {
349
402
  !Number.isFinite(timestamp) ||
350
403
  (start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
351
404
  (start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
405
+ (process_incarnation !== undefined && (typeof process_incarnation !== "string" || !process_incarnation)) ||
352
406
  (owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
353
407
  (owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
354
408
  )
355
409
  return null;
356
- return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
410
+ return { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token };
357
411
  }
358
412
 
359
413
  /** @internal */
@@ -382,6 +436,8 @@ export async function readFileLockInfoForGc(lockDir: string): Promise<FileLockOw
382
436
  /** Owner identity stamped into a `<file>.lock/info` record. */
383
437
  export interface FileLockOwnerToken {
384
438
  pid: number;
439
+ /** Kernel-derived identity for the exact process generation owning `pid`. */
440
+ process_incarnation?: string;
385
441
  start_time?: string;
386
442
  /** Encoding marker for the canonical UTC process-start identity. */
387
443
  start_time_format?: string;
@@ -397,11 +453,114 @@ export interface FileLockOwnerToken {
397
453
  * another process may copy into a new lock generation.
398
454
  */
399
455
  const fileLockDirIdentities = new WeakMap<object, GenericFileLockDirIdentity>();
456
+ const pendingDetachedLockCleanups = new WeakMap<object, { path: string; rootDev: string; rootIno: string }>();
457
+
458
+ async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boolean> {
459
+ const pending = pendingDetachedLockCleanups.get(owner);
460
+ if (!pending) return false;
461
+ try {
462
+ const current = await fs.lstat(pending.path, { bigint: true });
463
+ if (
464
+ !current.isDirectory() ||
465
+ current.isSymbolicLink() ||
466
+ current.dev.toString() !== pending.rootDev ||
467
+ current.ino.toString() !== pending.rootIno
468
+ ) {
469
+ throw new Error("Detached file lock cleanup identity changed; refusing removal");
470
+ }
471
+ await fs.rm(pending.path, { recursive: true, force: true });
472
+ } catch (error) {
473
+ if (!isEnoent(error)) throw error;
474
+ }
475
+ pendingDetachedLockCleanups.delete(owner);
476
+ return true;
477
+ }
400
478
 
401
479
  function getLockPath(filePath: string): string {
402
480
  return `${filePath}.lock`;
403
481
  }
404
482
 
483
+ function fileLockRemovalTransitionPath(lockPath: string): string {
484
+ return `${lockPath}.removing`;
485
+ }
486
+
487
+ async function fileLockRemovalTransitionExists(lockPath: string): Promise<boolean> {
488
+ try {
489
+ await fs.lstat(fileLockRemovalTransitionPath(lockPath));
490
+ return true;
491
+ } catch (error) {
492
+ if (isEnoent(error)) return false;
493
+ throw error;
494
+ }
495
+ }
496
+
497
+ function sameFileLockTreeAfterPublication(
498
+ staged: NativeDirectoryTreeSnapshot,
499
+ published: NativeDirectoryTreeSnapshot,
500
+ ): boolean {
501
+ return (
502
+ staged.rootDev === published.rootDev &&
503
+ staged.rootIno === published.rootIno &&
504
+ staged.entries.length === published.entries.length &&
505
+ staged.entries.every((entry, index) => {
506
+ const current = published.entries[index];
507
+ return (
508
+ current !== undefined &&
509
+ entry.relativePath === current.relativePath &&
510
+ entry.kind === current.kind &&
511
+ entry.dev === current.dev &&
512
+ entry.ino === current.ino &&
513
+ entry.nlink === current.nlink &&
514
+ entry.size === current.size &&
515
+ entry.mtimeNs === current.mtimeNs &&
516
+ (entry.relativePath === "" || entry.ctimeNs === current.ctimeNs) &&
517
+ entry.sha256 === current.sha256
518
+ );
519
+ })
520
+ );
521
+ }
522
+
523
+ function rollbackPublishedFileLock(
524
+ canonicalParent: string,
525
+ lockPath: string,
526
+ pendingPath: string,
527
+ stagedSnapshot: NativeDirectoryTreeSnapshot,
528
+ ): void {
529
+ const authority = openRecoveryFsRoot(canonicalParent);
530
+ let rollback: RecoveryFsPublishResult | undefined;
531
+ const errors: unknown[] = [];
532
+ try {
533
+ rollback = authority.renameManagedTreeNoReplace(
534
+ path.basename(lockPath),
535
+ path.basename(pendingPath),
536
+ stagedSnapshot,
537
+ );
538
+ } catch (error) {
539
+ errors.push(error);
540
+ }
541
+ if (rollback && !rollback.ok) {
542
+ const failure = new Error(
543
+ `Failed to roll back file lock published during removal transition: ${rollback.code ?? "unknown"}.`,
544
+ ) as NodeJS.ErrnoException;
545
+ if (rollback.code) failure.code = rollback.code;
546
+ errors.push(failure);
547
+ }
548
+ try {
549
+ const closed = authority.close();
550
+ if (!closed.ok && closed.code !== "closed") {
551
+ const failure = new Error(
552
+ `Failed to close file lock rollback authority: ${closed.code ?? "unknown"}.`,
553
+ ) as NodeJS.ErrnoException;
554
+ if (closed.code) failure.code = closed.code;
555
+ errors.push(failure);
556
+ }
557
+ } catch (error) {
558
+ errors.push(error);
559
+ }
560
+ if (errors.length > 0) throw new AggregateError(errors, "File lock rollback authority failed");
561
+ if (!rollback) throw new Error("File lock rollback returned no result");
562
+ }
563
+
405
564
  async function ensureLockParent(directory: string): Promise<void> {
406
565
  const missing: string[] = [];
407
566
  let current = path.resolve(directory);
@@ -558,6 +717,11 @@ async function localLockKey(lockPath: string): Promise<string> {
558
717
  }
559
718
 
560
719
  function ownerIncarnationChanged(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
720
+ if (owner.process_incarnation) {
721
+ if (ownerLiveness(owner.pid) !== "alive") return false;
722
+ const currentIncarnation = processIncarnation(owner.pid);
723
+ return currentIncarnation !== null && currentIncarnation !== owner.process_incarnation;
724
+ }
561
725
  if (owner.start_time_format !== PROCESS_START_TIME_FORMAT || !owner.start_time || owner.start_time === "unknown")
562
726
  return false;
563
727
  if (ownerLiveness(owner.pid) !== "alive") return false;
@@ -639,6 +803,7 @@ export async function removeFileLockDirForGc(
639
803
  if (!expectedIdentity) return "owner_changed";
640
804
  if (
641
805
  current.pid !== expected.pid ||
806
+ (expected.process_incarnation !== undefined && current.process_incarnation !== expected.process_incarnation) ||
642
807
  (expected.start_time !== undefined && current.start_time !== expected.start_time) ||
643
808
  current.owner_host_id !== expected.owner_host_id ||
644
809
  (expected.owner_token !== undefined && current.owner_token !== expected.owner_token) ||
@@ -704,17 +869,12 @@ export async function removeFileLockDirForGc(
704
869
  removed.retainedPlaceholderPath === undefined &&
705
870
  removed.retainedUnknownPath === undefined;
706
871
  if (verifiedDetach && detachedPath !== undefined) {
707
- // The security-critical phase is done: the verified tree was detached from
708
- // the canonical name and durably parked under the no-replace quarantine
709
- // name with no successor retained. Finish that replay deterministically by
710
- // deleting the retained quarantine — the same completion contract gc-runtime
711
- // applies to its own exact removals — then report the release as done.
712
- try {
713
- await fs.rm(detachedPath, { recursive: true, force: true });
714
- } catch {
715
- // The canonical pathname is free either way; a retained quarantine is
716
- // recoverable debris, not a live lock.
717
- }
872
+ pendingDetachedLockCleanups.set(expected, {
873
+ path: detachedPath,
874
+ rootDev: captured.snapshot.rootDev,
875
+ rootIno: captured.snapshot.rootIno,
876
+ });
877
+ await finishDetachedLockCleanup(expected);
718
878
  return "removed";
719
879
  }
720
880
  if (removed.code === "not_found") return "removed";
@@ -822,7 +982,15 @@ async function staleLockSnapshot(
822
982
 
823
983
  async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSnapshot): Promise<boolean> {
824
984
  if (!snapshot.stale) return false;
825
- return (await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity)) === "removed";
985
+ try {
986
+ return (await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity)) === "removed";
987
+ } 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;
993
+ }
826
994
  }
827
995
 
828
996
  /**
@@ -880,8 +1048,10 @@ async function tryAcquireLock(
880
1048
  if (afterParentMkdir) await afterParentMkdir(lockPath);
881
1049
  const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
882
1050
  const owner = lockInfo(ownerHostId, ownerToken);
1051
+ let removePending = false;
883
1052
  try {
884
1053
  await fs.mkdir(pendingPath, { mode: 0o700 });
1054
+ removePending = true;
885
1055
  await fs.chmod(pendingPath, 0o700);
886
1056
  await writeLockInfo(pendingPath, owner);
887
1057
  // A plain POSIX rename replaces an existing empty directory. The legacy
@@ -896,6 +1066,22 @@ async function tryAcquireLock(
896
1066
  // component would follow an attacker-replaced symlink before native no-follow
897
1067
  // validation gets a chance to reject it.
898
1068
  const canonicalPendingPath = path.join(canonicalParent, path.basename(pendingPath));
1069
+ let stagedSnapshot: NativeDirectoryTreeSnapshot | undefined;
1070
+ if (process.platform === "linux") {
1071
+ // Linux exact tree removal owns this deterministic sibling from detach
1072
+ // until cleanup. The outer acquisition loop supplies the existing bounded
1073
+ // contention wait while the predecessor retains that namespace.
1074
+ if (await fileLockRemovalTransitionExists(destinationPath)) return null;
1075
+ const staged = snapshotDirectoryTree(canonicalPendingPath);
1076
+ if (!staged.ok || !staged.snapshot) {
1077
+ const failure = new Error(
1078
+ `Failed to snapshot staged file lock: ${staged.code ?? "unknown"}.`,
1079
+ ) as NodeJS.ErrnoException;
1080
+ if (staged.code) failure.code = staged.code;
1081
+ throw failure;
1082
+ }
1083
+ stagedSnapshot = staged.snapshot;
1084
+ }
899
1085
  const publication = FileLockTestHooks.nativePublicationBindings?.() ?? {
900
1086
  renameNoReplacePathAsync,
901
1087
  renameDirectoryNoReplacePathAsync,
@@ -924,12 +1110,34 @@ async function tryAcquireLock(
924
1110
  if (published.code) failure.code = published.code;
925
1111
  throw failure;
926
1112
  }
927
- // Published above, so an onAcquired failure must propagate instead of
928
- // retrying an acquisition that already owns the lock.
1113
+ removePending = false;
1114
+ // The transition can appear after the pre-publication check only when the
1115
+ // predecessor atomically detaches its lock before this no-replace publish.
1116
+ // Roll this exact staged tree back to its UUID path before reporting
1117
+ // 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;
1130
+ }
1131
+ rollbackPublishedFileLock(canonicalParent, destinationPath, canonicalPendingPath, publishedSnapshot.snapshot);
1132
+ removePending = true;
1133
+ return null;
1134
+ }
1135
+ // Published and transition-free above, so an onAcquired failure must
1136
+ // propagate instead of retrying an acquisition that already owns the lock.
929
1137
  onAcquired?.();
930
1138
  return owner;
931
1139
  } finally {
932
- await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
1140
+ if (removePending) await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
933
1141
  }
934
1142
  }
935
1143
 
@@ -1025,7 +1233,12 @@ async function quarantineReleasedLock(
1025
1233
  return false;
1026
1234
  } catch (error) {
1027
1235
  if (isEnoent(error)) {
1028
- nativeFileLockBindings().exactRemoveDirectoryTree(removed.detachedPath, captured.snapshot);
1236
+ pendingDetachedLockCleanups.set(owner, {
1237
+ path: removed.detachedPath,
1238
+ rootDev: captured.snapshot.rootDev,
1239
+ rootIno: captured.snapshot.rootIno,
1240
+ });
1241
+ await finishDetachedLockCleanup(owner);
1029
1242
  return true;
1030
1243
  }
1031
1244
  throw error;
@@ -1035,6 +1248,7 @@ async function quarantineReleasedLock(
1035
1248
  }
1036
1249
 
1037
1250
  async function releaseOwnedLock(lockPath: string, owner: FileLockOwnerToken): Promise<void> {
1251
+ if (await finishDetachedLockCleanup(owner)) return;
1038
1252
  let preVerdictIdentity: GenericFileLockDirIdentity | undefined;
1039
1253
  try {
1040
1254
  preVerdictIdentity = (await captureFileLockDirIdentity(lockPath)) ?? undefined;
@@ -1045,6 +1259,7 @@ async function releaseOwnedLock(lockPath: string, owner: FileLockOwnerToken): Pr
1045
1259
  let lastTransientError: unknown;
1046
1260
  for (let attempt = 0; attempt < FILE_LOCK_RELEASE_RETRY_ATTEMPTS; attempt++) {
1047
1261
  try {
1262
+ if (await finishDetachedLockCleanup(owner)) return;
1048
1263
  const outcome = await removeFileLockDirForGc(lockPath, owner, preVerdictIdentity);
1049
1264
  if (outcome === "removed" || outcome === "missing") {
1050
1265
  if (outcome === "missing") throw new Error("Failed to release file lock: missing.");
@@ -1134,6 +1349,9 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken, knownKey
1134
1349
  */
1135
1350
  async function lockHolderDescription(lockPath: string): Promise<string> {
1136
1351
  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";
1354
+ }
1137
1355
  const info = await readLockInfo(lockPath);
1138
1356
  if (info) {
1139
1357
  // A lock record carrying a foreign owner_host_id belongs to another
@@ -1173,7 +1391,7 @@ async function lockHolderDescription(lockPath: string): Promise<string> {
1173
1391
  }
1174
1392
  }
1175
1393
 
1176
- async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
1394
+ export async function acquireFileLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
1177
1395
  const requestedFilePath: unknown = filePath;
1178
1396
  if (typeof requestedFilePath !== "string" || requestedFilePath.length === 0 || !path.isAbsolute(requestedFilePath))
1179
1397
  throw new TypeError("filePath must be a non-empty absolute path");
@@ -1185,11 +1403,21 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
1185
1403
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
1186
1404
  const lockPath = getLockPath(filePath);
1187
1405
  await ensureLockParent(path.dirname(lockPath));
1406
+ try {
1407
+ await reapOrphanedLockStagingDirs(lockPath);
1408
+ } catch (error) {
1409
+ logger.debug("Failed to reap orphaned file-lock staging directories", { lockPath, error: String(error) });
1410
+ }
1188
1411
  const ownerToken = crypto.randomUUID();
1189
1412
  const contentionStartTimes = new Map<string, string | null>();
1190
1413
  for (let attempt = 0; attempt < opts.retries; attempt++) {
1191
1414
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
1192
1415
  const localKey = await localLockKey(lockPath);
1416
+ const priorRelease = localLockStates.get(localKey);
1417
+ if (priorRelease && priorRelease.status !== "held" && pendingDetachedLockCleanups.has(priorRelease.owner)) {
1418
+ await retryPendingLocalRelease(lockPath, localKey);
1419
+ continue;
1420
+ }
1193
1421
  const owner = await tryAcquireLock(lockPath, opts.ownerHostId, ownerToken, opts.onAcquired);
1194
1422
  if (owner) {
1195
1423
  localLockStates.set(localKey, { owner, status: "held" });
@@ -1228,10 +1456,7 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
1228
1456
  opts.signal.removeEventListener("abort", onAbort);
1229
1457
  }
1230
1458
  }
1231
- throw new Error(
1232
- `Failed to acquire lock for ${filePath} after ${opts.retries} attempts: ${await lockHolderDescription(lockPath)} (${lockPath}); ` +
1233
- `a live owner is never displaced — if this is an SDK broker (gjc sdk status), it must finish or be stopped before retrying`,
1234
- );
1459
+ throw new FileLockAcquireError(filePath, lockPath, opts.retries, await lockHolderDescription(lockPath));
1235
1460
  }
1236
1461
 
1237
1462
  /**
@@ -1245,7 +1470,7 @@ export async function withFileLock<T>(
1245
1470
  fn: () => Promise<T>,
1246
1471
  options: FileLockOptions = {},
1247
1472
  ): Promise<T> {
1248
- const release = await acquireLock(filePath, options);
1473
+ const release = await acquireFileLock(filePath, options);
1249
1474
  let result: T;
1250
1475
  try {
1251
1476
  result = await fn();
@@ -1260,3 +1485,126 @@ export async function withFileLock<T>(
1260
1485
  await release();
1261
1486
  return result;
1262
1487
  }
1488
+
1489
+ /** Strictly recognize the staging names emitted by tryAcquireLock. */
1490
+ export function fileLockStagingOwnerPid(name: string): number | null {
1491
+ const match = /^.+\.lock\.pending\.([1-9]\d*)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.exec(
1492
+ name,
1493
+ );
1494
+ if (!match) return null;
1495
+ const pid = Number(match[1]);
1496
+ return Number.isSafeInteger(pid) ? pid : null;
1497
+ }
1498
+
1499
+ export interface FileLockStagingResult {
1500
+ path: string;
1501
+ pid?: number;
1502
+ status: OwnerLiveness;
1503
+ removed: boolean;
1504
+ reason: string;
1505
+ }
1506
+
1507
+ /** Observe identity before probing; neither a replacement nor a symlink inherits the verdict. */
1508
+ export async function inspectFileLockStagingDir(
1509
+ stagingPath: string,
1510
+ probe: (pid: number) => OwnerLiveness = ownerLiveness,
1511
+ remove = false,
1512
+ ): Promise<FileLockStagingResult> {
1513
+ const kept: FileLockStagingResult = {
1514
+ path: stagingPath,
1515
+ status: "unknown",
1516
+ removed: false,
1517
+ reason: "unverified_staging_directory",
1518
+ };
1519
+ const namePid = fileLockStagingOwnerPid(path.basename(stagingPath));
1520
+ if (namePid === null) return kept;
1521
+ const canonical = await canonicalLockPathPreservingFinal(stagingPath);
1522
+ const root = await fs.lstat(canonical, { bigint: true });
1523
+ if (!root.isDirectory() || root.isSymbolicLink()) return kept;
1524
+ const captured = nativeFileLockBindings().snapshotDirectoryTree(canonical);
1525
+ if (
1526
+ !captured.ok ||
1527
+ !captured.snapshot ||
1528
+ captured.snapshot.rootDev !== root.dev.toString() ||
1529
+ captured.snapshot.rootIno !== root.ino.toString()
1530
+ )
1531
+ return kept;
1532
+ const observation = await readFileLockObservationForGc(canonical);
1533
+ let pid: number;
1534
+ if (observation) {
1535
+ if (
1536
+ observation.identity.rootDev !== captured.snapshot.rootDev ||
1537
+ observation.identity.rootIno !== captured.snapshot.rootIno
1538
+ )
1539
+ return kept;
1540
+ pid = observation.info.pid;
1541
+ if (observation.info.owner_host_id !== undefined) return { ...kept, pid, reason: "host_qualified_staging_owner" };
1542
+ } else {
1543
+ // Missing info is the only case where the filename is ownership evidence.
1544
+ try {
1545
+ await fs.lstat(path.join(canonical, "info"));
1546
+ return kept;
1547
+ } catch (error) {
1548
+ if (!isEnoent(error)) throw error;
1549
+ }
1550
+ if (captured.snapshot.entries.some(entry => entry.relativePath !== "")) return kept;
1551
+ pid = namePid;
1552
+ }
1553
+ if (pid === process.pid || namePid === process.pid) {
1554
+ return { ...kept, pid, status: "alive", reason: "in_process_staging_owner" };
1555
+ }
1556
+ let status = probe(pid);
1557
+ if (status === "alive" && observation && ownerIncarnationChanged(observation.info)) status = "dead";
1558
+ const result: FileLockStagingResult = {
1559
+ path: stagingPath,
1560
+ pid,
1561
+ status,
1562
+ removed: false,
1563
+ reason: `file_lock_staging_owner_${status}`,
1564
+ };
1565
+ if (status !== "dead" || !remove) return result;
1566
+ if (observation) {
1567
+ const removal = await removeFileLockDirForGc(canonical, observation.info, observation.identity);
1568
+ return { ...result, removed: removal === "removed", reason: removal };
1569
+ }
1570
+ const removal = nativeFileLockBindings().exactRemoveDirectoryTree(canonical, captured.snapshot);
1571
+ if (
1572
+ removal.detachedPath &&
1573
+ path.resolve(removal.detachedPath) !== path.resolve(canonical) &&
1574
+ !removal.retainedSuccessorPath &&
1575
+ !removal.retainedPlaceholderPath &&
1576
+ !removal.retainedUnknownPath
1577
+ ) {
1578
+ const detached = await fs.lstat(removal.detachedPath, { bigint: true });
1579
+ if (
1580
+ detached.isDirectory() &&
1581
+ !detached.isSymbolicLink() &&
1582
+ detached.dev.toString() === captured.snapshot.rootDev &&
1583
+ detached.ino.toString() === captured.snapshot.rootIno
1584
+ ) {
1585
+ await fs.rmdir(removal.detachedPath);
1586
+ return { ...result, removed: true, reason: "removed" };
1587
+ }
1588
+ }
1589
+ return { ...result, removed: removal.ok, reason: removal.ok ? "removed" : (removal.code ?? "cleanup_failed") };
1590
+ }
1591
+
1592
+ /** Bounded opportunistic cleanup; the acquisition caller treats failures as diagnostic only. */
1593
+ export async function reapOrphanedLockStagingDirs(lockPath: string): Promise<{
1594
+ removed: string[];
1595
+ retained: FileLockStagingResult[];
1596
+ }> {
1597
+ const summary: { removed: string[]; retained: FileLockStagingResult[] } = { removed: [], retained: [] };
1598
+ const parent = path.dirname(lockPath);
1599
+ const prefix = `${path.basename(lockPath)}.pending.`;
1600
+ const entries = await fs.readdir(parent);
1601
+ let candidates = 0;
1602
+ for (const entry of entries) {
1603
+ if (!entry.startsWith(prefix) || fileLockStagingOwnerPid(entry) === null) continue;
1604
+ if (++candidates > 64) break;
1605
+ const result = await inspectFileLockStagingDir(path.join(parent, entry), ownerLiveness, true);
1606
+ if (result.removed) summary.removed.push(result.path);
1607
+ else summary.retained.push(result);
1608
+ }
1609
+ return summary;
1610
+ }