@gajae-code/coding-agent 0.16.7 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -38,36 +38,97 @@ const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca4959
38
38
  const LOCK_ACQUIRE_RETRY_MS = 5;
39
39
  const LOCK_ACQUIRE_MAX_RETRY_MS = 100;
40
40
  const LOCK_ACQUIRE_TIMEOUT_MS = 5_000;
41
+ /**
42
+ * Ceiling on total waiting, however much progress the lock is making.
43
+ *
44
+ * `LOCK_ACQUIRE_TIMEOUT_MS` bounds waiting WITHOUT PROGRESS (see
45
+ * {@link creditLockHandoffProgress}); this bounds waiting altogether, so a contender behind
46
+ * an unboundedly busy lock still fails rather than blocking its caller forever. It matches
47
+ * the namespace-wide `locks/mutation.lock` budget that encloses these same writes, so the
48
+ * inner and outer locks cannot disagree about how long a write may take.
49
+ */
50
+ const LOCK_ACQUIRE_MAX_WAIT_MS = 60_000;
41
51
  const LOCK_STALE_MS = 30_000;
42
52
  const RELEASED_TRANSITION_GRACE_MS = 1_000;
43
53
  const SESSION_STATE_LOCK_BUSY = Symbol("session-state-lock-busy");
44
54
 
45
55
  interface LockRetryBudget {
56
+ /** When the current no-progress window began; moved forward by an observed handoff. */
46
57
  startedAt: number;
58
+ /** When this acquisition began waiting; never moved, and bounds total waiting. */
59
+ firstAttemptAt: number;
47
60
  nextDelayMs: number;
48
61
  attempts: number;
49
62
  nonBlocking: boolean;
63
+ /** Identity of the holder observed on the previous contended attempt, if any. */
64
+ observedHolder?: string;
50
65
  }
51
66
 
52
67
  function lockRetryBudget(nonBlocking = false): LockRetryBudget {
53
- return { startedAt: performance.now(), nextDelayMs: LOCK_ACQUIRE_RETRY_MS, attempts: 0, nonBlocking };
68
+ const now = performance.now();
69
+ return { startedAt: now, firstAttemptAt: now, nextDelayMs: LOCK_ACQUIRE_RETRY_MS, attempts: 0, nonBlocking };
70
+ }
71
+
72
+ /**
73
+ * Credit the acquisition budget when the lock is observed to have CHANGED HANDS.
74
+ *
75
+ * The acquisition deadline exists to bound waiting on a lock that is stuck, but on its own
76
+ * it cannot tell "stuck" from "busy": it expires just the same while peers take and release
77
+ * the lock normally in front of this contender. That is the starvation this protocol
78
+ * actually suffers from — several gjc processes write one `session-states/<id>.json`, each
79
+ * inside the 60s namespace transaction lock, so a critical section legitimately outlasting
80
+ * the 5s deadline costs every waiter its runtime-state update outright.
81
+ *
82
+ * A holder identity that differs from the one seen on the previous contended attempt, and
83
+ * that this contender did not itself produce, is positive proof that a PEER took and gave
84
+ * up the lock — so the no-progress window restarts from that observation. An UNCHANGED
85
+ * identity credits nothing, which keeps a genuinely wedged lock — the case the deadline is
86
+ * for — failing on exactly its original schedule.
87
+ *
88
+ * `selfCaused` is what separates a handoff from a livelock. A contender that reclaims an
89
+ * abandoned record sees a different identity next round purely because of its own removal;
90
+ * crediting that would let an endlessly recreated dead claim spin until the total-wait
91
+ * ceiling instead of failing on the acquisition deadline. Those rounds are already bounded
92
+ * by their own {@link lockRetryExhausted} check, so they credit nothing here.
93
+ *
94
+ * {@link LOCK_ACQUIRE_MAX_WAIT_MS} bounds total waiting regardless, so progress can never be
95
+ * credited indefinitely.
96
+ */
97
+ function creditLockHandoffProgress(budget: LockRetryBudget, holder: string | undefined, selfCaused: boolean): void {
98
+ if (holder === undefined) return;
99
+ const previous = budget.observedHolder;
100
+ budget.observedHolder = holder;
101
+ if (selfCaused || previous === undefined || previous === holder) return;
102
+ budget.startedAt = performance.now();
103
+ // A handoff means the wait restarts from a fresh, responsive lock rather than from
104
+ // whatever backoff the previous holder's tenure had grown to.
105
+ budget.nextDelayMs = LOCK_ACQUIRE_RETRY_MS;
54
106
  }
55
107
 
56
108
  function lockRetryElapsedMs(budget: LockRetryBudget): number {
57
109
  return Math.max(0, performance.now() - budget.startedAt);
58
110
  }
59
111
 
112
+ /** Milliseconds left before either the no-progress or the total-wait bound is reached. */
113
+ function lockRetryRemainingMs(budget: LockRetryBudget): number {
114
+ const now = performance.now();
115
+ return Math.min(
116
+ LOCK_ACQUIRE_TIMEOUT_MS - Math.max(0, now - budget.startedAt),
117
+ LOCK_ACQUIRE_MAX_WAIT_MS - Math.max(0, now - budget.firstAttemptAt),
118
+ );
119
+ }
120
+
60
121
  function lockRetryExhausted(budget: LockRetryBudget): boolean {
61
- return lockRetryElapsedMs(budget) >= LOCK_ACQUIRE_TIMEOUT_MS;
122
+ return lockRetryRemainingMs(budget) <= 0;
62
123
  }
63
124
 
64
125
  async function waitForLockRetry(budget: LockRetryBudget): Promise<boolean> {
65
126
  budget.attempts++;
66
- const remainingMs = LOCK_ACQUIRE_TIMEOUT_MS - lockRetryElapsedMs(budget);
127
+ const remainingMs = lockRetryRemainingMs(budget);
67
128
  if (remainingMs <= 0) return false;
68
129
  await Bun.sleep(Math.min(budget.nextDelayMs, remainingMs));
69
130
  budget.nextDelayMs = Math.min(LOCK_ACQUIRE_MAX_RETRY_MS, budget.nextDelayMs * 2);
70
- return lockRetryElapsedMs(budget) < LOCK_ACQUIRE_TIMEOUT_MS;
131
+ return !lockRetryExhausted(budget);
71
132
  }
72
133
 
73
134
  /**
@@ -128,7 +189,14 @@ function transitionGenerationFromStat(stat: fsSync.BigIntStats): TransitionDirec
128
189
  };
129
190
  }
130
191
 
131
- interface PendingTransitionRelease {
192
+ interface PendingOwnerRepair {
193
+ /** Descriptor retained when a rewrite fault defeats immediate repair. */
194
+ repairHandle?: fs.FileHandle;
195
+ repairBytes?: string;
196
+ repairSnapshot?: LockOwnerSnapshot;
197
+ }
198
+
199
+ interface PendingTransitionRelease extends PendingOwnerRepair {
132
200
  phase: "setup" | "release";
133
201
  token: string;
134
202
  generation?: TransitionDirectoryGeneration;
@@ -138,21 +206,24 @@ interface PendingTransitionRelease {
138
206
  * authority when setup generation capture itself faults or the pathname is
139
207
  * replaced before recovery gets to run. */
140
208
  generationHandle?: fs.FileHandle;
141
- /** Descriptor retained when a rewrite fault defeats immediate repair. */
142
- repairHandle?: fs.FileHandle;
143
- repairBytes?: string;
144
- repairSnapshot?: LockOwnerSnapshot;
145
209
  held?: LockOwnerSnapshot;
146
210
  releasedOwner?: SessionStateLockOwner;
147
211
  recoverable: boolean;
148
212
  recovery?: Promise<boolean>;
149
213
  }
150
214
 
215
+ interface PendingPrimaryOwnerRelease extends PendingOwnerRepair {
216
+ held: LockOwnerSnapshot;
217
+ recoverable: boolean;
218
+ recovery?: Promise<boolean>;
219
+ }
220
+
151
221
  interface LocalTransitionQueue {
152
222
  waiters: Array<() => void>;
153
223
  }
154
224
 
155
225
  const pendingTransitionReleases = new Map<string, PendingTransitionRelease>();
226
+ const pendingPrimaryOwnerReleases = new Map<string, PendingPrimaryOwnerRelease>();
156
227
  const localTransitionQueues = new Map<string, LocalTransitionQueue>();
157
228
 
158
229
  async function joinLocalTransitionQueue(
@@ -168,7 +239,14 @@ async function joinLocalTransitionQueue(
168
239
  SessionStateLockTestHooks.afterLocalTransitionQueued?.(transitionDir);
169
240
  queue.waiters.push(ready.resolve);
170
241
  await ready.promise;
171
- if (retryBudget) retryBudget.startedAt += Math.max(0, performance.now() - queuedAt);
242
+ // A same-process queue wait is this runtime's own serialization, not filesystem
243
+ // contention: it is credited against BOTH bounds so a descheduled local owner cannot
244
+ // time out its own successor.
245
+ if (retryBudget) {
246
+ const queuedMs = Math.max(0, performance.now() - queuedAt);
247
+ retryBudget.startedAt += queuedMs;
248
+ retryBudget.firstAttemptAt += queuedMs;
249
+ }
172
250
  return queue;
173
251
  }
174
252
 
@@ -203,7 +281,18 @@ function clearPendingTransitionRelease(key: string, pending?: PendingTransitionR
203
281
  }
204
282
  }
205
283
 
206
- async function repairPendingOwnerRecord(pending: PendingTransitionRelease): Promise<boolean> {
284
+ function clearPendingPrimaryOwnerRelease(key: string, pending?: PendingPrimaryOwnerRelease): void {
285
+ const current = pendingPrimaryOwnerReleases.get(key);
286
+ if (pending !== undefined && current !== pending) return;
287
+ pendingPrimaryOwnerReleases.delete(key);
288
+ const repairHandle = (pending ?? current)?.repairHandle;
289
+ if (repairHandle) {
290
+ (pending ?? current)!.repairHandle = undefined;
291
+ void repairHandle.close().catch(() => undefined);
292
+ }
293
+ }
294
+
295
+ async function repairPendingOwnerRecord(pending: PendingOwnerRepair): Promise<boolean> {
207
296
  const handle = pending.repairHandle;
208
297
  if (!handle || pending.repairBytes === undefined) return true;
209
298
  try {
@@ -593,6 +682,36 @@ function persistFailureClass(error: unknown): string {
593
682
  return `error:${boundedPersistFailureClassPart(persistFailureConstructorName(error), "Object")}:code=${boundedPersistFailureClassPart(code, "none")}:errno=${boundedPersistFailureClassPart(errno, "none")}`;
594
683
  }
595
684
 
685
+ /**
686
+ * The refusal reasons that mean ONLY "someone else held the lock the whole time I waited".
687
+ *
688
+ * Each is a contention verdict the acquisition protocol reached without learning anything
689
+ * about the state file itself, so the same write attempted later can still succeed. Every
690
+ * other reason is a standing condition — an unsafe path type, an unprovenanced owner, a
691
+ * failed release, an inspection fault — that a retry would only repeat, so those stay
692
+ * fail-closed and are reported once.
693
+ */
694
+ const CONTENTION_RETRYABLE_LOCK_REASONS: ReadonlySet<SessionStateLockUnavailableReason> = new Set([
695
+ "acquire_timeout",
696
+ "lock_owner_live_or_unverifiable",
697
+ "lock_owner_record_fresh",
698
+ "transition_claim_timeout",
699
+ ]);
700
+
701
+ /**
702
+ * Whether a failed persist lost only a race and is worth attempting again.
703
+ *
704
+ * A coordinator runtime-state update that is dropped on contention is not recoverable by
705
+ * any later write: the coordinator keeps reporting a stale lifecycle/activity snapshot for
706
+ * that session until some unrelated event happens to win the lock. Retrying a contention
707
+ * refusal is therefore repairing a dropped update, not masking a fault — and it is
708
+ * deliberately limited to the reasons that carry no information about the document.
709
+ */
710
+ export function isRetryableSessionStateLockContention(error: unknown): boolean {
711
+ const reason = sessionStateLockFailureFields(error).reason;
712
+ return reason !== undefined && CONTENTION_RETRYABLE_LOCK_REASONS.has(reason);
713
+ }
714
+
596
715
  export const PERSIST_FAILURE_WARN_WINDOW_MS = 30_000;
597
716
  const persistFailureLastWarnedAt = new Map<string, number>();
598
717
 
@@ -943,10 +1062,22 @@ function sameRegularFileIdentity(left: fsSync.BigIntStats, right: fsSync.BigIntS
943
1062
  );
944
1063
  }
945
1064
 
946
- function ownerSnapshotFrom(stat: fsSync.BigIntStats, bytes: Buffer): LockOwnerSnapshot {
1065
+ /** Whether two path/descriptor stats name the same Windows file object. */
1066
+ function sameWindowsFileObject(left: fsSync.BigIntStats, right: fsSync.BigIntStats): boolean {
1067
+ return left.isFile() && right.isFile() && left.dev === right.dev && left.ino === right.ino;
1068
+ }
1069
+
1070
+ function ownerSnapshotFrom(
1071
+ stat: fsSync.BigIntStats,
1072
+ bytes: Buffer,
1073
+ identityStat: fsSync.BigIntStats = stat,
1074
+ ): LockOwnerSnapshot {
947
1075
  return {
948
- dev: stat.dev,
949
- ino: stat.ino,
1076
+ // Windows may refresh descriptor dev/ino metadata after an in-place
1077
+ // truncate/write even though the pathname still names the same file
1078
+ // object. Preserve the pre-mutation identity when one brackets the read.
1079
+ dev: identityStat.dev,
1080
+ ino: identityStat.ino,
950
1081
  nlink: stat.nlink,
951
1082
  size: stat.size,
952
1083
  mtimeNs: stat.mtimeNs,
@@ -1023,12 +1154,12 @@ async function captureWindowsLockOwner(lockFile: string, flags: number): Promise
1023
1154
  throw new SessionStateLockUnavailableError(new Error("Lock path became a reparse point under the read."));
1024
1155
  // The open landed on the object the pre-`lstat` judged, and the pathname still
1025
1156
  // names it. Either disagreement means the bytes have no attributable identity.
1026
- if (!sameRegularFileIdentity(before, opened)) return null;
1027
- if (!relinked || !sameRegularFileIdentity(before, relinked)) return null;
1157
+ if (!sameWindowsFileObject(before, opened)) return null;
1158
+ if (!relinked || !sameWindowsFileObject(before, relinked)) return null;
1028
1159
  const bytes = await handle.readFile();
1029
1160
  const settled = await handle.stat({ bigint: true });
1030
1161
  if (!sameRegularFileIdentity(opened, settled) || settled.size !== BigInt(bytes.byteLength)) return null;
1031
- return ownerSnapshotFrom(settled, bytes);
1162
+ return ownerSnapshotFrom(settled, bytes, before);
1032
1163
  } finally {
1033
1164
  await handle.close().catch(() => undefined);
1034
1165
  }
@@ -1361,11 +1492,11 @@ async function rewriteHeldOwnerRecord(
1361
1492
  try {
1362
1493
  const opened = await handle.stat({ bigint: true });
1363
1494
  if (!opened.isFile()) throw new SessionStateLockUnavailableError(new Error("Owner record is not regular."));
1364
- if (before && !sameRegularFileIdentity(before, opened))
1495
+ if (before && !sameWindowsFileObject(before, opened))
1365
1496
  throw new SessionStateLockUnavailableError(new Error("Owner record changed while opening for rewrite."));
1366
1497
  if (before) {
1367
1498
  const relinked = await fs.lstat(file, { bigint: true }).catch(() => null);
1368
- if (!relinked || !sameRegularFileIdentity(opened, relinked))
1499
+ if (!relinked || !sameWindowsFileObject(opened, relinked))
1369
1500
  throw new SessionStateLockUnavailableError(new Error("Owner pathname changed while opening for rewrite."));
1370
1501
  }
1371
1502
  const currentBytes = await handle.readFile();
@@ -1381,9 +1512,12 @@ async function rewriteHeldOwnerRecord(
1381
1512
  await SessionStateLockTestHooks.beforeOwnerRecordRewrite?.(file);
1382
1513
  rewriteHookPassed = true;
1383
1514
  await writeOwnerBytes(handle, replacementBytes);
1384
- const rewritten = await handle.stat({ bigint: true });
1385
1515
  const canonical = await captureRegularLockOwner(file);
1386
- if (!canonical || canonical.dev !== rewritten.dev || canonical.ino !== rewritten.ino)
1516
+ // On Windows, an in-place truncate/write can expose refreshed descriptor
1517
+ // metadata even though the file object is unchanged. The descriptor opened
1518
+ // before mutation is the stable identity bracket; the path capture still
1519
+ // proves that the pathname resolves to that same object after the rewrite.
1520
+ if (!canonical || canonical.dev !== opened.dev || canonical.ino !== opened.ino)
1387
1521
  throw new SessionStateLockUnavailableError(new Error("Owner pathname changed after rewrite."));
1388
1522
  if (canonical.bytes !== replacementBytes.toString("utf8")) {
1389
1523
  let successor: unknown;
@@ -2038,6 +2172,82 @@ async function recoverPendingTransitionRelease(
2038
2172
  }
2039
2173
  }
2040
2174
 
2175
+ /**
2176
+ * Complete a primary owner release that failed after the caller's operation had settled.
2177
+ *
2178
+ * This authority is deliberately separate from the transition claim record: the transition
2179
+ * wrapper may have released its own claim successfully even though `releaseOwnerLock`
2180
+ * failed, leaving the primary owner live. Recovery always takes a NEW transition claim and
2181
+ * re-proves the exact held owner snapshot before rewriting it. A changed or missing path
2182
+ * invalidates the remembered authority without touching whatever replaced it.
2183
+ */
2184
+ async function recoverPendingPrimaryOwnerRelease(
2185
+ lockFile: string,
2186
+ recoveryKey: string,
2187
+ quarantineName: string,
2188
+ budget: LockRetryBudget,
2189
+ ): Promise<boolean> {
2190
+ const pending = pendingPrimaryOwnerReleases.get(recoveryKey);
2191
+ if (!pending?.recoverable) return false;
2192
+ if (pending.recovery) {
2193
+ if (budget.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
2194
+ return await pending.recovery;
2195
+ }
2196
+ const recovery = (async (): Promise<boolean> => {
2197
+ let released = false;
2198
+ try {
2199
+ await withLockPathTransition(
2200
+ lockFile,
2201
+ async () => {
2202
+ if (pendingPrimaryOwnerReleases.get(recoveryKey) !== pending) return false;
2203
+ if (!(await repairPendingOwnerRecord(pending))) return false;
2204
+ if (pending.repairSnapshot && sameLockOwnerObject(pending.repairSnapshot, pending.held))
2205
+ pending.held = pending.repairSnapshot;
2206
+ pending.repairSnapshot = undefined;
2207
+
2208
+ const current = await captureRegularLockOwner(lockFile);
2209
+ if (!current || !sameLockOwnerSnapshot(current, pending.held)) {
2210
+ clearPendingPrimaryOwnerRelease(recoveryKey, pending);
2211
+ return false;
2212
+ }
2213
+ try {
2214
+ await releaseOwnerLock(lockFile, pending.held);
2215
+ } catch (error) {
2216
+ const rewriteFailure = error as OwnerRewriteFailure;
2217
+ if (rewriteFailure.repairSnapshot && sameLockOwnerObject(rewriteFailure.repairSnapshot, pending.held))
2218
+ pending.held = rewriteFailure.repairSnapshot;
2219
+ if (rewriteFailure.repairHandle) {
2220
+ const previous = pending.repairHandle;
2221
+ pending.repairHandle = rewriteFailure.repairHandle;
2222
+ pending.repairBytes = rewriteFailure.repairBytes ?? pending.held.bytes;
2223
+ if (previous && previous !== rewriteFailure.repairHandle)
2224
+ void previous.close().catch(() => undefined);
2225
+ }
2226
+ pending.recoverable = true;
2227
+ return false;
2228
+ }
2229
+ clearPendingPrimaryOwnerRelease(recoveryKey, pending);
2230
+ released = true;
2231
+ return true;
2232
+ },
2233
+ quarantineName,
2234
+ budget,
2235
+ );
2236
+ } catch {
2237
+ // The remembered owner remains recoverable. A later admission gets a fresh
2238
+ // transition claim and retries the exact same release without replaying work.
2239
+ return false;
2240
+ }
2241
+ return released;
2242
+ })();
2243
+ pending.recovery = recovery;
2244
+ try {
2245
+ return await recovery;
2246
+ } finally {
2247
+ if (pending.recovery === recovery) pending.recovery = undefined;
2248
+ }
2249
+ }
2250
+
2041
2251
  /**
2042
2252
  * Run one pathname transition under an atomic `mkdir`/`rmdir` claim.
2043
2253
  *
@@ -2119,7 +2329,11 @@ async function runLockPathTransition<T>(
2119
2329
  throw new SessionStateLockUnavailableError(error);
2120
2330
  if (await joinLocalTurn()) continue;
2121
2331
  await SessionStateLockTestHooks.afterTransitionClaimContention?.(transitionDir);
2332
+ // Claims are held only for pathname bookkeeping, so a changed claim identity is
2333
+ // proof that contenders are cycling through rather than that one is wedged.
2334
+ const observedClaim = await observedLockHolderIdentity(transitionDir);
2122
2335
  const reclaimed = await reclaimStaleTransitionClaim(transitionDir, quarantineName);
2336
+ creditLockHandoffProgress(budget, observedClaim, reclaimed);
2123
2337
  // One safe reclaim may make progress, but a try-acquire never follows a
2124
2338
  // succession of contenders. A later maintenance pass can take the freed claim.
2125
2339
  if (budget.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
@@ -2516,6 +2730,29 @@ async function reclaimStaleDirectoryLock(
2516
2730
  );
2517
2731
  }
2518
2732
 
2733
+ /**
2734
+ * Identity of whatever currently occupies a lock or transition-claim pathname, for the
2735
+ * sole purpose of telling a lock that is HANDING OFF from one that is stuck.
2736
+ *
2737
+ * Every owner record carries a fresh `randomUUID` token and every release rewrites the
2738
+ * record, so hashing the exact bytes read distinguishes successive holders without
2739
+ * parsing, trusting, or acting on their contents. Inode identity is folded in so that a
2740
+ * record replaced by a byte-identical one still reads as a different holder.
2741
+ *
2742
+ * This is advisory only: it never authorizes a reclaim, and an unreadable or absent path
2743
+ * yields `undefined`, which credits no progress. Failing to observe a handoff can only
2744
+ * make this contender give up on its original schedule.
2745
+ */
2746
+ async function observedLockHolderIdentity(lockPath: string): Promise<string | undefined> {
2747
+ const snapshot = await captureRegularLockOwner(lockPath).catch(() => null);
2748
+ if (snapshot) return `f:${snapshot.dev}:${snapshot.ino}:${snapshot.sha256}`;
2749
+ // A legacy directory lock and a transition claim are directories; their identity is
2750
+ // the directory incarnation itself, since a successor claim is a new inode.
2751
+ const stat = await fs.lstat(lockPath, { bigint: true }).catch(() => null);
2752
+ if (!stat?.isDirectory()) return undefined;
2753
+ return `d:${stat.dev}:${stat.ino}:${stat.ctimeNs}`;
2754
+ }
2755
+
2519
2756
  /**
2520
2757
  * Decide what actually occupies the lock path, without following it.
2521
2758
  *
@@ -2561,6 +2798,11 @@ export async function reclaimStaleSessionStateLock(lockFile: string, quarantineN
2561
2798
  * Taking the path, cleaning up a failed write, and releasing are all pathname transitions,
2562
2799
  * so each runs under the transition claim — never the caller's operation, which holds the
2563
2800
  * lock file itself and may run for as long as it needs.
2801
+ *
2802
+ * The acquisition deadline measures LACK OF PROGRESS, not total waiting: see
2803
+ * {@link creditLockHandoffProgress}. A contender behind a queue of peers that are each
2804
+ * taking and releasing the lock normally keeps waiting; one behind a lock that never moves
2805
+ * still refuses promptly.
2564
2806
  */
2565
2807
  export async function withSessionStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
2566
2808
  return await runWithSessionStateFileLock(stateFile, operation, lockRetryBudget());
@@ -2591,9 +2833,11 @@ async function runWithSessionStateFileLock<T>(
2591
2833
  ): Promise<T> {
2592
2834
  const lockFile = `${stateFile}.lock`;
2593
2835
  let owner: SessionStateLockOwner;
2836
+ let primaryRecoveryKey: string;
2594
2837
  try {
2595
2838
  owner = await newLockOwner();
2596
2839
  await ensureSessionStateParent(path.dirname(stateFile));
2840
+ primaryRecoveryKey = await transitionRecoveryKey(`${lockFile}${LOCK_TRANSITION_RESOURCE_SUFFIX}`);
2597
2841
  } catch (error) {
2598
2842
  const cause = error instanceof SessionStateLockUnavailableError ? (error.cause ?? error) : error;
2599
2843
  throw lockUnavailable(lockFile, "lock_initialization_failed", budget, cause);
@@ -2604,13 +2848,24 @@ async function runWithSessionStateFileLock<T>(
2604
2848
  let held: LockOwnerSnapshot | undefined;
2605
2849
  let callbackFailure = false;
2606
2850
  let callbackError: unknown;
2851
+ let acquiredInsideTransition: LockOwnerSnapshot | undefined;
2607
2852
  try {
2853
+ // A prior call may settle and fail release while this contender is waiting.
2854
+ // Retry its remembered release under a fresh claim before each admission.
2855
+ await recoverPendingPrimaryOwnerRelease(lockFile, primaryRecoveryKey, cycleQuarantine, budget);
2608
2856
  // Contention (`EEXIST`, or `EISDIR`/`EPERM` for a legacy directory owner)
2609
2857
  // propagates out of the claim to the evaluation below; the claim is released
2610
2858
  // first, so the reclaim that follows can take it.
2611
2859
  held = await withLockPathTransition(
2612
2860
  lockFile,
2613
- () => acquireOwnerLock(lockFile, owner, cycleQuarantine),
2861
+ async () => {
2862
+ const acquired = await acquireOwnerLock(lockFile, owner, cycleQuarantine);
2863
+ // A stale pending release can only survive when the old pathname was
2864
+ // removed or replaced; this successful acquisition supersedes it.
2865
+ clearPendingPrimaryOwnerRelease(primaryRecoveryKey);
2866
+ acquiredInsideTransition = acquired;
2867
+ return acquired;
2868
+ },
2614
2869
  cycleQuarantine,
2615
2870
  budget,
2616
2871
  );
@@ -2624,9 +2879,42 @@ async function runWithSessionStateFileLock<T>(
2624
2879
  // Released against the identity captured when it was written, so a record that
2625
2880
  // is no longer ours is left for its owner rather than unlinked by name.
2626
2881
  let releaseFailure: { error: unknown } | undefined;
2882
+ const pending: PendingPrimaryOwnerRelease = {
2883
+ held: record,
2884
+ recoverable: false,
2885
+ };
2886
+ clearPendingPrimaryOwnerRelease(primaryRecoveryKey);
2887
+ pendingPrimaryOwnerReleases.set(primaryRecoveryKey, pending);
2627
2888
  try {
2628
- await withLockPathTransition(lockFile, async () => releaseOwnerLock(lockFile, record), cycleQuarantine);
2889
+ await withLockPathTransition(
2890
+ lockFile,
2891
+ async () => {
2892
+ try {
2893
+ await releaseOwnerLock(lockFile, record);
2894
+ clearPendingPrimaryOwnerRelease(primaryRecoveryKey, pending);
2895
+ } catch (error) {
2896
+ const rewriteFailure = error as OwnerRewriteFailure;
2897
+ if (
2898
+ rewriteFailure.repairSnapshot &&
2899
+ sameLockOwnerObject(rewriteFailure.repairSnapshot, pending.held)
2900
+ )
2901
+ pending.held = rewriteFailure.repairSnapshot;
2902
+ if (rewriteFailure.repairHandle) {
2903
+ const previous = pending.repairHandle;
2904
+ pending.repairHandle = rewriteFailure.repairHandle;
2905
+ pending.repairBytes = rewriteFailure.repairBytes ?? pending.held.bytes;
2906
+ if (previous && previous !== rewriteFailure.repairHandle)
2907
+ void previous.close().catch(() => undefined);
2908
+ }
2909
+ throw error;
2910
+ }
2911
+ },
2912
+ cycleQuarantine,
2913
+ );
2629
2914
  } catch (error) {
2915
+ // The operation and release wrapper have settled, even when claiming
2916
+ // the release transition failed before its callback could run.
2917
+ pending.recoverable = true;
2630
2918
  releaseFailure = { error };
2631
2919
  }
2632
2920
  if (!outcome.ok) {
@@ -2642,6 +2930,17 @@ async function runWithSessionStateFileLock<T>(
2642
2930
  if (releaseFailure) throw releaseFailure.error;
2643
2931
  return outcome.value;
2644
2932
  } catch (error) {
2933
+ if (!held && acquiredInsideTransition) {
2934
+ // The callback acquired the primary owner, but the wrapper rejected
2935
+ // before handing that value back (for example, claim cleanup exhausted).
2936
+ // The callback has settled now, so retain its exact snapshot for the next
2937
+ // admission to release under a fresh transition.
2938
+ clearPendingPrimaryOwnerRelease(primaryRecoveryKey);
2939
+ pendingPrimaryOwnerReleases.set(primaryRecoveryKey, {
2940
+ held: acquiredInsideTransition,
2941
+ recoverable: true,
2942
+ });
2943
+ }
2645
2944
  if (callbackFailure && error === callbackError) throw error;
2646
2945
  // A fault after the lock was taken belongs to the operation, not to acquisition.
2647
2946
  if (held) throw lockDiagnostic(error, lockFile, "lock_release_failed");
@@ -2676,6 +2975,14 @@ async function runWithSessionStateFileLock<T>(
2676
2975
  if (reclaim === "legacy_directory_unprovenanced") lastReason = "legacy_directory_owner_unprovenanced";
2677
2976
  else if (reclaim === "owner_live_or_unverifiable") lastReason = "lock_owner_live_or_unverifiable";
2678
2977
  else if (reclaim === "owner_record_fresh") lastReason = "lock_owner_record_fresh";
2978
+ // A holder that differs from the one seen last round proves the lock is moving,
2979
+ // so this contender is queued behind live peers rather than behind a wedge. A
2980
+ // change this attempt's own reclaim produced is not a peer handoff.
2981
+ creditLockHandoffProgress(
2982
+ budget,
2983
+ await observedLockHolderIdentity(lockFile),
2984
+ reclaim === "owner_removed" || reclaim === "reclaimed",
2985
+ );
2679
2986
  SessionStateLockTestHooks.afterAcquireContention?.(lockFile, budget.attempts + 1, lockRetryElapsedMs(budget));
2680
2987
  if (!(await waitForLockRetry(budget))) throw lockUnavailable(lockFile, lastReason, budget);
2681
2988
  }