@namzu/sdk 59.0.0 → 60.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (370) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/dist/agents/QueryAgent.d.ts.map +1 -1
  3. package/dist/agents/QueryAgent.js +1 -0
  4. package/dist/agents/QueryAgent.js.map +1 -1
  5. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  6. package/dist/bridge/a2a/mapper.js +16 -0
  7. package/dist/bridge/a2a/mapper.js.map +1 -1
  8. package/dist/bridge/acp/update.d.ts.map +1 -1
  9. package/dist/bridge/acp/update.js +1 -0
  10. package/dist/bridge/acp/update.js.map +1 -1
  11. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  12. package/dist/bridge/sse/mapper.js +12 -0
  13. package/dist/bridge/sse/mapper.js.map +1 -1
  14. package/dist/compaction/context-window.d.ts +53 -0
  15. package/dist/compaction/context-window.d.ts.map +1 -1
  16. package/dist/compaction/context-window.js +141 -2
  17. package/dist/compaction/context-window.js.map +1 -1
  18. package/dist/constants/continuation.d.ts +9 -0
  19. package/dist/constants/continuation.d.ts.map +1 -1
  20. package/dist/constants/continuation.js +9 -0
  21. package/dist/constants/continuation.js.map +1 -1
  22. package/dist/contracts/session/api.d.ts +7 -1
  23. package/dist/contracts/session/api.d.ts.map +1 -1
  24. package/dist/execution/workflow/index.d.ts +3 -1
  25. package/dist/execution/workflow/index.d.ts.map +1 -1
  26. package/dist/execution/workflow/index.js +2 -1
  27. package/dist/execution/workflow/index.js.map +1 -1
  28. package/dist/execution/workflow/journal.d.ts +24 -4
  29. package/dist/execution/workflow/journal.d.ts.map +1 -1
  30. package/dist/execution/workflow/journal.js +131 -9
  31. package/dist/execution/workflow/journal.js.map +1 -1
  32. package/dist/execution/workflow/prelude.d.ts +2 -0
  33. package/dist/execution/workflow/prelude.d.ts.map +1 -1
  34. package/dist/execution/workflow/prelude.js +49 -8
  35. package/dist/execution/workflow/prelude.js.map +1 -1
  36. package/dist/execution/workflow/resume.d.ts +59 -0
  37. package/dist/execution/workflow/resume.d.ts.map +1 -0
  38. package/dist/execution/workflow/resume.js +273 -0
  39. package/dist/execution/workflow/resume.js.map +1 -0
  40. package/dist/execution/workflow/runtime.d.ts.map +1 -1
  41. package/dist/execution/workflow/runtime.js +112 -8
  42. package/dist/execution/workflow/runtime.js.map +1 -1
  43. package/dist/execution/workflow/types.d.ts +105 -1
  44. package/dist/execution/workflow/types.d.ts.map +1 -1
  45. package/dist/index.d.ts +3 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +4 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  50. package/dist/manager/session/turn-recorder.js +1 -0
  51. package/dist/manager/session/turn-recorder.js.map +1 -1
  52. package/dist/plugin/compat.d.ts +24 -0
  53. package/dist/plugin/compat.d.ts.map +1 -0
  54. package/dist/plugin/compat.js +149 -0
  55. package/dist/plugin/compat.js.map +1 -0
  56. package/dist/plugin/loader.d.ts +2 -1
  57. package/dist/plugin/loader.d.ts.map +1 -1
  58. package/dist/plugin/loader.js +6 -0
  59. package/dist/plugin/loader.js.map +1 -1
  60. package/dist/pricing/index.d.ts.map +1 -1
  61. package/dist/pricing/index.js +2 -1
  62. package/dist/pricing/index.js.map +1 -1
  63. package/dist/process/kill-tree.d.ts +44 -0
  64. package/dist/process/kill-tree.d.ts.map +1 -1
  65. package/dist/process/kill-tree.js +102 -4
  66. package/dist/process/kill-tree.js.map +1 -1
  67. package/dist/prompt/coding-agent-doctrine.d.ts +1 -1
  68. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  69. package/dist/prompt/coding-agent-doctrine.js +9 -1
  70. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  71. package/dist/provider/collect-chat-completion.d.ts.map +1 -1
  72. package/dist/provider/collect-chat-completion.js +4 -0
  73. package/dist/provider/collect-chat-completion.js.map +1 -1
  74. package/dist/provider/fallback.d.ts.map +1 -1
  75. package/dist/provider/fallback.js +11 -0
  76. package/dist/provider/fallback.js.map +1 -1
  77. package/dist/provider/idle-timeout.d.ts.map +1 -1
  78. package/dist/provider/idle-timeout.js +5 -0
  79. package/dist/provider/idle-timeout.js.map +1 -1
  80. package/dist/provider/mock.d.ts +2 -0
  81. package/dist/provider/mock.d.ts.map +1 -1
  82. package/dist/provider/mock.js +16 -8
  83. package/dist/provider/mock.js.map +1 -1
  84. package/dist/provider/model-window-suffix.d.ts +18 -0
  85. package/dist/provider/model-window-suffix.d.ts.map +1 -0
  86. package/dist/provider/model-window-suffix.js +78 -0
  87. package/dist/provider/model-window-suffix.js.map +1 -0
  88. package/dist/provider/retry.d.ts.map +1 -1
  89. package/dist/provider/retry.js +5 -0
  90. package/dist/provider/retry.js.map +1 -1
  91. package/dist/public-runtime.d.ts +5 -3
  92. package/dist/public-runtime.d.ts.map +1 -1
  93. package/dist/public-runtime.js +5 -4
  94. package/dist/public-runtime.js.map +1 -1
  95. package/dist/public-types.d.ts +1 -0
  96. package/dist/public-types.d.ts.map +1 -1
  97. package/dist/registry/command/kernel-commands.d.ts +1 -4
  98. package/dist/registry/command/kernel-commands.d.ts.map +1 -1
  99. package/dist/registry/command/kernel-commands.js +0 -28
  100. package/dist/registry/command/kernel-commands.js.map +1 -1
  101. package/dist/runtime/jobs/awaited-jobs.d.ts +6 -0
  102. package/dist/runtime/jobs/awaited-jobs.d.ts.map +1 -1
  103. package/dist/runtime/jobs/awaited-jobs.js +11 -0
  104. package/dist/runtime/jobs/awaited-jobs.js.map +1 -1
  105. package/dist/runtime/jobs/host-exit-finalizer.d.ts +20 -0
  106. package/dist/runtime/jobs/host-exit-finalizer.d.ts.map +1 -0
  107. package/dist/runtime/jobs/host-exit-finalizer.js +57 -0
  108. package/dist/runtime/jobs/host-exit-finalizer.js.map +1 -0
  109. package/dist/runtime/jobs/liveness.d.ts +67 -0
  110. package/dist/runtime/jobs/liveness.d.ts.map +1 -0
  111. package/dist/runtime/jobs/liveness.js +94 -0
  112. package/dist/runtime/jobs/liveness.js.map +1 -0
  113. package/dist/runtime/jobs/registry.d.ts +21 -0
  114. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  115. package/dist/runtime/jobs/registry.js +104 -2
  116. package/dist/runtime/jobs/registry.js.map +1 -1
  117. package/dist/runtime/jobs/wake-budget.d.ts +35 -0
  118. package/dist/runtime/jobs/wake-budget.d.ts.map +1 -0
  119. package/dist/runtime/jobs/wake-budget.js +48 -0
  120. package/dist/runtime/jobs/wake-budget.js.map +1 -0
  121. package/dist/runtime/query/detached-calls.d.ts +65 -0
  122. package/dist/runtime/query/detached-calls.d.ts.map +1 -0
  123. package/dist/runtime/query/detached-calls.js +116 -0
  124. package/dist/runtime/query/detached-calls.js.map +1 -0
  125. package/dist/runtime/query/executor.d.ts +4 -1
  126. package/dist/runtime/query/executor.d.ts.map +1 -1
  127. package/dist/runtime/query/executor.js +90 -6
  128. package/dist/runtime/query/executor.js.map +1 -1
  129. package/dist/runtime/query/index.d.ts +34 -0
  130. package/dist/runtime/query/index.d.ts.map +1 -1
  131. package/dist/runtime/query/index.js +32 -5
  132. package/dist/runtime/query/index.js.map +1 -1
  133. package/dist/runtime/query/iteration/effort-clamp.d.ts +13 -0
  134. package/dist/runtime/query/iteration/effort-clamp.d.ts.map +1 -0
  135. package/dist/runtime/query/iteration/effort-clamp.js +42 -0
  136. package/dist/runtime/query/iteration/effort-clamp.js.map +1 -0
  137. package/dist/runtime/query/iteration/index.d.ts +34 -0
  138. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  139. package/dist/runtime/query/iteration/index.js +199 -6
  140. package/dist/runtime/query/iteration/index.js.map +1 -1
  141. package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -1
  142. package/dist/runtime/query/iteration/outstanding-work.js +11 -3
  143. package/dist/runtime/query/iteration/outstanding-work.js.map +1 -1
  144. package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
  145. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  146. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  147. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  148. package/dist/runtime/query/iteration/phases/tool-review.js +63 -1
  149. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  150. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  151. package/dist/runtime/query/iteration/stream-turn.js +6 -0
  152. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  153. package/dist/runtime/query/session-storage.d.ts.map +1 -1
  154. package/dist/runtime/query/session-storage.js +8 -3
  155. package/dist/runtime/query/session-storage.js.map +1 -1
  156. package/dist/runtime/query/steering.d.ts +29 -3
  157. package/dist/runtime/query/steering.d.ts.map +1 -1
  158. package/dist/runtime/query/steering.js +58 -3
  159. package/dist/runtime/query/steering.js.map +1 -1
  160. package/dist/runtime/query/tooling.d.ts +1 -0
  161. package/dist/runtime/query/tooling.d.ts.map +1 -1
  162. package/dist/runtime/query/tooling.js +1 -0
  163. package/dist/runtime/query/tooling.js.map +1 -1
  164. package/dist/runtime/system-events.d.ts +5 -0
  165. package/dist/runtime/system-events.d.ts.map +1 -1
  166. package/dist/runtime/system-events.js +7 -1
  167. package/dist/runtime/system-events.js.map +1 -1
  168. package/dist/sandbox/denial.d.ts +11 -0
  169. package/dist/sandbox/denial.d.ts.map +1 -0
  170. package/dist/sandbox/denial.js +61 -0
  171. package/dist/sandbox/denial.js.map +1 -0
  172. package/dist/sandbox/file-walk-program.d.ts.map +1 -1
  173. package/dist/sandbox/file-walk-program.js +31 -13
  174. package/dist/sandbox/file-walk-program.js.map +1 -1
  175. package/dist/sandbox/file-walk.d.ts +2 -0
  176. package/dist/sandbox/file-walk.d.ts.map +1 -1
  177. package/dist/sandbox/file-walk.js +64 -13
  178. package/dist/sandbox/file-walk.js.map +1 -1
  179. package/dist/sandbox/ignore-matcher.d.ts +30 -0
  180. package/dist/sandbox/ignore-matcher.d.ts.map +1 -0
  181. package/dist/sandbox/ignore-matcher.js +11 -0
  182. package/dist/sandbox/ignore-matcher.js.map +1 -0
  183. package/dist/sandbox/ignore-source.d.ts +26 -0
  184. package/dist/sandbox/ignore-source.d.ts.map +1 -0
  185. package/dist/sandbox/ignore-source.js +221 -0
  186. package/dist/sandbox/ignore-source.js.map +1 -0
  187. package/dist/sandbox/provider/local.d.ts.map +1 -1
  188. package/dist/sandbox/provider/local.js +11 -1
  189. package/dist/sandbox/provider/local.js.map +1 -1
  190. package/dist/scheduler/completion-inbox.d.ts.map +1 -1
  191. package/dist/scheduler/completion-inbox.js +1 -0
  192. package/dist/scheduler/completion-inbox.js.map +1 -1
  193. package/dist/session/log-hash.d.ts.map +1 -1
  194. package/dist/session/log-hash.js +18 -1
  195. package/dist/session/log-hash.js.map +1 -1
  196. package/dist/store/memory/disk.d.ts.map +1 -1
  197. package/dist/store/memory/disk.js +1 -1
  198. package/dist/store/memory/disk.js.map +1 -1
  199. package/dist/store/memory/markdown.d.ts.map +1 -1
  200. package/dist/store/memory/markdown.js +1 -1
  201. package/dist/store/memory/markdown.js.map +1 -1
  202. package/dist/store/memory/operation-lock.d.ts +20 -4
  203. package/dist/store/memory/operation-lock.d.ts.map +1 -1
  204. package/dist/store/memory/operation-lock.js +235 -58
  205. package/dist/store/memory/operation-lock.js.map +1 -1
  206. package/dist/store/session-index/sqlite.d.ts.map +1 -1
  207. package/dist/store/session-index/sqlite.js +169 -16
  208. package/dist/store/session-index/sqlite.js.map +1 -1
  209. package/dist/tools/builtins/bash.d.ts.map +1 -1
  210. package/dist/tools/builtins/bash.js +10 -4
  211. package/dist/tools/builtins/bash.js.map +1 -1
  212. package/dist/tools/builtins/computer-use.d.ts +35 -0
  213. package/dist/tools/builtins/computer-use.d.ts.map +1 -1
  214. package/dist/tools/builtins/computer-use.js +512 -67
  215. package/dist/tools/builtins/computer-use.js.map +1 -1
  216. package/dist/tools/builtins/glob.d.ts +1 -0
  217. package/dist/tools/builtins/glob.d.ts.map +1 -1
  218. package/dist/tools/builtins/glob.js +65 -28
  219. package/dist/tools/builtins/glob.js.map +1 -1
  220. package/dist/tools/builtins/grep.d.ts +2 -0
  221. package/dist/tools/builtins/grep.d.ts.map +1 -1
  222. package/dist/tools/builtins/grep.js +94 -40
  223. package/dist/tools/builtins/grep.js.map +1 -1
  224. package/dist/tools/builtins/job.d.ts +1 -1
  225. package/dist/tools/builtins/job.d.ts.map +1 -1
  226. package/dist/tools/builtins/job.js +34 -7
  227. package/dist/tools/builtins/job.js.map +1 -1
  228. package/dist/tools/builtins/search-support.d.ts +44 -0
  229. package/dist/tools/builtins/search-support.d.ts.map +1 -0
  230. package/dist/tools/builtins/search-support.js +89 -0
  231. package/dist/tools/builtins/search-support.js.map +1 -0
  232. package/dist/tools/builtins/wait-for-job-bounds.d.ts +2 -0
  233. package/dist/tools/builtins/wait-for-job-bounds.d.ts.map +1 -1
  234. package/dist/tools/builtins/wait-for-job-bounds.js +3 -1
  235. package/dist/tools/builtins/wait-for-job-bounds.js.map +1 -1
  236. package/dist/tools/builtins/wait-for-job.d.ts +1 -1
  237. package/dist/tools/builtins/wait-for-job.d.ts.map +1 -1
  238. package/dist/tools/builtins/wait-for-job.js +13 -2
  239. package/dist/tools/builtins/wait-for-job.js.map +1 -1
  240. package/dist/tools/command-shell.d.ts.map +1 -1
  241. package/dist/tools/command-shell.js +14 -5
  242. package/dist/tools/command-shell.js.map +1 -1
  243. package/dist/turn/reporter.d.ts.map +1 -1
  244. package/dist/turn/reporter.js +11 -0
  245. package/dist/turn/reporter.js.map +1 -1
  246. package/dist/types/agent/query.d.ts +2 -0
  247. package/dist/types/agent/query.d.ts.map +1 -1
  248. package/dist/types/computer-use/index.d.ts +129 -13
  249. package/dist/types/computer-use/index.d.ts.map +1 -1
  250. package/dist/types/computer-use/index.js.map +1 -1
  251. package/dist/types/message/index.d.ts +1 -1
  252. package/dist/types/message/index.d.ts.map +1 -1
  253. package/dist/types/message/index.js +1 -0
  254. package/dist/types/message/index.js.map +1 -1
  255. package/dist/types/plugin/index.d.ts +24 -0
  256. package/dist/types/plugin/index.d.ts.map +1 -1
  257. package/dist/types/plugin/index.js +4 -0
  258. package/dist/types/plugin/index.js.map +1 -1
  259. package/dist/types/provider/chat.d.ts +19 -0
  260. package/dist/types/provider/chat.d.ts.map +1 -1
  261. package/dist/types/provider/config.d.ts +7 -0
  262. package/dist/types/provider/config.d.ts.map +1 -1
  263. package/dist/types/provider/index.d.ts +1 -1
  264. package/dist/types/provider/index.d.ts.map +1 -1
  265. package/dist/types/provider/interface.d.ts +15 -0
  266. package/dist/types/provider/interface.d.ts.map +1 -1
  267. package/dist/types/provider/stream.d.ts +6 -0
  268. package/dist/types/provider/stream.d.ts.map +1 -1
  269. package/dist/types/sandbox/index.d.ts +38 -1
  270. package/dist/types/sandbox/index.d.ts.map +1 -1
  271. package/dist/types/sandbox/index.js.map +1 -1
  272. package/dist/types/session/events.d.ts +34 -0
  273. package/dist/types/session/events.d.ts.map +1 -1
  274. package/dist/types/session/events.js.map +1 -1
  275. package/dist/types/session/records.d.ts +298 -34
  276. package/dist/types/session/records.d.ts.map +1 -1
  277. package/dist/types/session/records.js +35 -1
  278. package/dist/types/session/records.js.map +1 -1
  279. package/dist/types/session/stop-reason.d.ts +11 -0
  280. package/dist/types/session/stop-reason.d.ts.map +1 -1
  281. package/dist/types/tool/index.d.ts +30 -0
  282. package/dist/types/tool/index.d.ts.map +1 -1
  283. package/dist/types/tool/index.js.map +1 -1
  284. package/dist/types/tool/presentation.d.ts +8 -0
  285. package/dist/types/tool/presentation.d.ts.map +1 -1
  286. package/package.json +1 -1
  287. package/src/agents/QueryAgent.ts +1 -0
  288. package/src/bridge/a2a/mapper.ts +19 -0
  289. package/src/bridge/acp/update.ts +6 -1
  290. package/src/bridge/sse/mapper.ts +13 -0
  291. package/src/compaction/context-window.ts +163 -2
  292. package/src/constants/continuation.ts +13 -0
  293. package/src/contracts/session/api.ts +6 -0
  294. package/src/execution/workflow/index.ts +14 -1
  295. package/src/execution/workflow/journal.ts +161 -11
  296. package/src/execution/workflow/prelude.ts +51 -8
  297. package/src/execution/workflow/resume.ts +313 -0
  298. package/src/execution/workflow/runtime.ts +127 -9
  299. package/src/execution/workflow/types.ts +109 -1
  300. package/src/index.ts +17 -0
  301. package/src/manager/session/turn-recorder.ts +1 -0
  302. package/src/plugin/compat.ts +173 -0
  303. package/src/plugin/loader.ts +7 -1
  304. package/src/pricing/index.ts +2 -1
  305. package/src/process/kill-tree.ts +106 -4
  306. package/src/prompt/coding-agent-doctrine.ts +9 -1
  307. package/src/provider/collect-chat-completion.ts +3 -0
  308. package/src/provider/fallback.ts +17 -0
  309. package/src/provider/idle-timeout.ts +9 -0
  310. package/src/provider/mock.ts +19 -9
  311. package/src/provider/model-window-suffix.ts +85 -0
  312. package/src/provider/retry.ts +9 -0
  313. package/src/public-runtime.ts +21 -2
  314. package/src/public-types.ts +1 -0
  315. package/src/registry/command/kernel-commands.ts +1 -32
  316. package/src/runtime/jobs/awaited-jobs.ts +11 -0
  317. package/src/runtime/jobs/host-exit-finalizer.ts +67 -0
  318. package/src/runtime/jobs/liveness.ts +144 -0
  319. package/src/runtime/jobs/registry.ts +114 -2
  320. package/src/runtime/jobs/wake-budget.ts +55 -0
  321. package/src/runtime/query/detached-calls.ts +135 -0
  322. package/src/runtime/query/executor.ts +92 -10
  323. package/src/runtime/query/index.ts +65 -7
  324. package/src/runtime/query/iteration/effort-clamp.ts +46 -0
  325. package/src/runtime/query/iteration/index.ts +260 -6
  326. package/src/runtime/query/iteration/outstanding-work.ts +12 -3
  327. package/src/runtime/query/iteration/phases/context.ts +9 -0
  328. package/src/runtime/query/iteration/phases/tool-review.ts +73 -8
  329. package/src/runtime/query/iteration/stream-turn.ts +5 -0
  330. package/src/runtime/query/session-storage.ts +8 -3
  331. package/src/runtime/query/steering.ts +77 -6
  332. package/src/runtime/query/tooling.ts +2 -0
  333. package/src/runtime/system-events.ts +12 -1
  334. package/src/sandbox/denial.ts +79 -0
  335. package/src/sandbox/file-walk-program.ts +32 -13
  336. package/src/sandbox/file-walk.ts +75 -12
  337. package/src/sandbox/ignore-matcher.ts +46 -0
  338. package/src/sandbox/ignore-source.ts +220 -0
  339. package/src/sandbox/provider/local.ts +11 -1
  340. package/src/scheduler/completion-inbox.ts +1 -0
  341. package/src/session/log-hash.ts +21 -0
  342. package/src/store/memory/disk.ts +1 -0
  343. package/src/store/memory/markdown.ts +1 -0
  344. package/src/store/memory/operation-lock.ts +288 -57
  345. package/src/store/session-index/sqlite.ts +171 -16
  346. package/src/tools/builtins/bash.ts +11 -4
  347. package/src/tools/builtins/computer-use.ts +593 -68
  348. package/src/tools/builtins/glob.ts +79 -32
  349. package/src/tools/builtins/grep.ts +107 -43
  350. package/src/tools/builtins/job.ts +42 -12
  351. package/src/tools/builtins/search-support.ts +107 -0
  352. package/src/tools/builtins/wait-for-job-bounds.ts +5 -1
  353. package/src/tools/builtins/wait-for-job.ts +14 -2
  354. package/src/tools/command-shell.ts +13 -5
  355. package/src/turn/reporter.ts +12 -0
  356. package/src/types/agent/query.ts +2 -0
  357. package/src/types/computer-use/index.ts +157 -15
  358. package/src/types/message/index.ts +5 -3
  359. package/src/types/plugin/index.ts +8 -0
  360. package/src/types/provider/chat.ts +20 -0
  361. package/src/types/provider/config.ts +7 -0
  362. package/src/types/provider/index.ts +1 -0
  363. package/src/types/provider/interface.ts +20 -0
  364. package/src/types/provider/stream.ts +6 -0
  365. package/src/types/sandbox/index.ts +40 -1
  366. package/src/types/session/events.ts +65 -5
  367. package/src/types/session/records.ts +39 -2
  368. package/src/types/session/stop-reason.ts +11 -0
  369. package/src/types/tool/index.ts +30 -0
  370. package/src/types/tool/presentation.ts +8 -0
@@ -1,10 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { type FileHandle, lstat, open, readFile, unlink } from 'node:fs/promises'
2
+ import type { Stats } from 'node:fs'
3
+ import { lstat, open, readFile, unlink } from 'node:fs/promises'
4
+ import { hostname as osHostname } from 'node:os'
3
5
  import { setTimeout as delay } from 'node:timers/promises'
4
6
 
5
7
  import { NamzuError } from '../../types/errors/index.js'
8
+ import type { Logger } from '../../utils/logger.js'
6
9
 
7
- /** Wait bound, not a lease: a slow or crashed owner is never silently replaced. */
10
+ /** Wait bound, not a lease: a slow owner that is still running is never replaced. */
8
11
  export const DEFAULT_MEMORY_LOCK_TIMEOUT_MS = 10_000
9
12
 
10
13
  export function validateMemoryLockTimeout(timeoutMs: number): void {
@@ -17,83 +20,311 @@ export function validateMemoryLockTimeout(timeoutMs: number): void {
17
20
  }
18
21
  }
19
22
 
20
- function lockError(path: string, reason: string, timeoutMs: number): NamzuError {
23
+ /**
24
+ * An empty or unparsable lock file is only believed abandoned once it is older
25
+ * than this. A live acquirer writes its record immediately after creating the
26
+ * file, so a gap of seconds means the writer died in between (disk full, kill).
27
+ */
28
+ export const MEMORY_LOCK_RECORD_GRACE_MS = 5_000
29
+
30
+ export interface MemoryOperationLockOptions {
31
+ readonly logger?: Logger
32
+ /** Clock compared against file mtimes. */
33
+ readonly now?: () => number
34
+ /** True when a process with this pid exists on this machine. */
35
+ readonly isPidAlive?: (pid: number) => boolean
36
+ readonly hostname?: string
37
+ }
38
+
39
+ interface OwnerRecord {
40
+ readonly pid: number
41
+ readonly token: string
42
+ readonly acquiredAt: number
43
+ readonly hostname?: string
44
+ }
45
+
46
+ interface Inspection {
47
+ readonly stats: Stats
48
+ readonly content: string
49
+ readonly pid: number | undefined
50
+ /** Set only when the owner is provably gone. */
51
+ readonly staleReason: string | undefined
52
+ }
53
+
54
+ interface Resolved {
55
+ readonly logger: Logger | undefined
56
+ readonly now: () => number
57
+ readonly isPidAlive: (pid: number) => boolean
58
+ readonly hostname: string
59
+ }
60
+
61
+ /**
62
+ * Residual risk, deliberately accepted: a pid that is alive is treated as the
63
+ * owner even when it is an unrelated process that reused the number after the
64
+ * real owner died. That cannot be disproved from here, so such a lock stays and
65
+ * the caller gets the plain "another process is using it" error. It is not
66
+ * bounded by `acquiredAt` age: a lock's age is not a lease.
67
+ */
68
+ function defaultIsPidAlive(pid: number): boolean {
69
+ try {
70
+ process.kill(pid, 0)
71
+ return true
72
+ } catch (error) {
73
+ return (error as NodeJS.ErrnoException).code !== 'ESRCH'
74
+ }
75
+ }
76
+
77
+ function lockError(path: string, message: string, timeoutMs: number): NamzuError {
21
78
  return new NamzuError({
22
79
  code: 'storage_error',
23
- message: `Memory operation lock ${path}: ${reason}. Stop all processes using this memory store and inspect the lock owner before removing a stale lock; it is never broken automatically.`,
80
+ message,
24
81
  details: { lockPath: path, timeoutMs },
25
82
  retryable: false,
26
83
  })
27
84
  }
28
85
 
86
+ function busyError(path: string, pid: number | undefined, timeoutMs: number): NamzuError {
87
+ const who =
88
+ pid === undefined ? 'Another Namzu process is' : `Another Namzu process (pid ${pid}) is`
89
+ return lockError(
90
+ path,
91
+ `${who} using the stored memory. Close it, or wait for it to finish.`,
92
+ timeoutMs,
93
+ )
94
+ }
95
+
96
+ function parseOwner(content: string): OwnerRecord | undefined {
97
+ try {
98
+ const value: unknown = JSON.parse(content)
99
+ if (!value || typeof value !== 'object') return undefined
100
+ const pid = (value as { pid?: unknown }).pid
101
+ if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined
102
+ const host = (value as { hostname?: unknown }).hostname
103
+ return {
104
+ pid,
105
+ token: String((value as { token?: unknown }).token ?? ''),
106
+ acquiredAt: Number((value as { acquiredAt?: unknown }).acquiredAt ?? 0),
107
+ ...(typeof host === 'string' && host.length > 0 ? { hostname: host } : {}),
108
+ }
109
+ } catch {
110
+ return undefined
111
+ }
112
+ }
113
+
114
+ /** Reads the file at `path`; undefined when it is gone or was swapped while reading. */
115
+ async function inspect(path: string, env: Resolved): Promise<Inspection | undefined> {
116
+ const before = await lstat(path).catch((error: NodeJS.ErrnoException) => {
117
+ if (error.code === 'ENOENT') return undefined
118
+ throw error
119
+ })
120
+ if (!before) return undefined
121
+ if (!before.isFile() || before.isSymbolicLink())
122
+ return { stats: before, content: '', pid: undefined, staleReason: undefined }
123
+ const content = await readFile(path, 'utf8').catch((error: NodeJS.ErrnoException) => {
124
+ if (error.code === 'ENOENT') return undefined
125
+ throw error
126
+ })
127
+ if (content === undefined) return undefined
128
+ const after = await lstat(path).catch(() => undefined)
129
+ if (!after || after.dev !== before.dev || after.ino !== before.ino) return undefined
130
+
131
+ const owner = parseOwner(content)
132
+ if (owner) {
133
+ // A record from another host on a shared filesystem cannot be checked from
134
+ // here; it is live. A record without a host predates the field: local.
135
+ if (owner.hostname !== undefined && owner.hostname !== env.hostname) {
136
+ return { stats: before, content, pid: owner.pid, staleReason: undefined }
137
+ }
138
+ return {
139
+ stats: before,
140
+ content,
141
+ pid: owner.pid,
142
+ staleReason: env.isPidAlive(owner.pid) ? undefined : `owner process ${owner.pid} is gone`,
143
+ }
144
+ }
145
+ const age = env.now() - before.mtimeMs
146
+ return {
147
+ stats: before,
148
+ content,
149
+ pid: undefined,
150
+ staleReason:
151
+ age > MEMORY_LOCK_RECORD_GRACE_MS
152
+ ? `lock holds no readable owner record and is ${Math.round(age / 1000)}s old`
153
+ : undefined,
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Creates `path` exclusively and writes this process's owner record. Returns
159
+ * undefined when the path is occupied. A failed write (a full disk) leaves no
160
+ * file behind.
161
+ */
162
+ async function createOwned(
163
+ path: string,
164
+ env: Resolved,
165
+ ): Promise<{ identity: Stats; owner: string } | undefined> {
166
+ let handle: Awaited<ReturnType<typeof open>>
167
+ try {
168
+ handle = await open(path, 'wx', 0o600)
169
+ } catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') return undefined
171
+ throw error
172
+ }
173
+ const owner = `${JSON.stringify({
174
+ pid: process.pid,
175
+ token: randomUUID(),
176
+ acquiredAt: Date.now(),
177
+ hostname: env.hostname,
178
+ })}\n`
179
+ let identity: Stats | undefined
180
+ try {
181
+ identity = await handle.stat()
182
+ await handle.writeFile(owner, 'utf8')
183
+ await handle.sync()
184
+ } catch (error) {
185
+ await handle.close().catch(() => undefined)
186
+ // We created this path exclusively a moment ago; drop it so no empty or
187
+ // partial record is left to block the next start.
188
+ const current = await lstat(path).catch(() => undefined)
189
+ if (!identity || (current?.dev === identity.dev && current.ino === identity.ino)) {
190
+ await unlink(path).catch(() => undefined)
191
+ }
192
+ throw error
193
+ }
194
+ await handle.close()
195
+ return { identity, owner }
196
+ }
197
+
198
+ async function releaseOwned(
199
+ path: string,
200
+ identity: Stats,
201
+ owner: string,
202
+ timeoutMs: number,
203
+ ): Promise<void> {
204
+ const current = await lstat(path)
205
+ if (
206
+ !current.isFile() ||
207
+ current.isSymbolicLink() ||
208
+ current.dev !== identity.dev ||
209
+ current.ino !== identity.ino ||
210
+ (await readFile(path, 'utf8')) !== owner
211
+ ) {
212
+ throw lockError(
213
+ path,
214
+ `Memory operation lock ${path}: the lock owner changed before release.`,
215
+ timeoutMs,
216
+ )
217
+ }
218
+ await unlink(path)
219
+ }
220
+
221
+ function sameFile(a: Stats, b: Stats): boolean {
222
+ return a.dev === b.dev && a.ino === b.ino
223
+ }
224
+
225
+ /** Unlinks `path` only if it is still exactly the stale file that was inspected. */
226
+ async function unlinkIfStillStale(path: string, seen: Inspection, env: Resolved): Promise<boolean> {
227
+ const again = await inspect(path, env)
228
+ if (!again || !sameFile(again.stats, seen.stats) || again.content !== seen.content) return false
229
+ if (again.staleReason === undefined) return false
230
+ // Narrow the window further: the inode must still be the inspected one now.
231
+ const last = await lstat(path).catch(() => undefined)
232
+ if (!last || !sameFile(last, seen.stats)) return false
233
+ await unlink(path).catch((error: NodeJS.ErrnoException) => {
234
+ if (error.code !== 'ENOENT') throw error
235
+ })
236
+ return true
237
+ }
238
+
239
+ /**
240
+ * Removes a lock whose owner is provably gone. Two processes that see the same
241
+ * stale lock are serialised through `<lock>.break`; the lock is re-inspected
242
+ * under it and only the inspected inode is unlinked. Returns whether the lock
243
+ * was removed.
244
+ */
245
+ async function breakStaleLock(
246
+ path: string,
247
+ seen: Inspection,
248
+ timeoutMs: number,
249
+ env: Resolved,
250
+ ): Promise<boolean> {
251
+ const breakPath = `${path}.break`
252
+ let held: { identity: Stats; owner: string } | undefined
253
+ for (let attempt = 0; attempt < 2 && !held; attempt++) {
254
+ held = await createOwned(breakPath, env)
255
+ if (held) break
256
+ const breaker = await inspect(breakPath, env)
257
+ if (!breaker) continue
258
+ // A dead breaker's file follows the same rule, once.
259
+ if (breaker.staleReason === undefined || !(await unlinkIfStillStale(breakPath, breaker, env))) {
260
+ return false
261
+ }
262
+ }
263
+ if (!held) return false
264
+ try {
265
+ const removed = await unlinkIfStillStale(path, seen, env)
266
+ if (removed) {
267
+ env.logger?.info('Broke an abandoned memory operation lock', {
268
+ 'namzu.store.lock_path': path,
269
+ 'namzu.store.lock_reason': seen.staleReason,
270
+ })
271
+ }
272
+ return removed
273
+ } finally {
274
+ await releaseOwned(breakPath, held.identity, held.owner, timeoutMs).catch(() => undefined)
275
+ }
276
+ }
277
+
29
278
  /**
30
279
  * Local-filesystem exclusion for cooperating processes. Atomic rename of the
31
280
  * index alone cannot serialize the index/content read-modify-write transaction.
32
- * An abandoned lock fails closed; guessing a lease expired would permit two
33
- * live writers to overwrite each other again.
281
+ * A lock whose owner is provably gone (dead pid on this host, or an owner-less
282
+ * file older than the grace period) is broken; a live or unverifiable owner is
283
+ * waited for until the timeout, never replaced on a guessed lease.
34
284
  */
35
285
  export async function acquireMemoryOperationLock(
36
286
  path: string,
37
287
  timeoutMs: number,
288
+ options: MemoryOperationLockOptions = {},
38
289
  ): Promise<() => Promise<void>> {
290
+ const env: Resolved = {
291
+ logger: options.logger,
292
+ now: options.now ?? Date.now,
293
+ isPidAlive: options.isPidAlive ?? defaultIsPidAlive,
294
+ hostname: options.hostname ?? osHostname(),
295
+ }
39
296
  const deadline = performance.now() + timeoutMs
40
297
  let attempted = false
298
+ let lastPid: number | undefined
299
+ // The wait bound limits waiting for a live owner, not the attempt that
300
+ // follows a successful break: the lock is free then, and breaking it can
301
+ // itself outlast a short bound on a slow disk.
302
+ let justBroke = false
41
303
  for (;;) {
42
- if (attempted && performance.now() >= deadline) {
43
- throw lockError(path, `acquisition timed out after ${timeoutMs} ms`, timeoutMs)
304
+ if (attempted && !justBroke && performance.now() >= deadline) {
305
+ throw busyError(path, lastPid, timeoutMs)
44
306
  }
307
+ justBroke = false
45
308
  attempted = true
46
- let handle: FileHandle
47
- try {
48
- handle = await open(path, 'wx', 0o600)
49
- } catch (error) {
50
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
51
- const existing = await lstat(path).catch((statError: NodeJS.ErrnoException) => {
52
- if (statError.code === 'ENOENT') return undefined
53
- throw statError
54
- })
55
- if (!existing) continue
56
- if (!existing.isFile() || existing.isSymbolicLink()) {
57
- throw lockError(path, 'the occupied lock is not a regular file', timeoutMs)
58
- }
59
- if (performance.now() >= deadline) {
60
- throw lockError(path, `acquisition timed out after ${timeoutMs} ms`, timeoutMs)
61
- }
62
- await delay(Math.min(25, Math.max(1, deadline - performance.now())))
63
- continue
309
+ const created = await createOwned(path, env)
310
+ if (created) {
311
+ return () => releaseOwned(path, created.identity, created.owner, timeoutMs)
64
312
  }
65
-
66
- const owner = `${JSON.stringify({ pid: process.pid, token: randomUUID(), acquiredAt: Date.now() })}\n`
67
- const identity = await handle.stat().catch(async (error: unknown) => {
68
- await handle.close()
69
- // Exclusive create happened, but ownership could not be established.
70
- // Leave the path for explicit recovery rather than unlink an unknown inode.
71
- throw error
72
- })
73
- try {
74
- await handle.writeFile(owner, 'utf8')
75
- await handle.sync()
76
- } catch (error) {
77
- await handle.close()
78
- const current = await lstat(path).catch(() => undefined)
79
- if (current?.dev === identity.dev && current.ino === identity.ino) {
80
- await unlink(path).catch(() => undefined)
81
- }
82
- throw error
313
+ const seen = await inspect(path, env)
314
+ if (!seen) continue
315
+ if (!seen.stats.isFile() || seen.stats.isSymbolicLink()) {
316
+ throw lockError(
317
+ path,
318
+ `The stored memory lock ${path} is not a regular file, so Namzu will not use it. Move it aside to continue.`,
319
+ timeoutMs,
320
+ )
83
321
  }
84
- await handle.close()
85
- return async () => {
86
- const current = await lstat(path)
87
- if (
88
- !current.isFile() ||
89
- current.isSymbolicLink() ||
90
- current.dev !== identity.dev ||
91
- current.ino !== identity.ino ||
92
- (await readFile(path, 'utf8')) !== owner
93
- ) {
94
- throw lockError(path, 'the lock owner changed before release', timeoutMs)
95
- }
96
- await unlink(path)
322
+ lastPid = seen.pid
323
+ if (seen.staleReason !== undefined && (await breakStaleLock(path, seen, timeoutMs, env))) {
324
+ justBroke = true
325
+ continue
97
326
  }
327
+ if (performance.now() >= deadline) throw busyError(path, lastPid, timeoutMs)
328
+ await delay(Math.min(25, Math.max(1, deadline - performance.now())))
98
329
  }
99
330
  }
@@ -1,3 +1,4 @@
1
+ import { existsSync, statSync } from 'node:fs'
1
2
  import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'
2
3
  import { createRequire } from 'node:module'
3
4
  import { basename, dirname, join } from 'node:path'
@@ -305,32 +306,135 @@ async function sweepAbandonedRebuilds(path: string): Promise<void> {
305
306
  }
306
307
  }
307
308
 
309
+ /** `device:inode` of the file at `path`, or undefined when there is none. */
310
+ function fileIdentity(path: string): string | undefined {
311
+ try {
312
+ const { dev, ino } = statSync(path)
313
+ return `${dev}:${ino}`
314
+ } catch {
315
+ return undefined
316
+ }
317
+ }
318
+
308
319
  function userVersion(db: DatabaseSync): number {
309
320
  return Number(db.prepare('PRAGMA user_version').get()?.user_version)
310
321
  }
311
322
 
323
+ /** SQLite primary result codes for "the image is malformed" and "the file is not a database". */
324
+ const SQLITE_CORRUPT = 11
325
+ const SQLITE_NOTADB = 26
326
+
327
+ /** Whether `error` says the file's content is not a usable SQLite database. */
328
+ function isNotADatabase(error: unknown): boolean {
329
+ const code = (error as { errcode?: unknown } | null)?.errcode
330
+ return typeof code === 'number' && [SQLITE_CORRUPT, SQLITE_NOTADB].includes(code & 0xff)
331
+ }
332
+
333
+ const SQLITE_BUSY = 5
334
+ const SQLITE_READONLY = 8
335
+ /** `SQLITE_READONLY_DBMOVED`: the file this connection opened was renamed or unlinked. */
336
+ const SQLITE_READONLY_DBMOVED = 1032
337
+
338
+ function errcode(error: unknown): number | undefined {
339
+ const code = (error as { errcode?: unknown } | null)?.errcode
340
+ return typeof code === 'number' ? code : undefined
341
+ }
342
+
343
+ /** Whether `error` says another process holds the index locked past the busy timeout. */
344
+ function isBusy(error: unknown): boolean {
345
+ const code = errcode(error)
346
+ return code !== undefined && (code & 0xff) === SQLITE_BUSY
347
+ }
348
+
349
+ /** Whether `error` says the file under this connection was replaced by another process. */
350
+ function isMoved(error: unknown): boolean {
351
+ const code = errcode(error)
352
+ return code === SQLITE_READONLY_DBMOVED && (code & 0xff) === SQLITE_READONLY
353
+ }
354
+
355
+ /** A plain-language error for an index another process keeps locked. */
356
+ function busyError(path: string, cause: unknown): SessionIndexError {
357
+ return new SessionIndexError(
358
+ `The session index at ${path} is in use by another process. Try again in a moment.`,
359
+ { cause },
360
+ )
361
+ }
362
+
312
363
  /**
313
- * The version of the index file at `path`: -1 when there is none, 0 when it
314
- * cannot be read as a SQLite database, otherwise its `user_version`.
364
+ * The version of the index file at `path`: -1 when there is none, 0 when its
365
+ * content is not a SQLite database, otherwise its `user_version`.
366
+ *
367
+ * The file is opened for writing, never read-only. A writer that died inside
368
+ * a transaction leaves a hot journal beside the file, and only a connection
369
+ * that may write can roll it back; a read-only one fails on it, which used to
370
+ * read as "not a database" and sent the caller to replace a healthy index,
371
+ * leaving the dead writer's journal beside the new file for the next
372
+ * connection to play back into it. Only a SQLite "corrupt" or "not a
373
+ * database" answer means 0; being busy, read-only or unable to read is thrown,
374
+ * never a reason to rebuild.
315
375
  */
316
376
  function indexVersion(path: string): number {
377
+ if (!existsSync(path)) return -1
317
378
  const { DatabaseSync } = requireSqlite()
318
- let db: DatabaseSync
319
- try {
320
- db = new DatabaseSync(path, { readOnly: true })
321
- } catch {
322
- return -1
323
- }
379
+ const db = new DatabaseSync(path)
324
380
  try {
325
381
  db.exec('PRAGMA busy_timeout = 10000')
326
382
  return userVersion(db)
327
- } catch {
328
- return 0
383
+ } catch (error) {
384
+ if (isNotADatabase(error)) return 0
385
+ throw isBusy(error) ? busyError(path, error) : error
329
386
  } finally {
330
387
  db.close()
331
388
  }
332
389
  }
333
390
 
391
+ /**
392
+ * Put the finished `temporary` file in place of the index at `path`.
393
+ *
394
+ * Takes the old index's exclusive lock first. That waits out a peer that is
395
+ * mid-write and rolls back a hot journal left by one that died, so no journal
396
+ * for the old file survives to be played into the new one. A leftover journal
397
+ * of a file that is not a database is removed too: it can describe nothing
398
+ * but that file.
399
+ */
400
+ async function replaceIndexFile(temporary: string, path: string, force: boolean): Promise<boolean> {
401
+ let old: DatabaseSync | undefined
402
+ if (existsSync(path)) {
403
+ const { DatabaseSync } = requireSqlite()
404
+ old = new DatabaseSync(path)
405
+ try {
406
+ old.exec('PRAGMA busy_timeout = 10000')
407
+ old.exec('BEGIN EXCLUSIVE')
408
+ // A peer may have finished its own rebuild while this one ran. With the
409
+ // lock held, nobody can change the file between this check and the
410
+ // rename, so a current index is left alone rather than replaced under
411
+ // the connections that already adopted it.
412
+ if (!force && userVersion(old) === SESSION_INDEX_VERSION) {
413
+ old.close()
414
+ old = undefined
415
+ return false
416
+ }
417
+ } catch (error) {
418
+ old?.close()
419
+ old = undefined
420
+ if (!isNotADatabase(error)) throw isBusy(error) ? busyError(path, error) : error
421
+ }
422
+ }
423
+ try {
424
+ // Windows will not rename over a file that is open, so the lock is let go
425
+ // first there; elsewhere it is held until the new file is in place.
426
+ if (process.platform === 'win32') {
427
+ old?.close()
428
+ old = undefined
429
+ }
430
+ await rm(`${path}-journal`, { force: true })
431
+ await rename(temporary, path)
432
+ return true
433
+ } finally {
434
+ old?.close()
435
+ }
436
+ }
437
+
334
438
  export interface SqliteSessionIndexOptions {
335
439
  /** The index file, normally `$NAMZU_HOME/index.sqlite`. */
336
440
  readonly path: string
@@ -371,15 +475,21 @@ export function rebuildSqliteSessionIndex(
371
475
  */
372
476
  export class SqliteSessionIndex extends SessionIndexBase {
373
477
  readonly backend = 'sqlite' as const
374
- readonly #db: DatabaseSync
478
+ #db: DatabaseSync
479
+ /** Where `#db` was opened, or undefined for a temporary build file that is never watched. */
480
+ readonly #path: string | undefined
481
+ /** Identity of the file `#db` has open, to notice another process replacing it. */
482
+ #identity: string | undefined
375
483
  readonly #sink: IndexSink
376
484
  readonly #statements = new Map<string, StatementSync>()
377
485
  #open = true
378
486
 
379
487
  /** Wraps an open database whose schema is current. Use {@link SqliteSessionIndex.open}. */
380
- private constructor(db: DatabaseSync) {
488
+ private constructor(db: DatabaseSync, path?: string) {
381
489
  super()
382
490
  this.#db = db
491
+ this.#path = path
492
+ this.#identity = path === undefined ? undefined : fileIdentity(path)
383
493
  const run = (sql: string, ...values: SQLInputValue[]) => {
384
494
  this.#statement(sql).run(...values)
385
495
  }
@@ -583,8 +693,7 @@ export class SqliteSessionIndex extends SessionIndexBase {
583
693
  if (!options.force && (await pathExists(options.path))) {
584
694
  if (indexVersion(options.path) === SESSION_INDEX_VERSION) return false
585
695
  }
586
- await rename(temporary, options.path)
587
- return true
696
+ return await replaceIndexFile(temporary, options.path, options.force === true)
588
697
  } finally {
589
698
  await rm(temporary, { force: true })
590
699
  await rm(`${temporary}-journal`, { force: true })
@@ -615,13 +724,55 @@ export class SqliteSessionIndex extends SessionIndexBase {
615
724
  db.close()
616
725
  throw error
617
726
  }
618
- const index = new SqliteSessionIndex(db)
727
+ const index = new SqliteSessionIndex(db, options.path)
619
728
  await index.sync(options.home)
620
729
  return index
621
730
  }
622
731
 
732
+ /**
733
+ * Another process rebuilt the index and renamed a new file over it: this
734
+ * connection still has the old, unlinked file, whose reads go stale and
735
+ * whose writes fail with SQLITE_READONLY_DBMOVED. Reopen the file now at
736
+ * the path. A no-op for a temporary build file, and when the path holds
737
+ * nothing to adopt.
738
+ */
739
+ #reopenIfReplaced(force = false): void {
740
+ const path = this.#path
741
+ if (path === undefined || !this.#open) return
742
+ const identity = fileIdentity(path)
743
+ if (identity === undefined || (!force && identity === this.#identity)) return
744
+ const { DatabaseSync } = requireSqlite()
745
+ const db = new DatabaseSync(path)
746
+ try {
747
+ configure(db)
748
+ if (userVersion(db) !== SESSION_INDEX_VERSION) {
749
+ throw new SessionIndexError(`The index at ${path} changed version while in use.`)
750
+ }
751
+ } catch (error) {
752
+ db.close()
753
+ throw error
754
+ }
755
+ const previous = this.#db
756
+ this.#statements.clear()
757
+ this.#db = db
758
+ this.#identity = fileIdentity(path)
759
+ try {
760
+ previous.close()
761
+ } catch {
762
+ // The old file is already unlinked; nothing is lost if it will not close.
763
+ }
764
+ }
765
+
623
766
  protected transaction<T>(work: (sink: IndexSink) => T): T {
624
- this.#db.exec('BEGIN IMMEDIATE')
767
+ this.#reopenIfReplaced()
768
+ try {
769
+ this.#db.exec('BEGIN IMMEDIATE')
770
+ } catch (error) {
771
+ // Nothing has run yet, so the work can start again on the new file.
772
+ if (!isMoved(error)) throw error
773
+ this.#reopenIfReplaced(true)
774
+ this.#db.exec('BEGIN IMMEDIATE')
775
+ }
625
776
  try {
626
777
  const result = work(this.#sink)
627
778
  this.#db.exec('COMMIT')
@@ -643,10 +794,12 @@ export class SqliteSessionIndex extends SessionIndexBase {
643
794
  }
644
795
 
645
796
  #all(sql: string, ...values: SQLInputValue[]): Row[] {
797
+ this.#reopenIfReplaced()
646
798
  return this.#statement(sql).all(...values) as Row[]
647
799
  }
648
800
 
649
801
  protected readSession(sessionId: SessionId): SessionRow | undefined {
802
+ this.#reopenIfReplaced()
650
803
  const row = this.#statement('SELECT * FROM sessions WHERE id = ?').get(sessionId) as
651
804
  | Row
652
805
  | undefined
@@ -707,6 +860,7 @@ export class SqliteSessionIndex extends SessionIndexBase {
707
860
  kind: ExternalRefKind,
708
861
  externalId: string,
709
862
  ): Promise<ExternalRefTarget | undefined> {
863
+ this.#reopenIfReplaced()
710
864
  const row = this.#db
711
865
  .prepare(
712
866
  `SELECT * FROM external_refs WHERE protocol = ? AND kind = ? AND external_id = ?
@@ -749,6 +903,7 @@ export class SqliteSessionIndex extends SessionIndexBase {
749
903
  values.push(options.sessionId)
750
904
  }
751
905
  const clause = where.length === 0 ? '' : `WHERE ${where.join(' AND ')}`
906
+ this.#reopenIfReplaced()
752
907
  const statement = this.#db.prepare(
753
908
  `SELECT text, session_id, turn_id, seq, part, source, tool_name, is_error FROM evidence_fts
754
909
  ${clause} ORDER BY session_id, seq, part`,
@@ -751,12 +751,14 @@ const BashToolBase = defineTool({
751
751
  clipped.length > 0
752
752
  ? `[${clipped.join(' and ')} was truncated by the sandbox output cap. The omitted output is unavailable in this result. Use a saved artifact or a read-only observation; do not repeat a state-changing action to recover its output.]`
753
753
  : '',
754
+ result.attribution ? `[${result.attribution.message}]` : '',
754
755
  ]
755
756
  .filter(Boolean)
756
757
  .join('\n\n')
757
758
 
758
759
  const judged = result.timedOut ? undefined : judgeExit(input.command, result.exitCode)
759
- const answered = judged !== undefined && !judged.isError
760
+ const answered =
761
+ judged !== undefined && !judged.isError && result.attribution?.kind !== 'runner-failure'
760
762
  return {
761
763
  success: answered,
762
764
  output: output || '(no output)',
@@ -767,12 +769,17 @@ const BashToolBase = defineTool({
767
769
  timedOut: result.timedOut,
768
770
  stdoutTruncated: result.stdoutTruncated ?? false,
769
771
  stderrTruncated: result.stderrTruncated ?? false,
772
+ ...(result.attribution ? { sandboxAttribution: result.attribution.kind } : {}),
770
773
  },
771
774
  error: result.timedOut
772
775
  ? `Command timed out after ${input.timeout}ms. Any captured output before the deadline is above.`
773
- : !answered
774
- ? `Command exited with code ${result.exitCode}`
775
- : undefined,
776
+ : result.attribution?.kind === 'runner-failure'
777
+ ? 'The sandbox launcher failed before the command ran (runner failure, not a command failure).'
778
+ : !answered
779
+ ? result.attribution?.kind === 'denial'
780
+ ? `Command exited with code ${result.exitCode}; looks like a sandbox denial`
781
+ : `Command exited with code ${result.exitCode}`
782
+ : undefined,
776
783
  facts: commandFacts({
777
784
  command: input.command,
778
785
  exitCode: result.exitCode,