@pentoshi/clai 3.9.1 → 3.9.3

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 (334) hide show
  1. package/dist/agent/compaction-attempt.d.ts +22 -0
  2. package/dist/agent/compaction-attempt.js +69 -0
  3. package/dist/agent/compaction-attempt.js.map +1 -0
  4. package/dist/agent/compaction-summary.d.ts +27 -0
  5. package/dist/agent/compaction-summary.js +62 -0
  6. package/dist/agent/compaction-summary.js.map +1 -1
  7. package/dist/agent/context-breakdown.d.ts +2 -0
  8. package/dist/agent/context-breakdown.js +37 -0
  9. package/dist/agent/context-breakdown.js.map +1 -1
  10. package/dist/agent/context-manager.d.ts +7 -1
  11. package/dist/agent/context-manager.js +64 -15
  12. package/dist/agent/context-manager.js.map +1 -1
  13. package/dist/agent/continue-orient.d.ts +8 -0
  14. package/dist/agent/continue-orient.js +26 -3
  15. package/dist/agent/continue-orient.js.map +1 -1
  16. package/dist/agent/durable-envelope.d.ts +39 -0
  17. package/dist/agent/durable-envelope.js +195 -0
  18. package/dist/agent/durable-envelope.js.map +1 -0
  19. package/dist/agent/events.d.ts +21 -0
  20. package/dist/agent/loop-guard.d.ts +13 -5
  21. package/dist/agent/loop-guard.js +114 -1
  22. package/dist/agent/loop-guard.js.map +1 -1
  23. package/dist/agent/outcomes.d.ts +29 -0
  24. package/dist/agent/outcomes.js +118 -3
  25. package/dist/agent/outcomes.js.map +1 -1
  26. package/dist/agent/plan-decision.js +8 -3
  27. package/dist/agent/plan-decision.js.map +1 -1
  28. package/dist/agent/plan-tool.d.ts +5 -0
  29. package/dist/agent/plan-tool.js +166 -6
  30. package/dist/agent/plan-tool.js.map +1 -1
  31. package/dist/agent/reliability-policy.d.ts +11 -6
  32. package/dist/agent/reliability-policy.js +19 -13
  33. package/dist/agent/reliability-policy.js.map +1 -1
  34. package/dist/agent/request-budget.d.ts +24 -0
  35. package/dist/agent/request-budget.js +59 -0
  36. package/dist/agent/request-budget.js.map +1 -0
  37. package/dist/agent/responder-context.js +2 -2
  38. package/dist/agent/responder-context.js.map +1 -1
  39. package/dist/agent/responder-parent.d.ts +30 -0
  40. package/dist/agent/responder-parent.js +82 -0
  41. package/dist/agent/responder-parent.js.map +1 -0
  42. package/dist/agent/runner.d.ts +14 -1
  43. package/dist/agent/runner.js +850 -237
  44. package/dist/agent/runner.js.map +1 -1
  45. package/dist/agent/scope-context.d.ts +5 -0
  46. package/dist/agent/scope-context.js +7 -1
  47. package/dist/agent/scope-context.js.map +1 -1
  48. package/dist/agent/session-policy.js +1 -0
  49. package/dist/agent/session-policy.js.map +1 -1
  50. package/dist/agent/stop-summary.js +13 -7
  51. package/dist/agent/stop-summary.js.map +1 -1
  52. package/dist/agent/stream-recovery.d.ts +83 -0
  53. package/dist/agent/stream-recovery.js +270 -0
  54. package/dist/agent/stream-recovery.js.map +1 -0
  55. package/dist/agent/task-analyzer.js +7 -3
  56. package/dist/agent/task-analyzer.js.map +1 -1
  57. package/dist/agent/task-evidence.js +2 -0
  58. package/dist/agent/task-evidence.js.map +1 -1
  59. package/dist/agent/task-sync.d.ts +11 -0
  60. package/dist/agent/task-sync.js +31 -0
  61. package/dist/agent/task-sync.js.map +1 -1
  62. package/dist/agent/tool-call-parser.d.ts +6 -13
  63. package/dist/agent/tool-call-parser.js +186 -82
  64. package/dist/agent/tool-call-parser.js.map +1 -1
  65. package/dist/agent/tool-history.d.ts +8 -2
  66. package/dist/agent/tool-history.js +8 -1
  67. package/dist/agent/tool-history.js.map +1 -1
  68. package/dist/agent/turn-outcome.d.ts +4 -1
  69. package/dist/agent/turn-outcome.js +16 -2
  70. package/dist/agent/turn-outcome.js.map +1 -1
  71. package/dist/app/adapters/agent-event-adapter.js +27 -0
  72. package/dist/app/adapters/agent-event-adapter.js.map +1 -1
  73. package/dist/app/adapters/current-agent-adapter.js +1 -0
  74. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  75. package/dist/app/adapters/current-jobs-adapter.js +4 -3
  76. package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
  77. package/dist/app/adapters/current-store-adapter.js +4 -3
  78. package/dist/app/adapters/current-store-adapter.js.map +1 -1
  79. package/dist/app/adapters/current-updates-adapter.d.ts +1 -1
  80. package/dist/app/adapters/current-updates-adapter.js +34 -5
  81. package/dist/app/adapters/current-updates-adapter.js.map +1 -1
  82. package/dist/app/adapters/quiet-meta-tools.js +2 -1
  83. package/dist/app/adapters/quiet-meta-tools.js.map +1 -1
  84. package/dist/app/controllers/job-controller.d.ts +4 -3
  85. package/dist/app/controllers/job-controller.js +9 -6
  86. package/dist/app/controllers/job-controller.js.map +1 -1
  87. package/dist/app/controllers/plan-controller.js +14 -0
  88. package/dist/app/controllers/plan-controller.js.map +1 -1
  89. package/dist/app/controllers/responder-settlement.js +2 -2
  90. package/dist/app/controllers/responder-settlement.js.map +1 -1
  91. package/dist/app/controllers/session-compact-helper.d.ts +23 -1
  92. package/dist/app/controllers/session-compact-helper.js +117 -7
  93. package/dist/app/controllers/session-compact-helper.js.map +1 -1
  94. package/dist/app/controllers/session-context-usage.d.ts +24 -0
  95. package/dist/app/controllers/session-context-usage.js +89 -0
  96. package/dist/app/controllers/session-context-usage.js.map +1 -0
  97. package/dist/app/controllers/session-controller.d.ts +18 -17
  98. package/dist/app/controllers/session-controller.js +127 -119
  99. package/dist/app/controllers/session-controller.js.map +1 -1
  100. package/dist/app/controllers/session-prompt-queue.d.ts +3 -0
  101. package/dist/app/controllers/session-prompt-queue.js +20 -1
  102. package/dist/app/controllers/session-prompt-queue.js.map +1 -1
  103. package/dist/app/controllers/session-responder.js +16 -6
  104. package/dist/app/controllers/session-responder.js.map +1 -1
  105. package/dist/app/controllers/session-turn-request.d.ts +18 -0
  106. package/dist/app/controllers/session-turn-request.js +31 -0
  107. package/dist/app/controllers/session-turn-request.js.map +1 -0
  108. package/dist/app/controllers/turn-continuation.d.ts +14 -0
  109. package/dist/app/controllers/turn-continuation.js +46 -0
  110. package/dist/app/controllers/turn-continuation.js.map +1 -0
  111. package/dist/app/events/app-event.d.ts +21 -0
  112. package/dist/app/events/app-event.js +1 -0
  113. package/dist/app/events/app-event.js.map +1 -1
  114. package/dist/app/ports/agent-port.d.ts +7 -0
  115. package/dist/app/ports/jobs-port.d.ts +4 -3
  116. package/dist/app/ports/persistence-port.d.ts +11 -0
  117. package/dist/app/ports/updates-port.d.ts +8 -0
  118. package/dist/attachments/service.js +13 -2
  119. package/dist/attachments/service.js.map +1 -1
  120. package/dist/commands/providers.js +13 -10
  121. package/dist/commands/providers.js.map +1 -1
  122. package/dist/commands/update.d.ts +4 -0
  123. package/dist/commands/update.js +14 -0
  124. package/dist/commands/update.js.map +1 -1
  125. package/dist/llm/adapters/anthropic-tools.d.ts +14 -1
  126. package/dist/llm/adapters/anthropic-tools.js +36 -5
  127. package/dist/llm/adapters/anthropic-tools.js.map +1 -1
  128. package/dist/llm/adapters/gemini-tools.d.ts +3 -1
  129. package/dist/llm/adapters/gemini-tools.js +5 -2
  130. package/dist/llm/adapters/gemini-tools.js.map +1 -1
  131. package/dist/llm/adapters/ollama-tools.js +8 -3
  132. package/dist/llm/adapters/ollama-tools.js.map +1 -1
  133. package/dist/llm/adapters/openai-tools.js +8 -1
  134. package/dist/llm/adapters/openai-tools.js.map +1 -1
  135. package/dist/llm/agentrouter.js +2 -0
  136. package/dist/llm/agentrouter.js.map +1 -1
  137. package/dist/llm/anthropic.d.ts +8 -0
  138. package/dist/llm/anthropic.js +68 -9
  139. package/dist/llm/anthropic.js.map +1 -1
  140. package/dist/llm/aws-mantle.js +60 -12
  141. package/dist/llm/aws-mantle.js.map +1 -1
  142. package/dist/llm/bynara.js +2 -0
  143. package/dist/llm/bynara.js.map +1 -1
  144. package/dist/llm/capabilities.d.ts +7 -0
  145. package/dist/llm/capabilities.js +22 -0
  146. package/dist/llm/capabilities.js.map +1 -1
  147. package/dist/llm/gemini.d.ts +3 -0
  148. package/dist/llm/gemini.js +71 -11
  149. package/dist/llm/gemini.js.map +1 -1
  150. package/dist/llm/groq.d.ts +20 -1
  151. package/dist/llm/groq.js +32 -4
  152. package/dist/llm/groq.js.map +1 -1
  153. package/dist/llm/http.d.ts +29 -0
  154. package/dist/llm/http.js +122 -29
  155. package/dist/llm/http.js.map +1 -1
  156. package/dist/llm/kimchi.js +2 -0
  157. package/dist/llm/kimchi.js.map +1 -1
  158. package/dist/llm/nvidia.js +2 -0
  159. package/dist/llm/nvidia.js.map +1 -1
  160. package/dist/llm/ollama.d.ts +5 -0
  161. package/dist/llm/ollama.js +53 -6
  162. package/dist/llm/ollama.js.map +1 -1
  163. package/dist/llm/openai.js +2 -0
  164. package/dist/llm/openai.js.map +1 -1
  165. package/dist/llm/openrouter.js +2 -0
  166. package/dist/llm/openrouter.js.map +1 -1
  167. package/dist/llm/qwen-cloud.js +2 -0
  168. package/dist/llm/qwen-cloud.js.map +1 -1
  169. package/dist/llm/router.d.ts +16 -0
  170. package/dist/llm/router.js +79 -23
  171. package/dist/llm/router.js.map +1 -1
  172. package/dist/llm/sampling.d.ts +32 -0
  173. package/dist/llm/sampling.js +48 -0
  174. package/dist/llm/sampling.js.map +1 -0
  175. package/dist/llm/stream-progress.d.ts +4 -0
  176. package/dist/llm/stream-progress.js +43 -0
  177. package/dist/llm/stream-progress.js.map +1 -0
  178. package/dist/llm/system-messages.d.ts +29 -0
  179. package/dist/llm/system-messages.js +51 -0
  180. package/dist/llm/system-messages.js.map +1 -0
  181. package/dist/llm/token-usage.d.ts +4 -1
  182. package/dist/llm/token-usage.js +56 -4
  183. package/dist/llm/token-usage.js.map +1 -1
  184. package/dist/llm/tool-protocol.js +30 -0
  185. package/dist/llm/tool-protocol.js.map +1 -1
  186. package/dist/os/process-tree.d.ts +15 -0
  187. package/dist/os/process-tree.js +77 -0
  188. package/dist/os/process-tree.js.map +1 -0
  189. package/dist/prompts/embedded.js +1 -1
  190. package/dist/prompts/embedded.js.map +1 -1
  191. package/dist/prompts/system.agent.md +13 -12
  192. package/dist/repl/prompt-line.d.ts +5 -0
  193. package/dist/repl/prompt-line.js +7 -1
  194. package/dist/repl/prompt-line.js.map +1 -1
  195. package/dist/repl.js +61 -41
  196. package/dist/repl.js.map +1 -1
  197. package/dist/safety/classifier.js +98 -44
  198. package/dist/safety/classifier.js.map +1 -1
  199. package/dist/safety/engagement-policy.d.ts +8 -0
  200. package/dist/safety/engagement-policy.js +52 -7
  201. package/dist/safety/engagement-policy.js.map +1 -1
  202. package/dist/safety/patterns.d.ts +44 -0
  203. package/dist/safety/patterns.js +168 -37
  204. package/dist/safety/patterns.js.map +1 -1
  205. package/dist/store/config.d.ts +9 -1
  206. package/dist/store/config.js +51 -1
  207. package/dist/store/config.js.map +1 -1
  208. package/dist/store/history-index.d.ts +12 -0
  209. package/dist/store/history-index.js +47 -18
  210. package/dist/store/history-index.js.map +1 -1
  211. package/dist/store/history.d.ts +5 -2
  212. package/dist/store/history.js +153 -62
  213. package/dist/store/history.js.map +1 -1
  214. package/dist/store/plan.d.ts +81 -5
  215. package/dist/store/plan.js +406 -46
  216. package/dist/store/plan.js.map +1 -1
  217. package/dist/store/redaction-cache.d.ts +12 -0
  218. package/dist/store/redaction-cache.js +51 -0
  219. package/dist/store/redaction-cache.js.map +1 -0
  220. package/dist/store/responder-settlement.d.ts +5 -1
  221. package/dist/store/responder-settlement.js +42 -30
  222. package/dist/store/responder-settlement.js.map +1 -1
  223. package/dist/store/scope.js +36 -27
  224. package/dist/store/scope.js.map +1 -1
  225. package/dist/store/task-transitions.d.ts +19 -0
  226. package/dist/store/task-transitions.js +46 -0
  227. package/dist/store/task-transitions.js.map +1 -0
  228. package/dist/tools/capabilities.js +33 -18
  229. package/dist/tools/capabilities.js.map +1 -1
  230. package/dist/tools/command-intent.d.ts +24 -0
  231. package/dist/tools/command-intent.js +158 -4
  232. package/dist/tools/command-intent.js.map +1 -1
  233. package/dist/tools/definitions.d.ts +5 -1
  234. package/dist/tools/definitions.js +162 -21
  235. package/dist/tools/definitions.js.map +1 -1
  236. package/dist/tools/fs.d.ts +16 -0
  237. package/dist/tools/fs.js +122 -46
  238. package/dist/tools/fs.js.map +1 -1
  239. package/dist/tools/image.js +1 -1
  240. package/dist/tools/image.js.map +1 -1
  241. package/dist/tools/jobs.d.ts +78 -4
  242. package/dist/tools/jobs.js +542 -115
  243. package/dist/tools/jobs.js.map +1 -1
  244. package/dist/tools/net-ping-sweep.d.ts +3 -1
  245. package/dist/tools/net-ping-sweep.js +64 -26
  246. package/dist/tools/net-ping-sweep.js.map +1 -1
  247. package/dist/tools/nmap-runner.js +5 -2
  248. package/dist/tools/nmap-runner.js.map +1 -1
  249. package/dist/tools/pdf.js +50 -7
  250. package/dist/tools/pdf.js.map +1 -1
  251. package/dist/tools/registry.js +154 -96
  252. package/dist/tools/registry.js.map +1 -1
  253. package/dist/tools/shell.d.ts +24 -2
  254. package/dist/tools/shell.js +113 -28
  255. package/dist/tools/shell.js.map +1 -1
  256. package/dist/tools/tool-types.d.ts +2 -0
  257. package/dist/tools/validate.d.ts +8 -5
  258. package/dist/tools/validate.js +177 -7
  259. package/dist/tools/validate.js.map +1 -1
  260. package/dist/tools/wordlists.js +69 -17
  261. package/dist/tools/wordlists.js.map +1 -1
  262. package/dist/tui/state.d.ts +4 -0
  263. package/dist/tui/state.js +88 -2
  264. package/dist/tui/state.js.map +1 -1
  265. package/dist/tui-v2/actions/keymap.js +3 -8
  266. package/dist/tui-v2/actions/keymap.js.map +1 -1
  267. package/dist/tui-v2/app/App.js +56 -5
  268. package/dist/tui-v2/app/App.js.map +1 -1
  269. package/dist/tui-v2/app/commands/config-commands.js +5 -2
  270. package/dist/tui-v2/app/commands/config-commands.js.map +1 -1
  271. package/dist/tui-v2/app/commands/picker-commands.js +3 -0
  272. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  273. package/dist/tui-v2/app/plan-lifecycle.d.ts +1 -1
  274. package/dist/tui-v2/app/plan-lifecycle.js +2 -1
  275. package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
  276. package/dist/tui-v2/components/composer/composer-input-box.d.ts +1 -1
  277. package/dist/tui-v2/components/composer/composer-input-box.js +12 -3
  278. package/dist/tui-v2/components/composer/composer-input-box.js.map +1 -1
  279. package/dist/tui-v2/components/jobs/jobs-panel.js +19 -6
  280. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  281. package/dist/tui-v2/components/plan/plan-view.js +9 -6
  282. package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
  283. package/dist/tui-v2/components/status/status-line.d.ts +15 -0
  284. package/dist/tui-v2/components/status/status-line.js +33 -25
  285. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  286. package/dist/tui-v2/components/transcript/assistant-message.js +17 -9
  287. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  288. package/dist/tui-v2/components/transcript/compacted-row.js +49 -22
  289. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  290. package/dist/tui-v2/components/transcript/intro-card.js +3 -0
  291. package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
  292. package/dist/tui-v2/components/transcript/thinking-block.js +2 -1
  293. package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
  294. package/dist/tui-v2/components/transcript/transcript-view.js +27 -2
  295. package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
  296. package/dist/tui-v2/components/transcript/use-transcript-selection.js +57 -6
  297. package/dist/tui-v2/components/transcript/use-transcript-selection.js.map +1 -1
  298. package/dist/tui-v2/composer/composer-editor.js +20 -12
  299. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  300. package/dist/tui-v2/rendering/incremental-strip.d.ts +11 -0
  301. package/dist/tui-v2/rendering/incremental-strip.js +89 -0
  302. package/dist/tui-v2/rendering/incremental-strip.js.map +1 -0
  303. package/dist/tui-v2/rendering/plan-view.d.ts +12 -0
  304. package/dist/tui-v2/rendering/plan-view.js +71 -2
  305. package/dist/tui-v2/rendering/plan-view.js.map +1 -1
  306. package/dist/tui-v2/rendering/streaming-markdown.d.ts +25 -0
  307. package/dist/tui-v2/rendering/streaming-markdown.js +108 -0
  308. package/dist/tui-v2/rendering/streaming-markdown.js.map +1 -0
  309. package/dist/tui-v2/rendering/thinking-tail.d.ts +5 -0
  310. package/dist/tui-v2/rendering/thinking-tail.js +26 -0
  311. package/dist/tui-v2/rendering/thinking-tail.js.map +1 -0
  312. package/dist/tui-v2/state/transcript-hydrate.js +6 -2
  313. package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
  314. package/dist/tui-v2/state/transcript-reducer.js +146 -25
  315. package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
  316. package/dist/tui-v2/state/transcript-types.d.ts +6 -0
  317. package/dist/tui-v2/state/transcript-types.js +17 -0
  318. package/dist/tui-v2/state/transcript-types.js.map +1 -1
  319. package/dist/types.d.ts +29 -0
  320. package/dist/types.js.map +1 -1
  321. package/dist/ui/intro-header.d.ts +1 -0
  322. package/dist/ui/intro-header.js +10 -13
  323. package/dist/ui/intro-header.js.map +1 -1
  324. package/dist/ui/markdown.js +9 -2
  325. package/dist/ui/markdown.js.map +1 -1
  326. package/dist/ui/mentions.d.ts +5 -0
  327. package/dist/ui/mentions.js +66 -7
  328. package/dist/ui/mentions.js.map +1 -1
  329. package/dist/ui/thinking.d.ts +5 -1
  330. package/dist/ui/thinking.js +3 -2
  331. package/dist/ui/thinking.js.map +1 -1
  332. package/dist/version.generated.d.ts +2 -2
  333. package/dist/version.generated.js +2 -2
  334. package/package.json +2 -1
@@ -1,13 +1,14 @@
1
1
  import { spawn, execFileSync } from "node:child_process";
2
- import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { mkdir, open, stat } from "node:fs/promises";
4
- import { basename, join } from "node:path";
4
+ import { basename, join, sep } from "node:path";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { StringDecoder } from "node:string_decoder";
7
7
  import { redactSecrets } from "../llm/provider.js";
8
8
  import { safeCwd } from "../os/cwd.js";
9
9
  import { getJobsDir } from "../store/paths.js";
10
10
  import { resolveShell } from "./shell.js";
11
+ import { terminateProcessTree } from "../os/process-tree.js";
11
12
  export function formatJobElapsed(job, now = Date.now()) {
12
13
  const startedAt = Date.parse(job.startedAt);
13
14
  const endedAt = job.endedAt ? Date.parse(job.endedAt) : now;
@@ -31,6 +32,15 @@ function commandDisplay(command) {
31
32
  return command;
32
33
  return command.display ?? [command.command, ...command.argv].map(displayArg).join(" ");
33
34
  }
35
+ function launchFollowUp(id, responder) {
36
+ return responder
37
+ ? "Responder owns completion tracking for this self-completing job. " +
38
+ "Do not poll it with shell.tail/shell.jobs, sleep, or read its artifact. " +
39
+ "Continue other work; if not already delivered, its terminal result will be delivered automatically."
40
+ : "OS launch does not prove application readiness or continued liveness. " +
41
+ `This is a normal background job: use shell.tail {"id":"${id}"} with nextOffset and shell.jobs for status; ` +
42
+ "for a server or watcher, also run an application readiness probe. Do not launch a duplicate.";
43
+ }
34
44
  const PER_FILE_BYTES = 1024 * 1024;
35
45
  const MAX_STREAM_BYTES = 16 * 1024 * 1024;
36
46
  const DEFAULT_TAIL_BYTES = 8_000;
@@ -42,10 +52,16 @@ const MAX_DURABLE_TERMINAL_JOBS = 80;
42
52
  const TERMINAL_JOB_MAX_AGE_MS = 48 * 60 * 60 * 1000;
43
53
  const ARCHIVED_UNSETTLED_MAX_AGE_MS = 24 * 60 * 60 * 1000;
44
54
  const MAX_ARCHIVED_UNSETTLED_NOTIFICATIONS = 40;
55
+ const MAX_SUPERSEDED_REVISIONS = 5;
56
+ const SETTLEMENT_MAX_BACKOFF_MS = 30_000;
57
+ const LIVENESS_WATCH_INTERVAL_MS = 2_000;
58
+ const SETTLEMENT_DEAD_LETTER_MS = 10 * 60_000;
45
59
  /** Max lines shell.jobs returns to the model (running first, then recent). */
46
60
  const LIST_JOBS_MAX_LINES = 40;
47
61
  /** Coalesce window for chatty stdout/stderr progress persistence + UI events. */
48
62
  const PROGRESS_FLUSH_MS = 250;
63
+ /** Durable registry cadence for running-job progress (UI updates stay at 250ms). */
64
+ const DURABLE_PROGRESS_FLUSH_MS = 5_000;
49
65
  /**
50
66
  * A live job with no in-process ChildProcess handle (resumed session, or a
51
67
  * handle that was released) is only declared "lost" after failing the liveness
@@ -55,6 +71,8 @@ const PROGRESS_FLUSH_MS = 250;
55
71
  * elapsed, and a ✗ status=lost that later flipped back to exit=0).
56
72
  */
57
73
  const LIVENESS_LOST_GRACE_MS = 8_000;
74
+ /** Minimum spacing between liveness probes for the same job. */
75
+ const LIVENESS_PROBE_INTERVAL_MS = 1_000;
58
76
  /**
59
77
  * Number of trailing bytes in `buf` that form an incomplete multi-byte UTF-8
60
78
  * sequence (a lead byte whose continuation bytes were cut off by the read
@@ -91,27 +109,75 @@ function processAlive(pid) {
91
109
  return error.code === "EPERM";
92
110
  }
93
111
  }
94
- function processIdentity(pid) {
95
- if (!pid || process.platform === "win32")
112
+ /** Identity is invariant for a pid's lifetime, so it caches safely. */
113
+ const PROCESS_IDENTITY_TTL_MS = 15_000;
114
+ const PROCESS_IDENTITY_CACHE_MAX = 512;
115
+ const processIdentityCache = new Map();
116
+ function readLinuxProcessStart(pid) {
117
+ try {
118
+ const raw = readFileSync(`/proc/${pid}/stat`, "utf8");
119
+ const commEnd = raw.lastIndexOf(")");
120
+ if (commEnd < 0)
121
+ return undefined;
122
+ const fields = raw.slice(commEnd + 2).trim().split(/\s+/);
123
+ const startTime = fields[19];
124
+ return startTime && startTime.length > 0 ? startTime : undefined;
125
+ }
126
+ catch {
96
127
  return undefined;
128
+ }
129
+ }
130
+ function readPsProcessStart(pid) {
97
131
  try {
98
- // Start time only: it is invariant for a pid's lifetime and changes on
99
- // reuse, so it is a sufficient pid-reuse guard. The command line is
100
- // deliberately excluded `sh -c "cmd"` execs into `cmd`, mutating
101
- // `ps command=` mid-run, which made a live job fail its OWN identity check
102
- // (stop refused with "process identity no longer matches", liveness falsely
103
- // marked lost, and double-Esc cancel unable to kill the process).
104
- const value = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
132
+ // Start time only: the command line is deliberately excluded `sh -c "cmd"`
133
+ // execs into `cmd`, mutating `ps command=` mid-run, which made a live job
134
+ // fail its OWN identity check.
135
+ return execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
105
136
  encoding: "utf8",
106
137
  timeout: 2_000,
107
138
  stdio: ["ignore", "pipe", "ignore"],
108
139
  }).trim();
109
- return value ? createHash("sha256").update(value).digest("hex") : undefined;
110
140
  }
111
141
  catch {
112
142
  return undefined;
113
143
  }
114
144
  }
145
+ function computeProcessIdentity(pid) {
146
+ const raw = process.platform === "linux"
147
+ ? readLinuxProcessStart(pid)
148
+ : readPsProcessStart(pid);
149
+ return raw ? createHash("sha256").update(raw).digest("hex") : undefined;
150
+ }
151
+ function pruneProcessIdentityCache(now) {
152
+ if (processIdentityCache.size <= PROCESS_IDENTITY_CACHE_MAX)
153
+ return;
154
+ for (const [pid, entry] of processIdentityCache) {
155
+ if (now - entry.at >= PROCESS_IDENTITY_TTL_MS)
156
+ processIdentityCache.delete(pid);
157
+ }
158
+ if (processIdentityCache.size > PROCESS_IDENTITY_CACHE_MAX) {
159
+ processIdentityCache.clear();
160
+ }
161
+ }
162
+ function forgetProcessIdentity(pid) {
163
+ if (pid)
164
+ processIdentityCache.delete(pid);
165
+ }
166
+ function processIdentity(pid, options = {}) {
167
+ if (!pid || process.platform === "win32")
168
+ return undefined;
169
+ const now = Date.now();
170
+ const cached = processIdentityCache.get(pid);
171
+ if (cached &&
172
+ !options.refresh &&
173
+ now - cached.at < PROCESS_IDENTITY_TTL_MS) {
174
+ return cached.value;
175
+ }
176
+ const value = computeProcessIdentity(pid);
177
+ processIdentityCache.set(pid, { value, at: now });
178
+ pruneProcessIdentityCache(now);
179
+ return value;
180
+ }
115
181
  /**
116
182
  * Heuristic for legacy registry rows that were actually agent tool-stall
117
183
  * trackers (commandDisplay = "fs.list /path", "shell.jobs {}", …), not real
@@ -268,7 +334,9 @@ export class JobManager {
268
334
  authorizationTimers = new Map();
269
335
  responderLeases = new Map();
270
336
  settlementTimers = new Map();
271
- settlementAttempts = new Map();
337
+ pendingSettlements = new Map();
338
+ consumedResponderResults = new Map();
339
+ livenessWatchTimer;
272
340
  finalizations = new Map();
273
341
  listeners = new Set();
274
342
  /**
@@ -278,6 +346,7 @@ export class JobManager {
278
346
  * observed alive again or finalized.
279
347
  */
280
348
  livenessMisses = new Map();
349
+ livenessCheckedAt = new Map();
281
350
  registryRetryTimer;
282
351
  registryPath;
283
352
  transientV2RegistryPath;
@@ -286,10 +355,84 @@ export class JobManager {
286
355
  this.registryPath = join(this.jobsDir, REGISTRY_FILE);
287
356
  this.transientV2RegistryPath = join(this.jobsDir, TRANSIENT_V2_REGISTRY_FILE);
288
357
  this.loadAndReconcile();
358
+ this.sweepOrphanArtifacts();
289
359
  }
290
360
  isDurable(job) {
291
361
  return job.kind !== "ephemeral";
292
362
  }
363
+ artifactPathsOf(job) {
364
+ return [
365
+ job.artifactPath,
366
+ job.stdoutArtifact,
367
+ job.stderrArtifact,
368
+ ...(job.artifacts?.stdout.chunks ?? []),
369
+ ...(job.artifacts?.stderr.chunks ?? []),
370
+ ].filter((path) => Boolean(path));
371
+ }
372
+ /** Delete a dropped job's artifact chunks, never one still referenced. */
373
+ removeJobArtifacts(job) {
374
+ const paths = this.artifactPathsOf(job).filter((path) => path.startsWith(`${this.jobsDir}${sep}`));
375
+ if (paths.length === 0)
376
+ return;
377
+ const referenced = new Set();
378
+ for (const other of this.jobs.values()) {
379
+ for (const path of this.artifactPathsOf(other))
380
+ referenced.add(path);
381
+ }
382
+ for (const path of new Set(paths)) {
383
+ if (referenced.has(path))
384
+ continue;
385
+ try {
386
+ rmSync(path, { force: true });
387
+ }
388
+ catch {
389
+ // Artifact cleanup is best-effort; a locked file is retried on boot.
390
+ }
391
+ }
392
+ }
393
+ /** Drop a job row plus its per-job caches and artifacts. */
394
+ forgetJob(id) {
395
+ const job = this.jobs.get(id);
396
+ this.jobs.delete(id);
397
+ for (const [notificationId, notification] of this.notifications) {
398
+ if (notification.jobId === id)
399
+ this.notifications.delete(notificationId);
400
+ }
401
+ this.consumedResponderResults.delete(id);
402
+ this.livenessMisses.delete(id);
403
+ this.livenessCheckedAt.delete(id);
404
+ if (!job)
405
+ return;
406
+ forgetProcessIdentity(job.pid);
407
+ this.removeJobArtifacts(job);
408
+ }
409
+ /** Remove artifact files whose job row no longer exists. */
410
+ sweepOrphanArtifacts() {
411
+ let names;
412
+ try {
413
+ names = readdirSync(this.jobsDir);
414
+ }
415
+ catch {
416
+ return;
417
+ }
418
+ const known = new Set(this.jobs.keys());
419
+ const now = Date.now();
420
+ for (const name of names) {
421
+ if (!/\.(stdout|stderr)\.log(\.\d+)?$/.test(name))
422
+ continue;
423
+ if ([...known].some((id) => name.includes(`-${id}.`)))
424
+ continue;
425
+ const path = join(this.jobsDir, name);
426
+ try {
427
+ if (now - statSync(path).mtimeMs <= TERMINAL_JOB_MAX_AGE_MS)
428
+ continue;
429
+ rmSync(path, { force: true });
430
+ }
431
+ catch {
432
+ // Ignore unreadable/locked leftovers.
433
+ }
434
+ }
435
+ }
293
436
  isLive(job) {
294
437
  return (job.status === "running" ||
295
438
  job.status === "starting" ||
@@ -355,18 +498,17 @@ export class JobManager {
355
498
  if (!current || (leaseId && current !== leaseId))
356
499
  return;
357
500
  this.responderLeases.delete(sessionId);
358
- const archivedAt = new Date().toISOString();
359
501
  let changed = false;
360
502
  for (const notification of this.notifications.values()) {
361
- if (notification.ownerSessionId === sessionId &&
362
- notification.responderLeaseId === current) {
363
- this.claimedNotifications.delete(notification.id);
503
+ if (notification.ownerSessionId !== sessionId ||
504
+ notification.responderLeaseId !== current) {
505
+ continue;
364
506
  }
365
- if (notification.ownerSessionId === sessionId &&
366
- notification.responderLeaseId === current &&
367
- !notification.acknowledgedAt &&
368
- !notification.archivedAt) {
369
- notification.archivedAt = archivedAt;
507
+ this.claimedNotifications.delete(notification.id);
508
+ // Releasing a runtime lease must not discard an unread durable result;
509
+ // detach it so the next lease for this session adopts it.
510
+ if (!notification.acknowledgedAt && !notification.archivedAt) {
511
+ delete notification.responderLeaseId;
370
512
  changed = true;
371
513
  }
372
514
  }
@@ -399,10 +541,60 @@ export class JobManager {
399
541
  JSON.stringify(stdoutArtifact) ||
400
542
  JSON.stringify(existing.stderrArtifact) !==
401
543
  JSON.stringify(stderrArtifact)));
402
- const reopenSettlement = Boolean(existing?.acknowledgedAt && completionChanged);
403
- const responderLeaseId = job.responderLeaseId ?? existing?.responderLeaseId;
404
- const leaseIsActive = Boolean(responderLeaseId &&
405
- this.responderLeases.get(job.ownerSessionId) === responderLeaseId);
544
+ const resultHash = createHash("sha256")
545
+ .update([
546
+ job.status,
547
+ endedAt,
548
+ String(job.exitCode ?? ""),
549
+ String(job.signal ?? ""),
550
+ JSON.stringify(stdoutArtifact),
551
+ JSON.stringify(stderrArtifact),
552
+ ].join("\0"))
553
+ .digest("hex")
554
+ .slice(0, 24);
555
+ const consumed = this.consumedResponderResults.get(job.id);
556
+ if (!existing && consumed?.resultHash === resultHash) {
557
+ return { created: false, updated: false };
558
+ }
559
+ const correctsConsumed = Boolean(!existing && consumed && consumed.resultHash !== resultHash);
560
+ if (correctsConsumed)
561
+ this.consumedResponderResults.delete(job.id);
562
+ // A materially different authoritative result is a new revision: every
563
+ // delivery/read/analysis marker belonged to the superseded result and must
564
+ // not suppress review of the corrected one.
565
+ const supersedes = Boolean(existing &&
566
+ completionChanged &&
567
+ (existing.resultHash === undefined || existing.resultHash !== resultHash));
568
+ const resultRevision = supersedes
569
+ ? (existing?.resultRevision ?? 1) + 1
570
+ : correctsConsumed
571
+ ? (consumed?.resultRevision ?? 1) + 1
572
+ : (existing?.resultRevision ?? 1);
573
+ const supersededRevisions = supersedes && existing
574
+ ? [
575
+ ...(existing.supersededRevisions ?? []),
576
+ {
577
+ resultRevision: existing.resultRevision ?? 1,
578
+ resultHash: existing.resultHash ?? "",
579
+ status: existing.status,
580
+ endedAt: existing.endedAt,
581
+ ...(existing.exitCode !== undefined
582
+ ? { exitCode: existing.exitCode }
583
+ : {}),
584
+ ...(existing.signal !== undefined ? { signal: existing.signal } : {}),
585
+ ...(existing.deliveredAt ? { deliveredAt: existing.deliveredAt } : {}),
586
+ ...(existing.readAt ? { readAt: existing.readAt } : {}),
587
+ ...(existing.analyzedAt ? { analyzedAt: existing.analyzedAt } : {}),
588
+ ...(existing.acknowledgedAt
589
+ ? { acknowledgedAt: existing.acknowledgedAt }
590
+ : {}),
591
+ ...(existing.settledAt ? { settledAt: existing.settledAt } : {}),
592
+ },
593
+ ].slice(-MAX_SUPERSEDED_REVISIONS)
594
+ : existing?.supersededRevisions;
595
+ const carried = supersedes ? undefined : existing;
596
+ const activeLeaseId = this.responderLeases.get(job.ownerSessionId);
597
+ const responderLeaseId = activeLeaseId ?? job.responderLeaseId ?? existing?.responderLeaseId;
406
598
  const notification = {
407
599
  id: existing?.id ??
408
600
  `completion:${createHash("sha256").update(`${job.ownerSessionId}\0${job.id}\0${job.startedAt}`).digest("hex").slice(0, 24)}`,
@@ -423,21 +615,34 @@ export class JobManager {
423
615
  ...(job.signal !== undefined ? { signal: job.signal } : {}),
424
616
  ...(job.monitor !== undefined ? { monitor: job.monitor } : {}),
425
617
  ...(responderLeaseId ? { responderLeaseId } : {}),
426
- ...(existing?.deliveredAt ? { deliveredAt: existing.deliveredAt } : {}),
427
- ...(existing?.readAt ? { readAt: existing.readAt } : {}),
428
- ...((existing?.analyzedAt ?? existing?.acknowledgedAt)
429
- ? { analyzedAt: existing?.analyzedAt ?? existing?.acknowledgedAt }
618
+ resultRevision,
619
+ resultHash,
620
+ ...(supersededRevisions?.length ? { supersededRevisions } : {}),
621
+ ...(carried?.deliveryStartedAt
622
+ ? { deliveryStartedAt: carried.deliveryStartedAt }
430
623
  : {}),
431
- ...(!reopenSettlement && existing?.acknowledgedAt
432
- ? { acknowledgedAt: existing.acknowledgedAt }
624
+ ...(carried?.deliveredAt ? { deliveredAt: carried.deliveredAt } : {}),
625
+ ...(carried?.readAt ? { readAt: carried.readAt } : {}),
626
+ ...((carried?.analyzedAt ?? carried?.acknowledgedAt)
627
+ ? { analyzedAt: carried?.analyzedAt ?? carried?.acknowledgedAt }
433
628
  : {}),
434
- ...(existing?.settledAt ? { settledAt: existing.settledAt } : {}),
435
- ...(!leaseIsActive
436
- ? { archivedAt: existing?.archivedAt ?? endedAt }
437
- : existing?.archivedAt
438
- ? { archivedAt: existing.archivedAt }
439
- : {}),
629
+ ...(carried?.acknowledgedAt
630
+ ? { acknowledgedAt: carried.acknowledgedAt }
631
+ : {}),
632
+ ...(carried?.settledAt ? { settledAt: carried.settledAt } : {}),
633
+ ...(carried?.discardedAt
634
+ ? {
635
+ discardedAt: carried.discardedAt,
636
+ ...(carried.discardReason
637
+ ? { discardReason: carried.discardReason }
638
+ : {}),
639
+ wakeOnCompletion: false,
640
+ }
641
+ : {}),
642
+ ...(carried?.archivedAt ? { archivedAt: carried.archivedAt } : {}),
440
643
  };
644
+ if (supersedes)
645
+ this.claimedNotifications.delete(notification.id);
441
646
  const updated = Boolean(existing && JSON.stringify(existing) !== JSON.stringify(notification));
442
647
  this.notifications.set(notification.id, notification);
443
648
  return { notification, created: !existing, updated };
@@ -514,11 +719,22 @@ export class JobManager {
514
719
  }
515
720
  }
516
721
  /** Reconcile a restored process that no longer has a ChildProcess close event. */
517
- refreshJobLiveness(job) {
722
+ refreshJobLiveness(job, options = {}) {
518
723
  if (!this.isLive(job) || this.processes.has(job.id)) {
519
724
  this.livenessMisses.delete(job.id);
725
+ this.livenessCheckedAt.delete(job.id);
726
+ return;
727
+ }
728
+ // Subscribers can ask for state many times per frame; one probe per job per
729
+ // interval is enough because the lost grace window is far longer.
730
+ const checkedAt = this.livenessCheckedAt.get(job.id);
731
+ const probeAt = Date.now();
732
+ if (!options.force &&
733
+ checkedAt !== undefined &&
734
+ probeAt - checkedAt < LIVENESS_PROBE_INTERVAL_MS) {
520
735
  return;
521
736
  }
737
+ this.livenessCheckedAt.set(job.id, probeAt);
522
738
  // Primary signal is the pid itself. If the process is alive we keep the job
523
739
  // running and only ever declare "lost" on a PROVEN pid reuse (both the
524
740
  // stored and the current identity are present AND differ). An identity read
@@ -532,6 +748,9 @@ export class JobManager {
532
748
  return;
533
749
  }
534
750
  }
751
+ else {
752
+ forgetProcessIdentity(job.pid);
753
+ }
535
754
  // The process looks gone (or is a proven pid reuse). Require the miss to
536
755
  // persist across a grace window before finalizing — a single transient
537
756
  // hiccup must not kill a job whose real close event is still in flight.
@@ -570,47 +789,122 @@ export class JobManager {
570
789
  clearTimeout(existing);
571
790
  const timer = setTimeout(() => {
572
791
  this.settlementTimers.delete(job.id);
792
+ const revision = this.notificationForJob(job.id)?.resultRevision ?? 1;
573
793
  void import("../store/responder-settlement.js")
574
- .then(({ settleResponderJob }) => settleResponderJob({ ...job }))
794
+ .then(({ settleResponderJob }) => settleResponderJob({ ...job }, { resultRevision: revision }))
575
795
  .then((result) => {
576
796
  if (result === "applied" || result === "noop") {
577
- this.settlementAttempts.delete(job.id);
578
- const notification = this.notificationForJob(job.id);
579
- if (notification && !notification.settledAt) {
580
- notification.settledAt = new Date().toISOString();
581
- if (!this.persistSync())
582
- this.scheduleRegistryRetry();
583
- this.emit({
584
- type: "notification",
585
- jobId: job.id,
586
- notificationId: notification.id,
587
- });
588
- }
797
+ this.completeSettlement(job.id, revision);
589
798
  return;
590
799
  }
591
- const attempt = (this.settlementAttempts.get(job.id) ?? 0) + 1;
592
- this.settlementAttempts.set(job.id, attempt);
593
- if (attempt < 8) {
594
- this.scheduleTaskSettlement(job, Math.min(5_000, 50 * 2 ** attempt));
595
- }
596
- else {
597
- this.settlementAttempts.delete(job.id);
598
- }
800
+ this.retrySettlement(job, revision, result);
599
801
  })
600
- .catch(() => {
601
- const attempt = (this.settlementAttempts.get(job.id) ?? 0) + 1;
602
- this.settlementAttempts.set(job.id, attempt);
603
- if (attempt < 8) {
604
- this.scheduleTaskSettlement(job, Math.min(5_000, 50 * 2 ** attempt));
605
- }
606
- else {
607
- this.settlementAttempts.delete(job.id);
608
- }
802
+ .catch((error) => {
803
+ this.retrySettlement(job, revision, error instanceof Error ? error.message : String(error));
609
804
  });
610
805
  }, delayMs);
611
806
  timer.unref?.();
612
807
  this.settlementTimers.set(job.id, timer);
613
808
  }
809
+ completeSettlement(jobId, resultRevision) {
810
+ this.pendingSettlements.delete(jobId);
811
+ const notification = this.notificationForJob(jobId);
812
+ if (!notification ||
813
+ (notification.resultRevision ?? 1) !== resultRevision ||
814
+ notification.settledAt) {
815
+ if (!this.persistSync())
816
+ this.scheduleRegistryRetry();
817
+ return;
818
+ }
819
+ notification.settledAt = new Date().toISOString();
820
+ if (!this.persistSync())
821
+ this.scheduleRegistryRetry();
822
+ this.emit({
823
+ type: "notification",
824
+ jobId,
825
+ notificationId: notification.id,
826
+ });
827
+ }
828
+ /**
829
+ * Retry until the projection lands. The marker is durable, so a crash or a
830
+ * long-lived plan-write conflict cannot silently strand a green child, and an
831
+ * unrecoverable case is dead-lettered with a visible reason.
832
+ */
833
+ retrySettlement(job, resultRevision, reason) {
834
+ const now = new Date().toISOString();
835
+ const previous = this.pendingSettlements.get(job.id);
836
+ const carryOver = previous && previous.resultRevision === resultRevision ? previous : undefined;
837
+ const attempts = (carryOver?.attempts ?? 0) + 1;
838
+ const firstAttemptAt = carryOver?.firstAttemptAt ?? now;
839
+ const elapsed = Date.now() - Date.parse(firstAttemptAt);
840
+ const deadLettered = Number.isFinite(elapsed) && elapsed > SETTLEMENT_DEAD_LETTER_MS;
841
+ this.pendingSettlements.set(job.id, {
842
+ jobId: job.id,
843
+ resultRevision,
844
+ attempts,
845
+ firstAttemptAt,
846
+ lastAttemptAt: now,
847
+ lastReason: reason,
848
+ ...(deadLettered ? { deadLetteredAt: now } : {}),
849
+ });
850
+ if (!this.persistSync())
851
+ this.scheduleRegistryRetry();
852
+ this.emit({ type: "job", jobId: job.id });
853
+ if (deadLettered)
854
+ return;
855
+ this.scheduleTaskSettlement(job, Math.min(SETTLEMENT_MAX_BACKOFF_MS, 50 * 2 ** Math.min(attempts, 10)));
856
+ }
857
+ /**
858
+ * One coalesced, unref'd watcher that reconciles restored live jobs without a
859
+ * UI read. Without it a headless session can leave a finished responder job
860
+ * "running" forever, because liveness was only refreshed by list/get calls.
861
+ */
862
+ scheduleLivenessWatch() {
863
+ if (this.livenessWatchTimer)
864
+ return;
865
+ const restored = [...this.jobs.values()].filter((job) => this.isDurable(job) && this.isLive(job) && !this.processes.has(job.id));
866
+ if (restored.length === 0)
867
+ return;
868
+ const timer = setInterval(() => {
869
+ const pending = [...this.jobs.values()].filter((job) => this.isDurable(job) && this.isLive(job) && !this.processes.has(job.id));
870
+ for (const job of pending)
871
+ this.refreshJobLiveness(job);
872
+ const remaining = [...this.jobs.values()].some((job) => this.isDurable(job) && this.isLive(job) && !this.processes.has(job.id));
873
+ if (remaining)
874
+ return;
875
+ clearInterval(timer);
876
+ this.livenessWatchTimer = undefined;
877
+ }, LIVENESS_WATCH_INTERVAL_MS);
878
+ timer.unref?.();
879
+ this.livenessWatchTimer = timer;
880
+ }
881
+ /** Ownership guard: a receipt may only be mutated by its owning session. */
882
+ ownsNotification(notification, sessionId) {
883
+ if (!notification)
884
+ return false;
885
+ return sessionId === undefined || notification.ownerSessionId === sessionId;
886
+ }
887
+ /** Receipt for a launch that was deduplicated by its delegation key. */
888
+ startJobReceipt(job, reason) {
889
+ return {
890
+ ok: true,
891
+ output: `Reusing background job id=${job.id} (${reason}) pid=${job.pid ?? "?"} status=${job.status}\n` +
892
+ `Command: ${job.commandDisplay}\nArtifact: ${job.stdoutArtifact}\n` +
893
+ `The duplicate launch was not started. ${launchFollowUp(job.id, job.responder === true)}`,
894
+ outputPath: job.stdoutArtifact,
895
+ backgroundJob: {
896
+ id: job.id,
897
+ status: job.status,
898
+ artifactPath: job.stdoutArtifact,
899
+ ...(job.responder ? { responder: true } : {}),
900
+ nextOffset: 0,
901
+ },
902
+ };
903
+ }
904
+ /** Terminal results whose plan child could not be settled yet. */
905
+ getPendingSettlements() {
906
+ return [...this.pendingSettlements.values()];
907
+ }
614
908
  /**
615
909
  * Register an in-flight tool for stall tracking only.
616
910
  * Never appears in shell.jobs and never touches the durable registry.
@@ -644,7 +938,7 @@ export class JobManager {
644
938
  if (!this.isLive(job))
645
939
  this.clearAuthorizationTimer(id);
646
940
  if (!this.isDurable(job) && !this.isLive(job)) {
647
- this.jobs.delete(id);
941
+ this.forgetJob(id);
648
942
  this.emit({ type: "job", jobId: id });
649
943
  return;
650
944
  }
@@ -661,6 +955,14 @@ export class JobManager {
661
955
  return { ok: false, output: `Engagement authorization for ${options.authorization.target} has expired.`, exitCode: 1 };
662
956
  }
663
957
  }
958
+ if (options?.delegationId) {
959
+ // A retried tool call must not spawn a second scanner: the delegation key
960
+ // identifies the work, so the existing job is returned unchanged.
961
+ const existing = [...this.jobs.values()].find((candidate) => candidate.delegationId === options.delegationId &&
962
+ candidate.ownerSessionId === (options.ownerSessionId ?? "unknown"));
963
+ if (existing)
964
+ return this.startJobReceipt(existing, "existing delegation");
965
+ }
664
966
  const id = randomUUID().slice(0, 8);
665
967
  const cwd = options?.cwd ?? safeCwd();
666
968
  try {
@@ -691,7 +993,7 @@ export class JobManager {
691
993
  exitCode: 127,
692
994
  };
693
995
  }
694
- await mkdir(this.jobsDir, { recursive: true });
996
+ await mkdir(this.jobsDir, { recursive: true, mode: 0o700 });
695
997
  const prefix = `${new Date().toISOString().replace(/[:.]/g, "-")}-${id}`;
696
998
  const stdoutArtifact = join(this.jobsDir, `${prefix}.stdout.log`);
697
999
  const stderrArtifact = join(this.jobsDir, `${prefix}.stderr.log`);
@@ -721,6 +1023,7 @@ export class JobManager {
721
1023
  ...(options?.name ? { name: options.name } : {}),
722
1024
  ...(options?.taskId ? { taskId: options.taskId } : {}),
723
1025
  ...(options?.parentTaskId ? { parentTaskId: options.parentTaskId } : {}),
1026
+ ...(options?.delegationId ? { delegationId: options.delegationId } : {}),
724
1027
  ...(options?.wakeOnCompletion !== undefined ? { wakeOnCompletion: options.wakeOnCompletion } : {}),
725
1028
  ...(options?.responder !== undefined ? { responder: options.responder } : {}),
726
1029
  ...(monitor !== undefined ? { monitor } : {}),
@@ -735,7 +1038,7 @@ export class JobManager {
735
1038
  await this.persist();
736
1039
  }
737
1040
  catch {
738
- this.jobs.delete(id);
1041
+ this.forgetJob(id);
739
1042
  return {
740
1043
  ok: false,
741
1044
  output: "Background command launch error [PERSIST_FAILED]: could not persist the job registry; the command was not started.",
@@ -801,10 +1104,14 @@ export class JobManager {
801
1104
  launchError.syscall ? `syscall=${JSON.stringify(launchError.syscall)}` : undefined,
802
1105
  launchError.path ? `path=${JSON.stringify(launchError.path)}` : undefined,
803
1106
  ].filter((value) => Boolean(value));
1107
+ const ownership = job.responder
1108
+ ? "Responder owns this terminal launch failure; do not poll or retry it. Its result will be delivered automatically."
1109
+ : "Inspect the failed job receipt before deciding whether changed evidence supports a retry.";
804
1110
  const detail = `${launchRetried ? "Automatic retry after a transient launch ENOENT also failed.\n" : ""}` +
805
1111
  `Background command launch error [${code}]: ${launchError.message}\n` +
806
1112
  `${fields.join("; ")}\n` +
807
- "The command did not start. Do not rewrite its syntax to work around this infrastructure error; verify the target and cwd.";
1113
+ "The command did not start. Do not rewrite its syntax to work around this infrastructure error; verify the target and cwd.\n" +
1114
+ ownership;
808
1115
  stderr.append(`${detail}\n`);
809
1116
  await this.finalizeJob(job, "failed", { exitCode: 127 });
810
1117
  return {
@@ -817,6 +1124,7 @@ export class JobManager {
817
1124
  status: "failed",
818
1125
  exitCode: 127,
819
1126
  artifactPath: stdoutArtifact,
1127
+ ...(job.responder ? { responder: true } : {}),
820
1128
  nextOffset: 0,
821
1129
  },
822
1130
  };
@@ -831,17 +1139,23 @@ export class JobManager {
831
1139
  job.processGroupId = detached ? child.pid : undefined;
832
1140
  job.status = "running";
833
1141
  job.heartbeatAt = new Date().toISOString();
834
- job.processIdentity = processIdentity(child.pid);
1142
+ job.processIdentity = processIdentity(child.pid, { refresh: true });
835
1143
  this.processes.set(id, child);
836
1144
  this.writers.set(id, { stdout, stderr });
837
1145
  this.scheduleAuthorizationExpiry(job);
838
1146
  let lastProgressFlush = 0;
1147
+ let lastDurableFlush = Date.now();
839
1148
  let progressDirty = false;
840
1149
  let progressTimer;
1150
+ // UI freshness is cheap; rewriting the whole registry is not. Terminal
1151
+ // transitions always persist authoritatively below.
841
1152
  const flushProgress = () => {
842
1153
  progressDirty = false;
843
1154
  lastProgressFlush = Date.now();
844
- this.persistSync();
1155
+ if (lastProgressFlush - lastDurableFlush >= DURABLE_PROGRESS_FLUSH_MS) {
1156
+ lastDurableFlush = lastProgressFlush;
1157
+ this.persistSync();
1158
+ }
845
1159
  this.emit({ type: "job", jobId: id });
846
1160
  };
847
1161
  // Coalesce high-frequency stdout/stderr chunks: a chatty job (scanner,
@@ -937,10 +1251,13 @@ export class JobManager {
937
1251
  const earlyStatus = this.getJob(id)?.status;
938
1252
  if (earlyStatus === "failed") {
939
1253
  await this.persist();
1254
+ const failureFollowUp = job.responder
1255
+ ? "Responder owns this terminal failure; do not poll or retry it. Its result will be delivered automatically."
1256
+ : `Use shell.tail {"id":"${id}"} for captured stderr; do not retry unchanged.`;
940
1257
  return {
941
1258
  ok: false,
942
1259
  output: `Background job failed immediately: id=${id} exit=${job.exitCode ?? "?"}\n` +
943
- `Command: ${job.commandDisplay}\nUse shell.tail {"id":"${id}"} for captured stderr; do not retry unchanged.`,
1260
+ `Command: ${job.commandDisplay}\n${failureFollowUp}`,
944
1261
  exitCode: job.exitCode ?? 1,
945
1262
  outputPath: stdoutArtifact,
946
1263
  backgroundJob: {
@@ -948,6 +1265,7 @@ export class JobManager {
948
1265
  status: "failed",
949
1266
  exitCode: job.exitCode,
950
1267
  artifactPath: stdoutArtifact,
1268
+ ...(job.responder ? { responder: true } : {}),
951
1269
  nextOffset: 0,
952
1270
  },
953
1271
  };
@@ -957,14 +1275,14 @@ export class JobManager {
957
1275
  return {
958
1276
  ok: true,
959
1277
  output: `OS process launch confirmed: id=${id} (canonical job ID) pid=${child.pid ?? "?"}\n` +
960
- `This does not prove application readiness or continued liveness; use shell.tail {"id":"${id}"} and a readiness probe.\n` +
961
- `Use shell.jobs for status; do not use the artifact filename as the id.\n` +
962
- `Command: ${job.commandDisplay}\nArtifact: ${stdoutArtifact}`,
1278
+ `Command: ${job.commandDisplay}\nArtifact: ${stdoutArtifact}\n` +
1279
+ launchFollowUp(id, job.responder === true),
963
1280
  outputPath: stdoutArtifact,
964
1281
  backgroundJob: {
965
1282
  id,
966
1283
  status: job.status,
967
1284
  artifactPath: stdoutArtifact,
1285
+ ...(job.responder ? { responder: true } : {}),
968
1286
  ...(options?.profile ? { profile: options.profile } : {}),
969
1287
  ...(options?.estimatedSeconds !== undefined ? { estimatedSeconds: options.estimatedSeconds } : {}),
970
1288
  nextOffset: 0,
@@ -983,10 +1301,12 @@ export class JobManager {
983
1301
  this.persistSync();
984
1302
  this.emit({ type: "job", jobId: id });
985
1303
  }
1304
+ const followUp = launchFollowUp(id, job.responder === true);
986
1305
  return {
987
1306
  ok: alive,
988
1307
  output: `Background command launched as pid=${job.pid ?? "?"}, but job setup/persistence failed: ${detail}\n` +
989
- `${alive ? `The process is still running as job ${id}; do not launch a duplicate. Use shell.jobs/tail/stop.` : "The process is no longer running; inspect its artifacts before deciding whether to retry."}`,
1308
+ `${alive ? `The process is still running as job ${id}; do not launch a duplicate. ` : "The process has already stopped. "}` +
1309
+ followUp,
990
1310
  exitCode: alive ? undefined : job.exitCode,
991
1311
  outputPath: stdoutArtifact,
992
1312
  backgroundJob: {
@@ -994,6 +1314,7 @@ export class JobManager {
994
1314
  status: job.status,
995
1315
  exitCode: job.exitCode,
996
1316
  artifactPath: stdoutArtifact,
1317
+ ...(job.responder ? { responder: true } : {}),
997
1318
  nextOffset: 0,
998
1319
  },
999
1320
  };
@@ -1002,8 +1323,18 @@ export class JobManager {
1002
1323
  return {
1003
1324
  ok: false,
1004
1325
  output: `Background command launch error [UNKNOWN]: ${detail}\n` +
1005
- `cwd=${JSON.stringify(cwd)}\nThe command did not start.`,
1326
+ `cwd=${JSON.stringify(cwd)}\nThe command did not start.\n` +
1327
+ launchFollowUp(id, job.responder === true),
1006
1328
  exitCode: 127,
1329
+ outputPath: stdoutArtifact,
1330
+ backgroundJob: {
1331
+ id,
1332
+ status: "failed",
1333
+ exitCode: 127,
1334
+ artifactPath: stdoutArtifact,
1335
+ ...(job.responder ? { responder: true } : {}),
1336
+ nextOffset: 0,
1337
+ },
1007
1338
  };
1008
1339
  }
1009
1340
  }
@@ -1039,7 +1370,27 @@ export class JobManager {
1039
1370
  ? "alive"
1040
1371
  : "unresponsive"
1041
1372
  : "terminal";
1042
- return `[${job.id}] ${job.status} health=${health} exit=${job.exitCode ?? "?"} ${formatJobElapsed(job)} ${job.commandDisplay.slice(0, 80)}`;
1373
+ const notification = this.notificationForJob(job.id);
1374
+ const receipt = notification
1375
+ ? notification.acknowledgedAt
1376
+ ? "consumed"
1377
+ : notification.analyzedAt
1378
+ ? "analyzed"
1379
+ : notification.readAt
1380
+ ? "read"
1381
+ : notification.deliveredAt
1382
+ ? "delivered-unread"
1383
+ : notification.deliveryStartedAt
1384
+ ? "delivering"
1385
+ : "ready"
1386
+ : this.consumedResponderResults.has(job.id)
1387
+ ? "consumed"
1388
+ : job.responder
1389
+ ? this.isLive(job)
1390
+ ? "running"
1391
+ : "pending"
1392
+ : undefined;
1393
+ return `[${job.id}] ${job.status} health=${health} exit=${job.exitCode ?? "?"}${receipt ? ` responder=${receipt}` : ""} ${formatJobElapsed(job)} ${job.commandDisplay.slice(0, 80)}`;
1043
1394
  };
1044
1395
  const header = sessionId
1045
1396
  ? `Session background jobs (${durable.length} total, session ${sessionId}):`
@@ -1082,6 +1433,7 @@ export class JobManager {
1082
1433
  getPendingNotifications(sessionId) {
1083
1434
  return [...this.notifications.values()]
1084
1435
  .filter((notification) => !notification.acknowledgedAt &&
1436
+ !notification.discardedAt &&
1085
1437
  (!sessionId || notification.ownerSessionId === sessionId))
1086
1438
  .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1087
1439
  }
@@ -1091,22 +1443,24 @@ export class JobManager {
1091
1443
  claimNextResponderNotification(sessionId, leaseId) {
1092
1444
  if (this.responderLeases.get(sessionId) !== leaseId)
1093
1445
  return undefined;
1094
- const archivedAt = new Date().toISOString();
1095
- let archived = false;
1446
+ let adopted = false;
1096
1447
  for (const notification of this.notifications.values()) {
1448
+ // Receipts belong to the session, not to a runtime lease: a new lease
1449
+ // adopts everything unread that an earlier lease left behind.
1097
1450
  if (notification.ownerSessionId === sessionId &&
1098
1451
  !notification.acknowledgedAt &&
1099
- notification.responderLeaseId !== leaseId &&
1100
- !notification.archivedAt) {
1101
- notification.archivedAt = archivedAt;
1102
- archived = true;
1452
+ !notification.archivedAt &&
1453
+ !notification.discardedAt &&
1454
+ notification.responderLeaseId !== leaseId) {
1455
+ notification.responderLeaseId = leaseId;
1456
+ adopted = true;
1103
1457
  }
1104
1458
  }
1105
1459
  const notification = [...this.notifications.values()]
1106
1460
  .filter((candidate) => candidate.ownerSessionId === sessionId &&
1107
1461
  candidate.responderLeaseId === leaseId &&
1108
1462
  !candidate.archivedAt &&
1109
- !candidate.deliveredAt &&
1463
+ !candidate.discardedAt &&
1110
1464
  !candidate.readAt &&
1111
1465
  !candidate.analyzedAt &&
1112
1466
  !candidate.acknowledgedAt &&
@@ -1114,26 +1468,53 @@ export class JobManager {
1114
1468
  .sort((left, right) => left.createdAt.localeCompare(right.createdAt))[0];
1115
1469
  if (notification)
1116
1470
  this.claimedNotifications.add(notification.id);
1117
- if (archived && !this.persistSync())
1471
+ if (adopted && !this.persistSync())
1118
1472
  this.scheduleRegistryRetry();
1119
1473
  return notification;
1120
1474
  }
1121
1475
  releaseResponderNotificationClaim(notificationId) {
1122
1476
  this.claimedNotifications.delete(notificationId);
1123
1477
  }
1124
- markDelivered(notificationId) {
1478
+ /**
1479
+ * Record that a delivery attempt started. This is deliberately weaker than
1480
+ * `markDelivered`: an aborted or failed analysis turn must remain deliverable.
1481
+ */
1482
+ markDeliveryStarted(notificationId, sessionId) {
1125
1483
  const notification = this.notifications.get(notificationId);
1126
- if (!notification)
1484
+ if (!this.ownsNotification(notification, sessionId))
1485
+ return false;
1486
+ if (notification.deliveryStartedAt)
1487
+ return true;
1488
+ notification.deliveryStartedAt = new Date().toISOString();
1489
+ if (!this.persistSync()) {
1490
+ notification.deliveryStartedAt = undefined;
1491
+ return false;
1492
+ }
1493
+ this.emit({
1494
+ type: "notification",
1495
+ jobId: notification.jobId,
1496
+ notificationId,
1497
+ });
1498
+ return true;
1499
+ }
1500
+ markDelivered(notificationId, sessionId) {
1501
+ const notification = this.notifications.get(notificationId);
1502
+ if (!this.ownsNotification(notification, sessionId))
1127
1503
  return false;
1128
1504
  if (!notification.deliveredAt) {
1129
1505
  notification.deliveredAt = new Date().toISOString();
1506
+ notification.deliveryStartedAt ??= notification.deliveredAt;
1130
1507
  if (!this.persistSync()) {
1131
1508
  notification.deliveredAt = undefined;
1132
1509
  return false;
1133
1510
  }
1134
1511
  this.emit({ type: "notification", jobId: notification.jobId, notificationId });
1135
1512
  }
1136
- this.claimedNotifications.delete(notificationId);
1513
+ if (notification.readAt ||
1514
+ notification.analyzedAt ||
1515
+ notification.acknowledgedAt) {
1516
+ this.claimedNotifications.delete(notificationId);
1517
+ }
1137
1518
  return true;
1138
1519
  }
1139
1520
  markRead(notificationId, sessionId) {
@@ -1141,7 +1522,8 @@ export class JobManager {
1141
1522
  if (!notification ||
1142
1523
  notification.ownerSessionId !== sessionId ||
1143
1524
  !notification.responder ||
1144
- notification.archivedAt) {
1525
+ notification.archivedAt ||
1526
+ notification.discardedAt) {
1145
1527
  return false;
1146
1528
  }
1147
1529
  const previousDeliveredAt = notification.deliveredAt;
@@ -1162,10 +1544,14 @@ export class JobManager {
1162
1544
  });
1163
1545
  return true;
1164
1546
  }
1165
- markAnalyzed(notificationId) {
1547
+ markAnalyzed(notificationId, sessionId) {
1166
1548
  const notification = this.notifications.get(notificationId);
1167
- if (!notification?.deliveredAt)
1549
+ if (!notification || !this.ownsNotification(notification, sessionId)) {
1168
1550
  return false;
1551
+ }
1552
+ if (!notification.deliveredAt && !notification.deliveryStartedAt) {
1553
+ return false;
1554
+ }
1169
1555
  this.claimedNotifications.delete(notificationId);
1170
1556
  if (!notification.analyzedAt) {
1171
1557
  notification.analyzedAt = new Date().toISOString();
@@ -1179,13 +1565,22 @@ export class JobManager {
1179
1565
  }
1180
1566
  return true;
1181
1567
  }
1182
- acknowledge(notificationId) {
1568
+ acknowledge(notificationId, sessionId) {
1183
1569
  const notification = this.notifications.get(notificationId);
1184
- if (!notification?.analyzedAt)
1570
+ if (!notification || !this.ownsNotification(notification, sessionId)) {
1571
+ return false;
1572
+ }
1573
+ if (!notification.analyzedAt)
1185
1574
  return false;
1186
1575
  this.claimedNotifications.delete(notificationId);
1187
1576
  if (!notification.acknowledgedAt) {
1188
1577
  notification.acknowledgedAt = new Date().toISOString();
1578
+ this.consumedResponderResults.set(notification.jobId, {
1579
+ jobId: notification.jobId,
1580
+ resultHash: notification.resultHash ?? "",
1581
+ resultRevision: notification.resultRevision ?? 1,
1582
+ acknowledgedAt: notification.acknowledgedAt,
1583
+ });
1189
1584
  const persisted = this.persistSync();
1190
1585
  if (!persisted)
1191
1586
  this.scheduleRegistryRetry();
@@ -1377,7 +1772,7 @@ export class JobManager {
1377
1772
  const identityMatches = () => {
1378
1773
  if (process.platform === "win32")
1379
1774
  return processAlive(pid);
1380
- const current = processIdentity(pid);
1775
+ const current = processIdentity(pid, { refresh: true });
1381
1776
  return Boolean(current && job.processIdentity && current === job.processIdentity);
1382
1777
  };
1383
1778
  if (targetAlive() && (!processAlive(pid) || !identityMatches())) {
@@ -1415,13 +1810,13 @@ export class JobManager {
1415
1810
  else if (!(processGroupId && processGroupVerified && allowVerifiedGroup)) {
1416
1811
  return "identity-mismatch";
1417
1812
  }
1418
- try {
1419
- processGroupId ? process.kill(-processGroupId, signal) : process.kill(pid, signal);
1420
- return "sent";
1421
- }
1422
- catch {
1813
+ const outcome = terminateProcessTree(pid, {
1814
+ signal,
1815
+ processGroupId,
1816
+ });
1817
+ if (outcome === "failed")
1423
1818
  return targetAlive() ? "failed" : "gone";
1424
- }
1819
+ return outcome;
1425
1820
  };
1426
1821
  const restoreRunning = () => {
1427
1822
  job.status = "running";
@@ -1536,15 +1931,16 @@ export class JobManager {
1536
1931
  acknowledgedAt: notification.acknowledgedAt,
1537
1932
  });
1538
1933
  notification.wakeOnCompletion = false;
1539
- notification.deliveredAt ??= acknowledgedAt;
1540
- notification.acknowledgedAt = acknowledgedAt;
1934
+ notification.discardedAt = acknowledgedAt;
1935
+ notification.discardReason = "session-cancelled";
1936
+ this.claimedNotifications.delete(notification.id);
1541
1937
  }
1542
1938
  const acknowledgementsPersisted = this.persistSync();
1543
1939
  if (!acknowledgementsPersisted) {
1544
1940
  for (const snapshot of notificationSnapshots) {
1545
1941
  snapshot.notification.wakeOnCompletion = snapshot.wakeOnCompletion;
1546
- snapshot.notification.deliveredAt = snapshot.deliveredAt;
1547
- snapshot.notification.acknowledgedAt = snapshot.acknowledgedAt;
1942
+ snapshot.notification.discardedAt = undefined;
1943
+ snapshot.notification.discardReason = undefined;
1548
1944
  }
1549
1945
  }
1550
1946
  else {
@@ -1611,21 +2007,26 @@ export class JobManager {
1611
2007
  if (timer)
1612
2008
  clearTimeout(timer);
1613
2009
  this.settlementTimers.delete(notification.jobId);
1614
- this.settlementAttempts.delete(notification.jobId);
2010
+ this.pendingSettlements.delete(notification.jobId);
1615
2011
  }
1616
2012
  for (const [id, notification] of this.notifications) {
1617
2013
  const job = this.jobs.get(notification.jobId);
1618
2014
  if (!job ||
2015
+ notification.discardedAt ||
1619
2016
  (notification.acknowledgedAt && job.status !== "lost")) {
1620
2017
  this.notifications.delete(id);
1621
2018
  }
1622
2019
  }
2020
+ for (const [jobId] of this.consumedResponderResults) {
2021
+ if (!this.jobs.has(jobId))
2022
+ this.consumedResponderResults.delete(jobId);
2023
+ }
1623
2024
  const durableTerminal = [];
1624
2025
  for (const [id, job] of this.jobs) {
1625
2026
  if (!this.isDurable(job)) {
1626
2027
  // Safety: never keep ephemeral rows that are not live.
1627
2028
  if (!this.isLive(job))
1628
- this.jobs.delete(id);
2029
+ this.forgetJob(id);
1629
2030
  continue;
1630
2031
  }
1631
2032
  if (this.isLive(job))
@@ -1638,7 +2039,7 @@ export class JobManager {
1638
2039
  }
1639
2040
  const ended = job.endedAt ? Date.parse(job.endedAt) : Date.parse(job.startedAt);
1640
2041
  if (Number.isFinite(ended) && now - ended > TERMINAL_JOB_MAX_AGE_MS) {
1641
- this.jobs.delete(id);
2042
+ this.forgetJob(id);
1642
2043
  continue;
1643
2044
  }
1644
2045
  durableTerminal.push(job);
@@ -1647,7 +2048,7 @@ export class JobManager {
1647
2048
  return;
1648
2049
  durableTerminal.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
1649
2050
  for (const job of durableTerminal.slice(MAX_DURABLE_TERMINAL_JOBS)) {
1650
- this.jobs.delete(job.id);
2051
+ this.forgetJob(job.id);
1651
2052
  }
1652
2053
  }
1653
2054
  loadAndReconcile() {
@@ -1667,6 +2068,23 @@ export class JobManager {
1667
2068
  catch { /* a corrupt current registry must not replay stale v1 state */ }
1668
2069
  if (!parsed)
1669
2070
  return;
2071
+ if (parsed.schemaVersion === 2 && Array.isArray(parsed.settlements)) {
2072
+ for (const settlement of parsed.settlements) {
2073
+ if (!settlement || typeof settlement.jobId !== "string")
2074
+ continue;
2075
+ this.pendingSettlements.set(settlement.jobId, settlement);
2076
+ }
2077
+ }
2078
+ if (parsed.schemaVersion === 2 && Array.isArray(parsed.consumedResults)) {
2079
+ for (const consumed of parsed.consumedResults) {
2080
+ if (!consumed ||
2081
+ typeof consumed.jobId !== "string" ||
2082
+ typeof consumed.resultHash !== "string") {
2083
+ continue;
2084
+ }
2085
+ this.consumedResponderResults.set(consumed.jobId, consumed);
2086
+ }
2087
+ }
1670
2088
  if (parsed.schemaVersion === 2 && Array.isArray(parsed.notifications)) {
1671
2089
  const notifiedJobs = new Set();
1672
2090
  for (const notification of parsed.notifications) {
@@ -1676,7 +2094,9 @@ export class JobManager {
1676
2094
  continue;
1677
2095
  notifiedJobs.add(notification.jobId);
1678
2096
  if (!notification.acknowledgedAt && !notification.archivedAt) {
1679
- notification.archivedAt = new Date().toISOString();
2097
+ // A durable receipt must survive the restart it exists for. Drop the
2098
+ // dead runtime lease so the next activated lease can adopt it.
2099
+ delete notification.responderLeaseId;
1680
2100
  }
1681
2101
  this.notifications.set(notification.id, notification);
1682
2102
  }
@@ -1713,6 +2133,7 @@ export class JobManager {
1713
2133
  if (!job?.responder)
1714
2134
  this.notifications.delete(id);
1715
2135
  }
2136
+ this.scheduleLivenessWatch();
1716
2137
  this.pruneTerminalJobs();
1717
2138
  this.persistSync();
1718
2139
  }
@@ -1724,6 +2145,12 @@ export class JobManager {
1724
2145
  schemaVersion: 2,
1725
2146
  jobs: [...this.jobs.values()].filter((job) => this.isDurable(job)),
1726
2147
  notifications: [...this.notifications.values()],
2148
+ ...(this.pendingSettlements.size
2149
+ ? { settlements: [...this.pendingSettlements.values()] }
2150
+ : {}),
2151
+ ...(this.consumedResponderResults.size
2152
+ ? { consumedResults: [...this.consumedResponderResults.values()] }
2153
+ : {}),
1727
2154
  };
1728
2155
  }
1729
2156
  scheduleRegistryRetry() {
@@ -1739,7 +2166,7 @@ export class JobManager {
1739
2166
  }
1740
2167
  persistSync() {
1741
2168
  try {
1742
- mkdirSync(this.jobsDir, { recursive: true });
2169
+ mkdirSync(this.jobsDir, { recursive: true, mode: 0o700 });
1743
2170
  const temp = `${this.registryPath}.${process.pid}.${randomUUID()}.tmp`;
1744
2171
  writeFileSync(temp, `${JSON.stringify(this.registry(), null, 2)}\n`, { mode: 0o600 });
1745
2172
  renameSync(temp, this.registryPath);