@wjarka/cezarion 0.15.1 → 0.16.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 (462) hide show
  1. package/README.md +167 -46
  2. package/dist/agent-config/catalog.d.ts +6 -2
  3. package/dist/agent-config/catalog.js +78 -3
  4. package/dist/agent-config/catalog.js.map +1 -1
  5. package/dist/agent-config/model-settings/shared.js +6 -3
  6. package/dist/agent-config/model-settings/shared.js.map +1 -1
  7. package/dist/agent-config/models.js +4 -0
  8. package/dist/agent-config/models.js.map +1 -1
  9. package/dist/agent-config/validate.js +6 -0
  10. package/dist/agent-config/validate.js.map +1 -1
  11. package/dist/artifacts/cli.js +10 -2
  12. package/dist/artifacts/cli.js.map +1 -1
  13. package/dist/automations/coordinator.d.ts +7 -0
  14. package/dist/automations/coordinator.js +22 -2
  15. package/dist/automations/coordinator.js.map +1 -1
  16. package/dist/automations/github-poller.d.ts +4 -4
  17. package/dist/automations/github-poller.js.map +1 -1
  18. package/dist/automations/schedule-runner.d.ts +130 -0
  19. package/dist/automations/schedule-runner.js +326 -0
  20. package/dist/automations/schedule-runner.js.map +1 -0
  21. package/dist/automations/scheduler.d.ts +38 -7
  22. package/dist/automations/scheduler.js +90 -22
  23. package/dist/automations/scheduler.js.map +1 -1
  24. package/dist/automations/store.d.ts +80 -3
  25. package/dist/automations/store.js +208 -16
  26. package/dist/automations/store.js.map +1 -1
  27. package/dist/automations/task-template.d.ts +42 -6
  28. package/dist/automations/task-template.js +152 -24
  29. package/dist/automations/task-template.js.map +1 -1
  30. package/dist/automations/types.d.ts +142 -6
  31. package/dist/automations/types.js +64 -6
  32. package/dist/automations/types.js.map +1 -1
  33. package/dist/autosave-holders.js +8 -12
  34. package/dist/autosave-holders.js.map +1 -1
  35. package/dist/ci-wait/client.d.ts +8 -1
  36. package/dist/ci-wait/client.js +43 -1
  37. package/dist/ci-wait/client.js.map +1 -1
  38. package/dist/ci-wait/controller.d.ts +83 -4
  39. package/dist/ci-wait/controller.js +76 -11
  40. package/dist/ci-wait/controller.js.map +1 -1
  41. package/dist/ci-wait/errors.d.ts +7 -1
  42. package/dist/ci-wait/errors.js +36 -0
  43. package/dist/ci-wait/errors.js.map +1 -1
  44. package/dist/ci-wait/github.d.ts +2 -0
  45. package/dist/ci-wait/github.js +3 -1
  46. package/dist/ci-wait/github.js.map +1 -1
  47. package/dist/ci-wait/mcp.d.ts +2 -98
  48. package/dist/ci-wait/mcp.js +11 -25
  49. package/dist/ci-wait/mcp.js.map +1 -1
  50. package/dist/ci-wait/supervisor.d.ts +2 -1
  51. package/dist/ci-wait/supervisor.js +2 -1
  52. package/dist/ci-wait/supervisor.js.map +1 -1
  53. package/dist/ci-wait/tools.d.ts +307 -0
  54. package/dist/ci-wait/tools.js +63 -0
  55. package/dist/ci-wait/tools.js.map +1 -0
  56. package/dist/config.d.ts +3 -0
  57. package/dist/config.js +7 -0
  58. package/dist/config.js.map +1 -1
  59. package/dist/contract/agent-config.d.ts +5 -0
  60. package/dist/contract/agent-profiles.d.ts +15 -0
  61. package/dist/contract/automation-schedule.d.ts +60 -0
  62. package/dist/contract/automations.d.ts +208 -36
  63. package/dist/contract/delegation.d.ts +96 -0
  64. package/dist/contract/discovery.d.ts +7 -0
  65. package/dist/contract/github.d.ts +1 -0
  66. package/dist/contract/health.d.ts +6 -0
  67. package/dist/contract/index.d.ts +3 -0
  68. package/dist/contract/index.js +1445 -745
  69. package/dist/contract/preview.d.ts +508 -0
  70. package/dist/contract/runs.d.ts +716 -15
  71. package/dist/contract/skills.d.ts +39 -0
  72. package/dist/contract/workflows.d.ts +6 -0
  73. package/dist/contract/workspace.d.ts +65 -0
  74. package/dist/contract/zoned-time.d.ts +35 -0
  75. package/dist/core/agent-env.d.ts +11 -0
  76. package/dist/core/agent-env.js +27 -5
  77. package/dist/core/agent-env.js.map +1 -1
  78. package/dist/core/agent-profiles.js +7 -0
  79. package/dist/core/agent-profiles.js.map +1 -1
  80. package/dist/core/agent-runner.d.ts +49 -1
  81. package/dist/core/agent-runner.js +2 -1
  82. package/dist/core/agent-runner.js.map +1 -1
  83. package/dist/core/backend-detect.d.ts +3 -2
  84. package/dist/core/backend-detect.js +27 -1
  85. package/dist/core/backend-detect.js.map +1 -1
  86. package/dist/core/claude-cli-runner.d.ts +1 -0
  87. package/dist/core/claude-cli-runner.js +69 -19
  88. package/dist/core/claude-cli-runner.js.map +1 -1
  89. package/dist/core/codex-app-server-runner.d.ts +1 -0
  90. package/dist/core/codex-app-server-runner.js +200 -25
  91. package/dist/core/codex-app-server-runner.js.map +1 -1
  92. package/dist/core/codex-app-server-transport.d.ts +7 -3
  93. package/dist/core/codex-app-server-transport.js +11 -9
  94. package/dist/core/codex-app-server-transport.js.map +1 -1
  95. package/dist/core/cursor-acp-runner.d.ts +1 -0
  96. package/dist/core/cursor-acp-runner.js +24 -9
  97. package/dist/core/cursor-acp-runner.js.map +1 -1
  98. package/dist/core/host-model-catalog.js +2 -0
  99. package/dist/core/host-model-catalog.js.map +1 -1
  100. package/dist/core/managed-session.js +2 -0
  101. package/dist/core/managed-session.js.map +1 -1
  102. package/dist/core/model-identity.js +2 -0
  103. package/dist/core/model-identity.js.map +1 -1
  104. package/dist/core/model-presets.js +3 -0
  105. package/dist/core/model-presets.js.map +1 -1
  106. package/dist/core/omp-model-catalog.d.ts +43 -0
  107. package/dist/core/omp-model-catalog.js +205 -0
  108. package/dist/core/omp-model-catalog.js.map +1 -0
  109. package/dist/core/omp-runner.d.ts +73 -0
  110. package/dist/core/omp-runner.js +990 -0
  111. package/dist/core/omp-runner.js.map +1 -0
  112. package/dist/core/omp-ui-mapper.d.ts +106 -0
  113. package/dist/core/omp-ui-mapper.js +882 -0
  114. package/dist/core/omp-ui-mapper.js.map +1 -0
  115. package/dist/core/opencode-server-runner.d.ts +18 -4
  116. package/dist/core/opencode-server-runner.js +337 -96
  117. package/dist/core/opencode-server-runner.js.map +1 -1
  118. package/dist/core/opencode-session-error.d.ts +11 -0
  119. package/dist/core/opencode-session-error.js +26 -0
  120. package/dist/core/opencode-session-error.js.map +1 -0
  121. package/dist/core/opencode-ui-mapper.js +4 -36
  122. package/dist/core/opencode-ui-mapper.js.map +1 -1
  123. package/dist/core/pi-runner.d.ts +1 -0
  124. package/dist/core/pi-runner.js +75 -12
  125. package/dist/core/pi-runner.js.map +1 -1
  126. package/dist/core/provider-auth.d.ts +9 -2
  127. package/dist/core/provider-auth.js +43 -6
  128. package/dist/core/provider-auth.js.map +1 -1
  129. package/dist/core/runner-factory.js +3 -0
  130. package/dist/core/runner-factory.js.map +1 -1
  131. package/dist/core/runner-model-catalog.js +1 -0
  132. package/dist/core/runner-model-catalog.js.map +1 -1
  133. package/dist/core/runner-stderr.d.ts +2 -0
  134. package/dist/core/runner-stderr.js +20 -0
  135. package/dist/core/runner-stderr.js.map +1 -0
  136. package/dist/core/session-process.d.ts +22 -0
  137. package/dist/core/session-process.js +202 -0
  138. package/dist/core/session-process.js.map +1 -0
  139. package/dist/core/ui-events.d.ts +1 -1
  140. package/dist/core/usage-limit.js +3 -60
  141. package/dist/core/usage-limit.js.map +1 -1
  142. package/dist/data-gitignore.d.ts +6 -0
  143. package/dist/data-gitignore.js +57 -0
  144. package/dist/data-gitignore.js.map +1 -0
  145. package/dist/delegation/capacity.d.ts +19 -0
  146. package/dist/delegation/capacity.js +34 -0
  147. package/dist/delegation/capacity.js.map +1 -0
  148. package/dist/delegation/cli.js +13 -4
  149. package/dist/delegation/cli.js.map +1 -1
  150. package/dist/delegation/conversations.d.ts +4 -2
  151. package/dist/delegation/conversations.js +8 -12
  152. package/dist/delegation/conversations.js.map +1 -1
  153. package/dist/delegation/credentials.d.ts +2 -0
  154. package/dist/delegation/credentials.js +10 -0
  155. package/dist/delegation/credentials.js.map +1 -1
  156. package/dist/delegation/destroy-observation.d.ts +42 -0
  157. package/dist/delegation/destroy-observation.js +115 -0
  158. package/dist/delegation/destroy-observation.js.map +1 -0
  159. package/dist/delegation/execution-identity.d.ts +6 -0
  160. package/dist/delegation/execution-identity.js +1 -1
  161. package/dist/delegation/execution-identity.js.map +1 -1
  162. package/dist/delegation/policy.d.ts +3 -2
  163. package/dist/delegation/policy.js +7 -5
  164. package/dist/delegation/policy.js.map +1 -1
  165. package/dist/delegation/process-liveness.d.ts +57 -20
  166. package/dist/delegation/process-liveness.js +114 -92
  167. package/dist/delegation/process-liveness.js.map +1 -1
  168. package/dist/delegation/provision.js +6 -6
  169. package/dist/delegation/provision.js.map +1 -1
  170. package/dist/delegation/results.js +4 -2
  171. package/dist/delegation/results.js.map +1 -1
  172. package/dist/delegation/retry-backoff.d.ts +23 -0
  173. package/dist/delegation/retry-backoff.js +18 -0
  174. package/dist/delegation/retry-backoff.js.map +1 -0
  175. package/dist/delegation/routes.d.ts +64 -58
  176. package/dist/delegation/scratch-cleanup.d.ts +29 -0
  177. package/dist/delegation/scratch-cleanup.js +143 -0
  178. package/dist/delegation/scratch-cleanup.js.map +1 -0
  179. package/dist/delegation/service.d.ts +20 -1
  180. package/dist/delegation/service.js +233 -70
  181. package/dist/delegation/service.js.map +1 -1
  182. package/dist/delegation/transport.d.ts +12 -6
  183. package/dist/delegation/workspace.d.ts +23 -4
  184. package/dist/delegation/workspace.js +147 -31
  185. package/dist/delegation/workspace.js.map +1 -1
  186. package/dist/git-worktree-lock-helper.js +8 -3
  187. package/dist/git-worktree-lock-helper.js.map +1 -1
  188. package/dist/git-worktree-lock.d.ts +6 -1
  189. package/dist/git-worktree-lock.js +10 -4
  190. package/dist/git-worktree-lock.js.map +1 -1
  191. package/dist/git-worktree-release.d.ts +20 -0
  192. package/dist/git-worktree-release.js +27 -0
  193. package/dist/git-worktree-release.js.map +1 -0
  194. package/dist/git-worktree.d.ts +8 -2
  195. package/dist/git-worktree.js +19 -2
  196. package/dist/git-worktree.js.map +1 -1
  197. package/dist/handoff.d.ts +2 -2
  198. package/dist/handoff.js +1 -1
  199. package/dist/index.js +109 -76
  200. package/dist/index.js.map +1 -1
  201. package/dist/paths.d.ts +1 -1
  202. package/dist/paths.js +5 -1
  203. package/dist/paths.js.map +1 -1
  204. package/dist/preview/cdp.d.ts +13 -0
  205. package/dist/preview/cdp.js +69 -0
  206. package/dist/preview/cdp.js.map +1 -0
  207. package/dist/preview/chromium.d.ts +75 -0
  208. package/dist/preview/chromium.js +394 -0
  209. package/dist/preview/chromium.js.map +1 -0
  210. package/dist/preview/dev-server.d.ts +68 -0
  211. package/dist/preview/dev-server.js +341 -0
  212. package/dist/preview/dev-server.js.map +1 -0
  213. package/dist/preview/host.d.ts +154 -0
  214. package/dist/preview/host.js +681 -0
  215. package/dist/preview/host.js.map +1 -0
  216. package/dist/preview/registration.d.ts +63 -0
  217. package/dist/preview/registration.js +109 -0
  218. package/dist/preview/registration.js.map +1 -0
  219. package/dist/preview/session.d.ts +73 -0
  220. package/dist/preview/session.js +347 -0
  221. package/dist/preview/session.js.map +1 -0
  222. package/dist/preview/url.d.ts +6 -0
  223. package/dist/preview/url.js +17 -0
  224. package/dist/preview/url.js.map +1 -0
  225. package/dist/runs/agent-tmpdir-removal.d.ts +15 -0
  226. package/dist/runs/agent-tmpdir-removal.js +60 -0
  227. package/dist/runs/agent-tmpdir-removal.js.map +1 -0
  228. package/dist/runs/agent-tmpdir.d.ts +12 -1
  229. package/dist/runs/agent-tmpdir.js +123 -34
  230. package/dist/runs/agent-tmpdir.js.map +1 -1
  231. package/dist/runs/delegation-state.d.ts +5 -1
  232. package/dist/runs/delegation-state.js +9 -0
  233. package/dist/runs/delegation-state.js.map +1 -1
  234. package/dist/runs/event-history.d.ts +4 -4
  235. package/dist/runs/event-history.js +97 -117
  236. package/dist/runs/event-history.js.map +1 -1
  237. package/dist/runs/history-compressor.d.ts +29 -0
  238. package/dist/runs/history-compressor.js +116 -0
  239. package/dist/runs/history-compressor.js.map +1 -0
  240. package/dist/runs/history-file.d.ts +36 -0
  241. package/dist/runs/history-file.js +463 -0
  242. package/dist/runs/history-file.js.map +1 -0
  243. package/dist/runs/human-ask-summary.js +18 -13
  244. package/dist/runs/human-ask-summary.js.map +1 -1
  245. package/dist/runs/legacy-index.d.ts +62 -0
  246. package/dist/runs/legacy-index.js +168 -0
  247. package/dist/runs/legacy-index.js.map +1 -0
  248. package/dist/runs/raw-record.d.ts +52 -0
  249. package/dist/runs/raw-record.js +145 -0
  250. package/dist/runs/raw-record.js.map +1 -0
  251. package/dist/runs/retention.d.ts +13 -6
  252. package/dist/runs/retention.js +42 -13
  253. package/dist/runs/retention.js.map +1 -1
  254. package/dist/runs/run-claims.d.ts +23 -0
  255. package/dist/runs/run-claims.js +24 -0
  256. package/dist/runs/run-claims.js.map +1 -0
  257. package/dist/runs/run-database.d.ts +385 -0
  258. package/dist/runs/run-database.js +930 -0
  259. package/dist/runs/run-database.js.map +1 -0
  260. package/dist/runs/run-index.d.ts +35 -9
  261. package/dist/runs/run-index.js +200 -25
  262. package/dist/runs/run-index.js.map +1 -1
  263. package/dist/runs/run-row.d.ts +44 -0
  264. package/dist/runs/run-row.js +84 -0
  265. package/dist/runs/run-row.js.map +1 -0
  266. package/dist/runs/run-search.d.ts +29 -0
  267. package/dist/runs/run-search.js +63 -0
  268. package/dist/runs/run-search.js.map +1 -0
  269. package/dist/runs/store-open-error.d.ts +24 -0
  270. package/dist/runs/store-open-error.js +61 -0
  271. package/dist/runs/store-open-error.js.map +1 -0
  272. package/dist/runs/store.d.ts +667 -32
  273. package/dist/runs/store.js +2153 -324
  274. package/dist/runs/store.js.map +1 -1
  275. package/dist/runs/transcript-facts.d.ts +77 -0
  276. package/dist/runs/transcript-facts.js +302 -0
  277. package/dist/runs/transcript-facts.js.map +1 -0
  278. package/dist/runs/webhook.js +5 -4
  279. package/dist/runs/webhook.js.map +1 -1
  280. package/dist/runs/worker-execution.d.ts +22 -0
  281. package/dist/runs/worker-execution.js +30 -0
  282. package/dist/runs/worker-execution.js.map +1 -0
  283. package/dist/server/capabilities.d.ts +1 -0
  284. package/dist/server/capabilities.js +2 -0
  285. package/dist/server/capabilities.js.map +1 -1
  286. package/dist/server/cockpit-ownership.d.ts +8 -0
  287. package/dist/server/cockpit-ownership.js +19 -0
  288. package/dist/server/cockpit-ownership.js.map +1 -1
  289. package/dist/server/forge/github.d.ts +1 -4
  290. package/dist/server/forge/github.js +47 -23
  291. package/dist/server/forge/github.js.map +1 -1
  292. package/dist/server/git.js +1 -1
  293. package/dist/server/git.js.map +1 -1
  294. package/dist/server/open-in-app.js +1 -0
  295. package/dist/server/open-in-app.js.map +1 -1
  296. package/dist/server/preview-socket.d.ts +36 -0
  297. package/dist/server/preview-socket.js +126 -0
  298. package/dist/server/preview-socket.js.map +1 -0
  299. package/dist/server/project-context.d.ts +31 -4
  300. package/dist/server/project-context.js +73 -9
  301. package/dist/server/project-context.js.map +1 -1
  302. package/dist/server/provider-action-gate.js +1 -0
  303. package/dist/server/provider-action-gate.js.map +1 -1
  304. package/dist/server/repo-branches.d.ts +4 -3
  305. package/dist/server/repo-branches.js +5 -4
  306. package/dist/server/repo-branches.js.map +1 -1
  307. package/dist/server/server.d.ts +4500 -2727
  308. package/dist/server/server.js +651 -202
  309. package/dist/server/server.js.map +1 -1
  310. package/dist/server/upgrade-router.d.ts +23 -0
  311. package/dist/server/upgrade-router.js +53 -0
  312. package/dist/server/upgrade-router.js.map +1 -0
  313. package/dist/server/validators.d.ts +6 -1
  314. package/dist/server/validators.js +4 -4
  315. package/dist/server/validators.js.map +1 -1
  316. package/dist/server/ws.d.ts +16 -16
  317. package/dist/server/ws.js +56 -54
  318. package/dist/server/ws.js.map +1 -1
  319. package/dist/skills-remote.d.ts +14 -0
  320. package/dist/skills-remote.js +91 -15
  321. package/dist/skills-remote.js.map +1 -1
  322. package/dist/task-cli/cli.js +40 -16
  323. package/dist/task-cli/cli.js.map +1 -1
  324. package/dist/task-cli/discovery.d.ts +4 -3
  325. package/dist/task-cli/discovery.js +4 -3
  326. package/dist/task-cli/discovery.js.map +1 -1
  327. package/dist/task-cli/http.d.ts +2 -0
  328. package/dist/task-cli/http.js +10 -4
  329. package/dist/task-cli/http.js.map +1 -1
  330. package/dist/task-cli/projections.d.ts +5 -2
  331. package/dist/task-cli/projections.js +13 -0
  332. package/dist/task-cli/projections.js.map +1 -1
  333. package/dist/task-cli/watch.d.ts +15 -2
  334. package/dist/task-cli/watch.js +31 -9
  335. package/dist/task-cli/watch.js.map +1 -1
  336. package/dist/ui-state.d.ts +4 -3
  337. package/dist/ui-state.js +8 -3
  338. package/dist/ui-state.js.map +1 -1
  339. package/dist/workflows/grouped-command.d.ts +31 -0
  340. package/dist/workflows/grouped-command.js +143 -0
  341. package/dist/workflows/grouped-command.js.map +1 -0
  342. package/dist/workflows/run.d.ts +223 -42
  343. package/dist/workflows/run.js +1316 -398
  344. package/dist/workflows/run.js.map +1 -1
  345. package/dist/workflows/types.d.ts +12 -0
  346. package/dist/workflows/types.js +20 -0
  347. package/dist/workflows/types.js.map +1 -1
  348. package/dist/workspace/agent-accounts.d.ts +5 -1
  349. package/dist/workspace/agent-accounts.js +1 -0
  350. package/dist/workspace/agent-accounts.js.map +1 -1
  351. package/dist/workspace/agent-profiles.js +1 -1
  352. package/dist/workspace/agent-profiles.js.map +1 -1
  353. package/dist/workspace/config.d.ts +3 -1
  354. package/dist/workspace/config.js +1 -0
  355. package/dist/workspace/config.js.map +1 -1
  356. package/dist/worktree-setup.d.ts +85 -0
  357. package/dist/worktree-setup.js +164 -0
  358. package/dist/worktree-setup.js.map +1 -0
  359. package/package.json +7 -5
  360. package/scripts/benchmark-run-store-fixture.ts +428 -0
  361. package/scripts/benchmark-run-store.ts +743 -0
  362. package/scripts/benchmark-stuck-destroys.ts +143 -0
  363. package/scripts/mock-autonomous.mjs +11 -0
  364. package/scripts/mock-ci-tool.mjs +20 -5
  365. package/scripts/mock-claude.mjs +110 -1
  366. package/scripts/mock-codex-app-server.mjs +117 -2
  367. package/scripts/mock-codex-coalesce-steer.mjs +59 -0
  368. package/scripts/mock-codex-reverse-steer.mjs +130 -0
  369. package/scripts/mock-cursor-acp.mjs +75 -1
  370. package/scripts/mock-omp-rpc.mjs +605 -0
  371. package/scripts/mock-opencode-serve.mjs +312 -22
  372. package/scripts/mock-pi-rpc.mjs +109 -3
  373. package/scripts/mock-runner-crash.mjs +42 -0
  374. package/scripts/mock-silent-tail.mjs +78 -0
  375. package/scripts/mock-turn-messages.mjs +5 -0
  376. package/scripts/omp-ci-wait.mjs +29 -0
  377. package/scripts/omp-restrict-delegation.yml +4 -0
  378. package/scripts/pi-ci-wait.mjs +12 -9
  379. package/scripts/seed-archive-profile.ts +3 -3
  380. package/web/dist/assets/agent-accounts-CFOBMVHc.js +1 -0
  381. package/web/dist/assets/alert-dialog-BrpNUwMt.js +1 -0
  382. package/web/dist/assets/app-window-CSHwDX9N.js +1 -0
  383. package/web/dist/assets/button-C_rd7CPp.js +43 -0
  384. package/web/dist/assets/compare-variants-D6WXhvXA.js +1 -0
  385. package/web/dist/assets/diff-DrEARXTV.js +3 -0
  386. package/web/dist/assets/diff-controls-B1-j7nFg.js +1 -0
  387. package/web/dist/assets/{diff-view-DPn6bQ5W.js → diff-view-DvGLjdTk.js} +3 -3
  388. package/web/dist/assets/dropdown-menu-BegOZFrj.js +1 -0
  389. package/web/dist/assets/format-DpNe3S0A.js +1 -0
  390. package/web/dist/assets/git-toolbar-Cw4ljmhB.js +1 -0
  391. package/web/dist/assets/github-CfV5sqL0.js +1 -0
  392. package/web/dist/assets/{image-preview-DJ8cgb1Z.js → image-preview-FOEfqpZP.js} +1 -1
  393. package/web/dist/assets/index-DgWWlOLy.js +13 -0
  394. package/web/dist/assets/index-fBFnuvxu.css +1 -0
  395. package/web/dist/assets/{nav-row-styles-3j64kH5s.js → nav-row-styles-vp2JbhuJ.js} +1 -1
  396. package/web/dist/assets/play-CmVAj_fu.js +1 -0
  397. package/web/dist/assets/popover-BohP6WTx.js +1 -0
  398. package/web/dist/assets/preview-pane-connected-qTA5KNoJ.js +3 -0
  399. package/web/dist/assets/preview-state-vuMviq6q.js +1 -0
  400. package/web/dist/assets/prompt-templates-Cubdut_C.js +15 -0
  401. package/web/dist/assets/repo-git-Cf4t5PD0.js +1 -0
  402. package/web/dist/assets/{run-diff-5-RRdcVz.js → run-diff-BpkUW37Z.js} +3 -3
  403. package/web/dist/assets/run-header-68WHf9wV.js +1 -0
  404. package/web/dist/assets/skills-DiVIgKeU.js +1 -0
  405. package/web/dist/assets/square-terminal-B6RYYieR.js +1 -0
  406. package/web/dist/assets/status-dot-CqTJHSP9.js +1 -0
  407. package/web/dist/assets/{tab-link-K0hjzEXR.js → tab-link-B07Hu4QX.js} +1 -1
  408. package/web/dist/assets/task-changes--lQF6EYf.js +1 -0
  409. package/web/dist/assets/task-commits-Cx0qy4DN.js +1 -0
  410. package/web/dist/assets/task-files-D50s-gKW.js +2 -0
  411. package/web/dist/assets/task-github-item-DaqmSwYT.js +1 -0
  412. package/web/dist/assets/task-thread-BPQ64lui.js +7 -0
  413. package/web/dist/assets/{task-thread-Bw_nQuTx.css → task-thread-zNE2Q9E-.css} +1 -1
  414. package/web/dist/assets/textarea-D20O4Pw0.js +1 -0
  415. package/web/dist/assets/thread-state-DWcFMrvb.js +4 -0
  416. package/web/dist/assets/use-hand-to-agent-state-BLIGmwbd.css +1 -0
  417. package/web/dist/assets/use-hand-to-agent-state-Cy9eyyfL.js +1 -0
  418. package/web/dist/assets/{utils-fzNfbPzA.js → utils-JsL3ZsJf.js} +1 -1
  419. package/web/dist/assets/workflows-jwG7uxqi.js +11 -0
  420. package/web/dist/assets/{zoomable-image-CDmZ_Rtl.js → zoomable-image-BJJ64KGc.js} +1 -1
  421. package/web/dist/cezarion-lockup-dark.svg +1 -13
  422. package/web/dist/cezarion-lockup-light.svg +1 -13
  423. package/web/dist/cezarion-mark-dark.svg +1 -12
  424. package/web/dist/cezarion-mark-light.svg +1 -12
  425. package/web/dist/index.html +16 -21
  426. package/web/dist/assets/agent-accounts-CU2ZDv24.js +0 -1
  427. package/web/dist/assets/alert-dialog-C-xYE_dg.js +0 -43
  428. package/web/dist/assets/chevron-right-BENZ-1ZN.js +0 -1
  429. package/web/dist/assets/collapsible-DD9J91Iv.js +0 -1
  430. package/web/dist/assets/compare-variants-5FmqpFSN.js +0 -1
  431. package/web/dist/assets/diff-controls-Bj0YrioE.js +0 -1
  432. package/web/dist/assets/diff-rE8rm75J2.js +0 -3
  433. package/web/dist/assets/diff-stat-BKDCSVfJ.js +0 -1
  434. package/web/dist/assets/dropdown-menu-Ch5BYQh_.js +0 -1
  435. package/web/dist/assets/format-dBiNDNVe.js +0 -1
  436. package/web/dist/assets/git-toolbar-ClVvf5iF.js +0 -1
  437. package/web/dist/assets/github-BkNOyour.css +0 -1
  438. package/web/dist/assets/github-DeZMeMwJ.js +0 -1
  439. package/web/dist/assets/github-item-detail-W5F6GVjF.js +0 -1
  440. package/web/dist/assets/github-sidebar-model-DcMu-IK2.js +0 -1
  441. package/web/dist/assets/highlighter-CdHqIwFr.js +0 -3
  442. package/web/dist/assets/index-CatemBEL.js +0 -8
  443. package/web/dist/assets/index-MvfD8bZJ.css +0 -1
  444. package/web/dist/assets/markdown-zdKeLIYA.js +0 -2
  445. package/web/dist/assets/mobile-run-bar-BCfmFi8V.js +0 -1
  446. package/web/dist/assets/prompt-templates-CBPp39IU.js +0 -15
  447. package/web/dist/assets/reference-chip-D6owncJT.js +0 -1
  448. package/web/dist/assets/repo-git-CtdkZFey.js +0 -1
  449. package/web/dist/assets/run-header-BcjoDi0W.js +0 -1
  450. package/web/dist/assets/sheet-ChnYVrp1.js +0 -1
  451. package/web/dist/assets/skills-C8YKp2vr.js +0 -1
  452. package/web/dist/assets/task-changes-BW8YdWG8.js +0 -1
  453. package/web/dist/assets/task-commits-q2kB2y6L.js +0 -1
  454. package/web/dist/assets/task-files-pPL6_3Cr.js +0 -2
  455. package/web/dist/assets/task-github-item-BXF5R52U.js +0 -1
  456. package/web/dist/assets/task-thread-DtkFL3EC.js +0 -10
  457. package/web/dist/assets/textarea-DxlkE-Yd.js +0 -1
  458. package/web/dist/assets/token-metrics-DIAjrPlM.js +0 -1
  459. package/web/dist/assets/use-desktop-CrDny2uG.js +0 -1
  460. package/web/dist/assets/use-submit-shortcut-KWJsC906.js +0 -1
  461. package/web/dist/assets/workflows-TmcPEj2K.js +0 -11
  462. /package/web/dist/assets/{lib-BQXq3kEf2.js → lib-BQXq3kEf.js} +0 -0
@@ -1,16 +1,21 @@
1
+ import { workerEvidenceRunIds } from '../runs/worker-execution.js';
1
2
  import { ciErrorMessage } from '../ci-wait/errors.js';
2
- import { ciWaitRequestSchema, ciWaitResultSchema } from '../contract/index.js';
3
+ import { ciWaitRequestSchema, ciWaitResultSchema, previewStopRequestSchema, previewServeRequestSchema } from '../contract/index.js';
4
+ import { previewToolEnabled } from '../ci-wait/tools.js';
5
+ import { previewStopResult, previewResult, validateRegistration } from '../preview/registration.js';
3
6
  import { acquireCiResources } from '../ci-wait/resources.js';
4
7
  import { artifactInstructions, provisionArtifactDirectory } from '../artifacts/lifecycle.js';
5
8
  import { workerContextHash, workerWorkflowHash, workerStepIdentity, captureWorkerAccount, boundWorkerAccountEnv, WorkerIdentityError } from '../delegation/execution-identity.js';
6
9
  import { buildChildEnv } from '../core/agent-env.js';
7
10
  import { randomUUID } from 'node:crypto';
8
- import { spawn } from 'node:child_process';
9
11
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
10
12
  import { dirname, join } from 'node:path';
11
13
  import { parseAskMarkerResult, stripAskMarker, } from '../core/ask.js';
12
14
  import { hasRegisteredRunProcess, onUsage, registerRunProcess, unregisterRunProcess } from '../core/process-usage.js';
13
- import { inspectGeneration, isCurrentProcess, processStartToken, recordedProcessLive } from '../delegation/process-liveness.js';
15
+ import { processGroupAlive, processGroupOf, sessionGroupOf, signalProcessGroup } from '../core/session-process.js';
16
+ import { WorkerScratchCleanup } from '../delegation/scratch-cleanup.js';
17
+ import { noJitter, ORPHAN_BACKOFF, retryDelayMs } from '../delegation/retry-backoff.js';
18
+ import { inspectExecutionGeneration, isCurrentProcess, processesWithCwdUnder, processStartToken, recordedGroupSignalable, recordedProcessLive, sharedCwdScan } from '../delegation/process-liveness.js';
14
19
  import { parseUsageLimit } from '../core/usage-limit.js';
15
20
  import { startManagedSession } from '../core/managed-session.js';
16
21
  import { createRunner } from '../core/runner-factory.js';
@@ -23,14 +28,16 @@ import { todosPath } from '../todos.js';
23
28
  // Contract VALUES, like `workspaceUiStateSchema` in workspace/migrations.ts: the attachment
24
29
  // vocabulary the routes validate with is the same one the engine stores and re-reads by, so the
25
30
  // wire and the disk can never disagree about what counts as an image (#950).
26
- import { requestWaitRequestSchema, workerWaitRequestSchema, attachmentExtension, delegationStateSchema, pendingHumanAsk, askRequestSchema, isImageAttachmentName, isImageMediaType, } from '../contract/index.js';
31
+ import { requestWaitRequestSchema, workerWaitRequestSchema, attachmentExtension, delegationStateSchema, askRequestSchema, isImageAttachmentName, isImageMediaType, } from '../contract/index.js';
27
32
  import { inputDeliveryOf } from '../core/agent-runner.js';
28
33
  import { discoverSkills } from '../skills.js';
29
- import { materializeSkillDir } from '../skills-remote.js';
34
+ import { materializeSkillDir, waitForCachedTeamSkills, waitForTeamSkills } from '../skills-remote.js';
30
35
  import { seedAgentConfigLocalLayer } from '../agent-config/seed.js';
31
36
  import { readAgentModelSettings, readAgentModelProvider } from '../agent-config/models.js';
32
37
  import { loadConfig, resolveWorktreeRetention } from '../config.js';
33
38
  import { autosaveCommit, createWorktree, resolveBaseRef, worktreeDiff, worktreeShortstat } from '../git-worktree.js';
39
+ import { runGroupedCommand } from './grouped-command.js';
40
+ import { resolveWorktreeSetup, runWorktreeSetup, worktreeSetupAgentNote, worktreeSetupEndNote, worktreeSetupEnv, worktreeSetupRecordError, worktreeSetupStartNote, } from '../worktree-setup.js';
34
41
  import { getHeadCommit, getRepoInfo } from '../server/git.js';
35
42
  import { ensureOwnedWorkspace, verifyOwnedWorkspace } from '../delegation/workspace.js';
36
43
  import { verifyWorkerContext } from '../delegation/context.js';
@@ -43,8 +50,9 @@ import { parentReadiness } from '../delegation/readiness.js';
43
50
  import { answersQuestion, openQuestions, questionMessage } from '../delegation/questions.js';
44
51
  import { workerOutcome } from '../runs/delegation-state.js';
45
52
  import { loadWorkflows } from './load.js';
53
+ import { RUN_IN_USE_ELSEWHERE } from '../runs/store.js';
46
54
  import { isReclaimable, reclaimWorktrees, rematerializeReclaimedWorktree } from '../runs/retention.js';
47
- import { AgentTempDirError, agentTmpEnv, agentTmpDirLocations, removeAgentTmpDir, sweepAgentTmpDirs, } from '../runs/agent-tmpdir.js';
55
+ import { AgentTempDirError, agentTmpEnv, agentTmpDirLocationEvidence, removeAgentTmpDir, } from '../runs/agent-tmpdir.js';
48
56
  import { extractTaskRefs, refineTaskRefs, titleRefNumber } from '../runs/task-refs.js';
49
57
  import { parseTaskMarkers, stripTaskMarkers } from '../runs/task-markers.js';
50
58
  import { autoNamingActive, generateRunName, liveTitleUpdatesEnabled, postValidateTitle } from '../runs/auto-name.js';
@@ -61,24 +69,81 @@ async function configuredModelProvider(backend, repoRoot) {
61
69
  /** An interactive session that hears nothing from the user closes itself. */
62
70
  export const IDLE_TIMEOUT_MS = 15 * 60_000;
63
71
  /**
64
- * Task-completion marker from the agent contract (HANDOFF_INSTRUCTIONS): a
65
- * turn whose text ends with `CEZ:DONE` means "goal achieved, nothing to ask" —
72
+ * Task-completion marker from the agent contract (HANDOFF_INSTRUCTIONS):
73
+ * an active `CEZ:DONE` declaration means "goal achieved, nothing to ask" —
66
74
  * the session is closed right away instead of parking at `waiting` (#347).
67
75
  * Detection runs on the accumulated turn text so delta-streaming backends
68
76
  * (codex, opencode) can't split the marker across text events.
69
77
  */
70
- const DONE_MARKER_RE = /CEZ:DONE\s*$/;
71
- /**
72
- * Still-working marker from the agent contract (spec
73
- * 2026-07-18-subagent-monitoring-status, #490): a turn whose text ends with
74
- * `CEZ:MONITORING` means "I ended this turn but I'm still working on my own
75
- * downstream work (a sub-agent / a command I'm monitoring), not waiting on the
76
- * user" — cezar parks it as `running`/`activity:'monitoring'` instead of
77
- * `waiting`, so the cockpit shows a non-attention state. `CEZ:DONE` wins if both
78
- * appear. Detected on accumulated turn text (like `CEZ:DONE`) so delta-streaming
79
- * backends can't split the marker across text events.
80
- */
81
- const MONITORING_MARKER_RE = /CEZ:MONITORING\s*$/;
78
+ import { PROSE_HUMAN_GATE, workerOutcomeKey } from '../runs/transcript-facts.js';
79
+ /** Classify only active prose: quoted/indented examples and fenced code cannot
80
+ * declare a park or request a human. A standalone monitoring line belongs to
81
+ * this turn even when a later assistant block acknowledges a new instruction. */
82
+ function turnParkSignals(text) {
83
+ let fence;
84
+ let monitoring = false;
85
+ let humanGate = false;
86
+ let done = false;
87
+ let askResult = { kind: 'none' };
88
+ let previous = '';
89
+ for (const raw of text.split('\n')) {
90
+ const delimiter = raw.match(/^ {0,3}(`{3,}|~{3,})/);
91
+ if (delimiter) {
92
+ previous = ''; // a fenced example consumes its heading, not the next declaration
93
+ const token = delimiter[1];
94
+ if (!fence)
95
+ fence = { char: token[0], length: token.length };
96
+ else if (token[0] === fence.char && token.length >= fence.length)
97
+ fence = undefined;
98
+ continue;
99
+ }
100
+ if (fence)
101
+ continue;
102
+ if (/^(?: {4}|\t|\s*>)/.test(raw)) {
103
+ if (raw.trim())
104
+ previous = '';
105
+ continue;
106
+ }
107
+ // Lists and emphasis format active prose; they do not quote it. Exclude
108
+ // fenced/indented/blockquote examples before removing their decoration.
109
+ const line = raw.trim().replace(/^(?:[-+*]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/, '')
110
+ .replace(/^#{1,6}\s+/, '').replace(/\*{1,2}|_{1,2}/g, '');
111
+ if (!line)
112
+ continue;
113
+ const example = /\b(?:example|sample|literal|quoted)(?: marker)?\s*:\s*$/i.test(previous);
114
+ if (!example && raw.trim() === 'CEZ:MONITORING') {
115
+ monitoring = true;
116
+ humanGate = false; // a later declaration supersedes earlier, resolved prose
117
+ done = false;
118
+ }
119
+ if (!example && raw.trim() === 'CEZ:DONE') {
120
+ done = true;
121
+ humanGate = false;
122
+ askResult = { kind: 'none' }; // retain the existing later-DONE precedence
123
+ }
124
+ // Preserve the existing payload validation/repair and latest malformed
125
+ // marker diagnostics. Only active declarations participate: an ACK after
126
+ // a real ASK must not erase its card, nor turn examples into questions.
127
+ const candidate = example ? { kind: 'none' } : parseAskMarkerResult(raw);
128
+ if (candidate.kind !== 'none') {
129
+ askResult = candidate;
130
+ done = false;
131
+ }
132
+ // Deliberately require a direct question/request, not words like "review",
133
+ // "checks" or "approval" in progress reports (hearsay-tools/cezarion#772 and hearsay-tools/cezarion#609).
134
+ const prose = line.replace(/`[^`]*`|"[^"]*"/g, '');
135
+ if (!example && prose.split(/(?<=[.!?])\s+/).some(sentence => /^(?:(?:should|shall|can|may) I\b|(?:do|would|could|can|will|are) you\b).*\?\s*$/i.test(sentence) ||
136
+ /^(?:which|what|how|where|when|who)\b.*\b(?:you|(?:should|shall|can|may) (?:I|we))\b.*\?\s*$/i.test(sentence) ||
137
+ /^(?:which|what) (?:module|option|approach|strategy|framework|library)\?\s*$/i.test(sentence) ||
138
+ /^(?:is|are) (?:this|these|that|it)\b.*\b(?:ok(?:ay)?|acceptable|approved)\?\s*$/i.test(sentence) ||
139
+ /^(?:please\s+(?:review|approve|confirm|choose|decide|answer|select|provide)\b|(?:I(?:['’]m| am)?\s+)?(?:need|awaiting|waiting for)\s+your\s+(?:review|approval|confirmation|answer|decision|input|permission)\b|(?:the )?PR (?:is )?ready for (?:your )?review\b)/i.test(sentence))) {
140
+ humanGate = true;
141
+ done = false;
142
+ }
143
+ previous = line;
144
+ }
145
+ return { monitoring, humanGate, done, askResult };
146
+ }
82
147
  /** Claude's native scheduler is the backend-level equivalent of the textual
83
148
  * monitoring marker. Keep this recognition here, at the workflow boundary,
84
149
  * so the v1 event protocol stays unchanged and other backends do not acquire
@@ -117,6 +182,17 @@ function appendCompletedAssistantText(current, event) {
117
182
  }
118
183
  return appendTurnText(current, event.item.text);
119
184
  }
185
+ /** Last top-level completed item: a non-empty assistant message is a visible tail; reasoning or a tool is silent even after earlier visible text (#544). An empty turn stays `none` and never nudges. */
186
+ export function trackTurnTail(tail, event) {
187
+ if (event.type !== 'item.completed' || event.item.parentItemId !== undefined)
188
+ return tail;
189
+ const item = event.item;
190
+ if (item.kind === 'message' && item.role === 'assistant' && item.text.trim() !== '')
191
+ return 'visible';
192
+ if (item.kind === 'reasoning' || item.kind === 'tool')
193
+ return 'silent';
194
+ return tail;
195
+ }
120
196
  /** Strip a trailing marker from one text event so transcripts stay free of
121
197
  * protocol noise. Delta backends may split the marker across events — then
122
198
  * it stays visible; detection above is unaffected. */
@@ -166,15 +242,14 @@ function askMarkerRecovery(result) {
166
242
  * they are hand-duplicated, and `AGENTS.md` warns that a lifecycle change
167
243
  * applied to only one of them ships half a fix — the notes and their tones are
168
244
  * exactly that kind of change. `enabled` is the caller's own precondition (the
169
- * session is open, the turn is not a `CEZ:DONE`, and for an agent step, the run
170
- * is interactive); when false there is no marker to look for. */
171
- function resolveAskTurn(turnText, completedAssistantText, enabled) {
245
+ * session is open and the turn is not a terminating `CEZ:DONE`); when false
246
+ * there is no marker to look for. Intermediate steps can ask too (#427). */
247
+ function resolveAskTurn(v1Result, v2Result, enabled) {
172
248
  if (!enabled)
173
249
  return { ask: null, notes: [] };
174
- const v1Result = parseAskMarkerResult(turnText);
175
- // v1 is authoritative whenever it carries an ASK marker. Claude can omit
176
- // the trailing marker from v1 while v2 retains the complete message.
177
- const result = v1Result.kind === 'none' ? parseAskMarkerResult(completedAssistantText) : v1Result;
250
+ // For unreconciled channels, v1 remains authoritative when it carries an
251
+ // ASK; a marker absent from v1 can still come from the complete v2 message.
252
+ const result = v1Result.kind === 'none' ? v2Result : v1Result;
178
253
  const notes = [];
179
254
  const rejection = askMarkerRejection(result);
180
255
  if (rejection)
@@ -184,11 +259,57 @@ function resolveAskTurn(turnText, completedAssistantText, enabled) {
184
259
  notes.push({ message: recovery, tone: 'danger' });
185
260
  return { ask: result.kind === 'valid' ? result.request : null, notes };
186
261
  }
187
- /** A trailing ASK marker — valid card or rejected payload — means the user
262
+ /** A partial channel cannot keep an earlier gate alive when the other carries
263
+ * the same transcript plus later declarations. Compare raw lines in order,
264
+ * preserving fences/examples and ACKs between declarations. Neither channel
265
+ * always wins: v1 reconstructs omitted snapshots, v2 can retain refreshed ones.
266
+ * Incomparable transcripts keep the existing conservative channel policy. */
267
+ function orderedTurnText(v1, v2) {
268
+ if (!v1)
269
+ return v2;
270
+ if (!v2 || v1 === v2)
271
+ return v1;
272
+ const contains = (full, partial) => {
273
+ const lines = partial.split('\n');
274
+ let next = 0;
275
+ for (const line of full.split('\n')) {
276
+ if (line === lines[next])
277
+ next++;
278
+ }
279
+ return next === lines.length;
280
+ };
281
+ if (contains(v2, v1))
282
+ return v2;
283
+ if (contains(v1, v2))
284
+ return v1;
285
+ return undefined;
286
+ }
287
+ /** One turn-end decision for fresh and Continue, also retained until an input
288
+ * ACK settles. DONE and human/completion gates take precedence over a quiet
289
+ * declaration or live workers; a false pending-ASK flag alone proves nothing. */
290
+ function classifyTurnEnd(turnText, completedAssistantText, options) {
291
+ const complete = orderedTurnText(turnText, completedAssistantText);
292
+ const v1 = turnParkSignals(complete ?? turnText);
293
+ const v2 = complete === undefined ? turnParkSignals(completedAssistantText) : v1;
294
+ const done = options.interactive && options.sessionOpen && (v1.done || v2.done) &&
295
+ v1.askResult.kind === 'none' && v2.askResult.kind === 'none' && !v1.humanGate && !v2.humanGate;
296
+ const askTurn = resolveAskTurn(v1.askResult, v2.askResult, options.sessionOpen && !done);
297
+ // Ordinary markerless autonomous turns keep their existing nudge policy.
298
+ // Prose and rejected-ASK fallbacks override a quiet park this turn would
299
+ // otherwise enter. Recovery notes beside a valid card are not rejections.
300
+ const quietPark = v1.monitoring || v2.monitoring || options.scheduledWake || options.liveWorkers || options.dependencyWait;
301
+ const rejectedAsk = !askTurn.ask && askTurn.notes.length > 0;
302
+ const humanGate = !done && quietPark && (v1.humanGate || v2.humanGate || rejectedAsk);
303
+ const monitoring = options.interactive && options.sessionOpen && !done && !askTurn.ask &&
304
+ !options.pendingHumanAsk && !options.completionAttention && !humanGate &&
305
+ (v1.monitoring || v2.monitoring || options.scheduledWake || options.liveWorkers);
306
+ return { ...askTurn, done, monitoring, humanGate };
307
+ }
308
+ /** An active ASK marker — valid card or rejected payload — means the user
188
309
  * must answer. Drop follow-ups queued mid-turn so they cannot start a new
189
310
  * turn the moment idle fires (OpenCode serializes `sendMessage` that way). */
190
311
  function discardQueuedMessagesOnAsk(session, outcome) {
191
- if (outcome.ask || outcome.notes.length)
312
+ if (outcome.ask || outcome.notes.length || outcome.humanGate)
192
313
  session?.discardQueuedMessages();
193
314
  }
194
315
  function isSyntheticContinuation(run, step) {
@@ -202,10 +323,7 @@ function isSyntheticContinuation(run, step) {
202
323
  }
203
324
  /** Periodic "cezar autosave" commit in the task worktree (spec 006). */
204
325
  export const AUTOSAVE_INTERVAL_MS = 90_000;
205
- /** Stop on a workflow check: SIGTERM first, SIGKILL to its process group after this grace. */
206
- export const CHECK_KILL_GRACE_MS = 3_000;
207
- /** After SIGKILL, how long to wait for the group to disappear before resolving unconfirmed. */
208
- export const CHECK_TERMINATION_CONFIRM_MS = 5_000;
326
+ export { CHECK_KILL_GRACE_MS, CHECK_TERMINATION_CONFIRM_MS } from './grouped-command.js';
209
327
  /** The periodic autosave timer is opt-in (#471): off, a task branch carries only the
210
328
  * agent's own commits plus the turn-end/pre-PR flushes — no mid-run "cezar autosave"
211
329
  * noise interleaving PR history. The flushes (`autosaveCommit` at turn end and before
@@ -224,13 +342,23 @@ export function repositoryRootLockDisabled(env = process.env) {
224
342
  return env.CEZ_DISABLE_REPO_LOCK === '1';
225
343
  }
226
344
  const REPOSITORY_ROOT_LOCK_DISABLED_NOTE = 'repository-root lock disabled by CEZ_DISABLE_REPO_LOCK=1 (shared checkout is unsafe)';
227
- /** Safety cap on autonomous auto-continues per run — stops a stuck agent from nudging forever. */
228
345
  /** #505: a real steer is read at the harness's next model step; a quiet boundary this long
229
346
  * with input still unread means the harness will not read it without help. */
230
347
  export const UNREAD_INPUT_GRACE_MS = 30_000;
231
- const MAX_AUTO_CONTINUES = 40;
232
- const AUTONOMOUS_NUDGE = 'Continue working autonomously until the task is fully complete. Do not ask me for confirmation or clarification — make reasonable assumptions and proceed. When everything is done, end the session with your done signal.';
233
- const MONITORING_WAKE_NUDGE = 'Re-check the downstream work you were monitoring. Continue toward the task goal; emit CEZ:MONITORING again only if it is still pending.';
348
+ /** #486: recheck a turn-end that deferred close/park only because the harness still held a human follow-up. */
349
+ export const HELD_HUMAN_INPUT_RECHECK_MS = 250;
350
+ function unreadInputUnconfirmedNote(count) {
351
+ return `delivered ${count} message${count === 1 ? '' : 's'}; the agent did not confirm reading ${count === 1 ? 'it' : 'them'}`;
352
+ }
353
+ /** Bound a final-message nudge that is ACKed but never produces a turn (#544).
354
+ * OpenCode heartbeats re-arm the managed-session no-progress guard, so this is
355
+ * the DEFAULT-path exit when the agent stays silent. Overridable on RunManager. */
356
+ export const FINAL_MESSAGE_NUDGE_REPLY_MS = 5 * 60_000;
357
+ /** Bound one unattended session; an explicit Continue starts a new budget. */
358
+ export const MAX_AUTO_CONTINUES = 40;
359
+ export const AUTONOMOUS_NUDGE = 'Continue working autonomously until the task is fully complete. Do not ask me for confirmation or clarification — make reasonable assumptions and proceed. When everything is done, end the session with your done signal.';
360
+ export const FINAL_MESSAGE_NUDGE = 'Your last turn ended without a message to the user. Write your user-facing reply now: what you did, where things stand, and anything you need from the user. If the task goal is fully achieved, end that message with CEZ:DONE.';
361
+ const MONITORING_WAKE_NUDGE = 'Re-check the downstream work you were monitoring. Continue toward the task goal. When the watched work is finished and the task goal is complete, end your final message with CEZ:DONE. If the watched work is still pending, end with CEZ:MONITORING. Never yield markerless for a monitoring wrap-up. If you genuinely need user input, follow the normal question path instead.';
234
362
  /**
235
363
  * Auto-resume after a provider usage limit (spec 2026-08-03-auto-resume-after-usage-limit).
236
364
  *
@@ -439,6 +567,10 @@ const ORPHAN_PROBE_CACHE_MS = 2_000;
439
567
  class WorkerOrphanAliveError extends Error {
440
568
  }
441
569
  const admissionError = (error, fallback) => error instanceof WorkerOrphanAliveError ? error.message : fallback;
570
+ /** The delegation family a run belongs to, by its root's id: a worker's parent, else itself. */
571
+ function familyOf(run) {
572
+ return run.delegation?.role === 'worker' ? run.delegation.parentRunId : run.id;
573
+ }
442
574
  /**
443
575
  * The mini workflow engine: executes a `WorkflowDef` against a repo, one step
444
576
  * at a time, persisting every event to the RunStore (which the SSE endpoints
@@ -468,6 +600,8 @@ export class RunManager {
468
600
  beginWorkerExecution(runId, admitted = true) {
469
601
  if (this.store.getRun(runId)?.delegation?.role !== 'worker')
470
602
  return;
603
+ if (this.reclaiming.has(runId))
604
+ throw new Error('Worker resources are being cleaned up');
471
605
  // #469: one site for Continue, --resume, parent replies and queued revival after a crash.
472
606
  // Fresh: admission is one-shot, so a cached "alive" must not refuse an orphan that has since died.
473
607
  if (!this.executions.has(runId))
@@ -561,18 +695,17 @@ export class RunManager {
561
695
  return { state: 'unknown' };
562
696
  if (record !== 'absent' && isCurrentProcess(record.controller))
563
697
  return { state: 'none' };
564
- // Finalization may reap terminal task scratch, so a process working there keeps the generation alive.
698
+ // Legacy execution proof also considers scratch holders; cleanup uses a separate strict proof.
699
+ const locations = agentTmpDirLocationEvidence(this.dataDir, runId);
565
700
  return { state: 'orphan', generation: proof.generation, ...(record === 'absent' ? {} : { record }),
566
- paths: [run.delegation.workspace.path, ...agentTmpDirLocations(this.dataDir, runId)],
567
- // No process of this worker can predate its record (1 s slack for tick rounding).
568
- ...(Number.isFinite(Date.parse(run.createdAt)) ? { since: Date.parse(run.createdAt) - 1_000 } : {}) };
701
+ paths: [run.delegation.workspace.path, ...locations.paths], pathsComplete: locations.complete };
569
702
  }
570
703
  orphanedWorkerGeneration(runId, admitting = false) {
571
704
  const orphan = this.orphanState(runId, admitting);
572
705
  return orphan.state === 'orphan' ? orphan : undefined;
573
706
  }
574
- /** Completes a crashed generation's proof once every one of its processes is proven gone (#469).
575
- * Sync and signal-free; recovery, resume, delivery, collect and destroy then take their normal paths.
707
+ /** Settles a crashed generation after exit proof. Sync and signal-free; recovery, resume,
708
+ * delivery, collect and destroy then take their normal paths.
576
709
  * A non-gone probe is cached briefly: on darwin the scan is a synchronous `lsof` on the event loop. */
577
710
  settleOrphanedWorkerExecution(runId, opts = {}) {
578
711
  const orphan = this.orphanedWorkerGeneration(runId, opts.admitting);
@@ -581,7 +714,7 @@ export class RunManager {
581
714
  const cached = this.orphanProbes.get(runId);
582
715
  if (!opts.fresh && cached?.generation === orphan.generation && Date.now() - cached.at < ORPHAN_PROBE_CACHE_MS)
583
716
  return false;
584
- const probe = inspectGeneration(orphan);
717
+ const probe = inspectExecutionGeneration({ ...orphan, cwds: opts.cwds });
585
718
  if (probe.liveness !== 'gone') {
586
719
  this.orphanProbes.set(runId, { generation: orphan.generation, at: Date.now(), probe });
587
720
  return false;
@@ -604,45 +737,70 @@ export class RunManager {
604
737
  return true;
605
738
  }
606
739
  orphanProbes = new Map();
607
- orphanReprobes = new Map();
740
+ /** Pending orphan reprobes by run id. One timer serves them all (hearsay-tools/cezarion#879). */
741
+ orphanDue = new Map();
742
+ orphanTimer;
743
+ /** The `at` the timer was armed for; with the clock, it decides what a tick takes. */
744
+ orphanArmedFor = 0;
608
745
  orphanBlockers = new Map();
609
746
  reportedOrphanBlockers = new Map();
610
747
  // Private and overridable so tests need not wait out production cadence.
611
- orphanReprobeMs = 15_000;
612
- orphanReprobeLimitMs = 15 * 60_000;
613
- orphanReprobeSlowMs = 60_000;
748
+ orphanBackoff = ORPHAN_BACKOFF;
614
749
  orphanTermGraceMs = 10_000;
615
750
  /** #469: a survivor that dies after recovery has no other wake source (no exit callback for a
616
751
  * process another cezar spawned). Unref'd; finalization's `run` event then lets worker waits and
617
- * outcomes observe it. Fast for the first window, then slow but uncapped, so a long-lived
618
- * survivor is still noticed when it exits. A tick while this manager holds the run is skipped;
619
- * only terminal reasons (proof settled, generation changed, run gone, unknown record, dispose)
620
- * stop it. */
752
+ * outcomes observe it. Every 15 s for the first 15 minutes, then backing off to hourly
753
+ * (hearsay-tools/cezarion#879), so a long-lived survivor is still noticed when it exits. One
754
+ * timer serves every orphan, and a tick shares one `/proc` scan across them. A tick while this
755
+ * manager holds the run is skipped; only terminal reasons (proof settled, generation changed,
756
+ * run gone, unknown record, dispose) stop it. */
621
757
  armOrphanReprobe(runId) {
622
758
  const armed = this.orphanedWorkerGeneration(runId);
623
- if (this.disposed || this.orphanReprobes.has(runId) || !armed)
624
- return;
625
- const slowAfter = Date.now() + this.orphanReprobeLimitMs;
626
- const schedule = () => {
627
- const timer = setTimeout(() => {
628
- const orphan = this.orphanState(runId);
629
- if (orphan.state === 'busy')
630
- return schedule();
631
- if (orphan.state !== 'orphan' || orphan.generation !== armed.generation || this.settleOrphanedWorkerExecution(runId))
632
- this.clearOrphanReprobe(runId);
633
- else
634
- schedule();
635
- }, Date.now() < slowAfter ? this.orphanReprobeMs : this.orphanReprobeSlowMs);
636
- timer.unref?.();
637
- this.orphanReprobes.set(runId, timer);
638
- };
639
- schedule();
759
+ if (this.disposed || this.orphanDue.has(runId) || !armed)
760
+ return;
761
+ this.orphanDue.set(runId, { generation: armed.generation, at: Date.now() + retryDelayMs(0, this.orphanBackoff, noJitter), attempts: 0 });
762
+ this.wakeOrphanReprobes();
763
+ }
764
+ wakeOrphanReprobes() {
765
+ clearTimeout(this.orphanTimer);
766
+ this.orphanTimer = undefined;
767
+ if (this.disposed || !this.orphanDue.size)
768
+ return;
769
+ let next = Infinity;
770
+ for (const { at } of this.orphanDue.values())
771
+ next = Math.min(next, at);
772
+ this.orphanArmedFor = next;
773
+ this.orphanTimer = setTimeout(() => this.orphanReprobeTick(), Math.max(0, next - Date.now()));
774
+ this.orphanTimer.unref?.();
775
+ }
776
+ orphanReprobeTick() {
777
+ this.orphanTimer = undefined;
778
+ if (this.disposed)
779
+ return;
780
+ // Orphans due within 5 % of the fast cadence ride along, so near neighbours share the scan, and
781
+ // every orphan this tick keeps is re-armed from the same instant, so they stay together.
782
+ const now = Date.now();
783
+ const dueBy = Math.max(this.orphanArmedFor, now) + this.orphanBackoff.fastMs / 20;
784
+ const cwds = sharedCwdScan();
785
+ for (const [runId, pending] of [...this.orphanDue]) {
786
+ if (pending.at > dueBy)
787
+ continue;
788
+ this.orphanDue.delete(runId);
789
+ const orphan = this.orphanState(runId);
790
+ let attempts = pending.attempts;
791
+ if (orphan.state !== 'busy') {
792
+ // Ticks are seconds apart, so the brief probe cache would only skip them: probe fresh.
793
+ if (orphan.state !== 'orphan' || orphan.generation !== pending.generation ||
794
+ this.settleOrphanedWorkerExecution(runId, { fresh: true, cwds }))
795
+ continue;
796
+ attempts++;
797
+ }
798
+ this.orphanDue.set(runId, { ...pending, attempts, at: now + retryDelayMs(attempts, this.orphanBackoff, noJitter) });
799
+ }
800
+ this.wakeOrphanReprobes();
640
801
  }
641
802
  clearOrphanReprobe(runId) {
642
- const timer = this.orphanReprobes.get(runId);
643
- if (timer)
644
- clearTimeout(timer);
645
- this.orphanReprobes.delete(runId);
803
+ this.orphanDue.delete(runId);
646
804
  }
647
805
  /** Why the last destroy could not prove termination (#469), and whether that differs from the
648
806
  * reason last reported, so a retry loop appends one lifecycle event per distinct blocker set. */
@@ -683,29 +841,16 @@ export class RunManager {
683
841
  // >= 500 ms: every probe may be a synchronous darwin `lsof`.
684
842
  const pause = () => new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(500, deadline - Date.now()))));
685
843
  // A live controller is another cezar's: nothing to signal or wait for. Otherwise signal only
686
- // recorded, token-verified survivors (none for a legacy record-less generation), then wait
687
- // out every other holder until the deadline; the scan's processes are never signalled.
844
+ // recorded, token-verified survivors and recorded session groups (none for a legacy
845
+ // record-less generation), then wait for readable cwd holders until the deadline.
688
846
  if (!orphan.record || !recordedProcessLive(orphan.record.controller)) {
689
847
  if (orphan.record) {
690
- const targets = orphan.record.processes.filter(entry => entry.startToken !== undefined && recordedProcessLive(entry));
691
- const signal = (name) => {
692
- for (const entry of targets) {
693
- if (!same())
694
- return;
695
- // Re-verified immediately before every signal, exactly: a reused PID is never touched.
696
- if (processStartToken(entry.pid) === entry.startToken)
697
- try {
698
- process.kill(entry.pid, name);
699
- }
700
- catch { /* already gone */ }
701
- }
702
- };
703
- signal('SIGTERM');
704
- const killAt = Math.min(deadline, Date.now() + this.orphanTermGraceMs);
705
- while (Date.now() < killAt && targets.some(recordedProcessLive))
706
- await pause();
707
- if (targets.some(recordedProcessLive))
708
- signal('SIGKILL');
848
+ const { processes } = orphan.record;
849
+ await this.terminateRecorded({
850
+ pids: processes.filter(entry => entry.pgid === undefined && entry.startToken !== undefined && recordedProcessLive(entry)),
851
+ groups: processes.filter(entry => entry.pgid !== undefined),
852
+ paths: orphan.paths,
853
+ }, deadline, pause, same);
709
854
  }
710
855
  while (!this.settleOrphanedWorkerExecution(runId, { fresh: true }) && Date.now() < deadline && same())
711
856
  await pause();
@@ -718,13 +863,70 @@ export class RunManager {
718
863
  else if (probe?.pids.length)
719
864
  this.orphanBlockers.set(runId, { kind: 'processes', pids: probe.pids });
720
865
  }
866
+ /** Destroy only: SIGTERM recorded survivors, then SIGKILL what outlives `orphanTermGraceMs`.
867
+ * `pids` are signalled by token-verified pid; `groups` are session leaders whose whole process
868
+ * group is signalled (hearsay-tools/cezarion#890), so what an agent left behind exits with it. A
869
+ * dead leader's group counts only while a process holding `paths` is in it. Every signal
870
+ * re-verifies first: a reused PID or group number is never touched. */
871
+ async terminateRecorded(targets, deadline, pause, current = () => true) {
872
+ const holders = processesWithCwdUnder(targets.paths);
873
+ const holderGroups = () => (holders === 'unknown' ? [] : holders).map(processGroupOf).filter((pgid) => pgid !== undefined);
874
+ const groups = targets.groups.filter(entry => recordedGroupSignalable(entry, holderGroups()) && processGroupAlive(entry.pgid));
875
+ const living = () => targets.pids.some(recordedProcessLive) || groups.some(entry => processGroupAlive(entry.pgid));
876
+ const signal = (name) => {
877
+ for (const entry of targets.pids) {
878
+ if (!current())
879
+ return;
880
+ if (processStartToken(entry.pid) === entry.startToken)
881
+ try {
882
+ process.kill(entry.pid, name);
883
+ }
884
+ catch { /* already gone */ }
885
+ }
886
+ const evidence = holderGroups();
887
+ for (const entry of groups) {
888
+ if (!current())
889
+ return;
890
+ if (recordedGroupSignalable(entry, evidence))
891
+ signalProcessGroup(entry.pgid, name);
892
+ }
893
+ };
894
+ if (!living())
895
+ return;
896
+ signal('SIGTERM');
897
+ const killAt = Math.min(deadline, Date.now() + this.orphanTermGraceMs);
898
+ while (Date.now() < killAt && living())
899
+ await pause();
900
+ if (living())
901
+ signal('SIGKILL');
902
+ }
903
+ /** Destroy of a finished generation (hearsay-tools/cezarion#890): a dev server or watcher its
904
+ * agent left in its process group holds the worktree, and nothing else ties it to the worker.
905
+ * A record whose controller is another live cezar is not ours to act on. */
906
+ async terminateFinishedWorkerGroups(runId, timeoutMs) {
907
+ const proof = this.store.readWorkerExecution(runId);
908
+ if (proof?.phase !== 'complete')
909
+ return;
910
+ const record = this.store.readWorkerProcesses(runId, proof.generation);
911
+ const run = this.store.getRun(runId);
912
+ if (typeof record === 'string' || run?.delegation?.role !== 'worker' ||
913
+ (recordedProcessLive(record.controller) && !isCurrentProcess(record.controller)))
914
+ return;
915
+ const groups = record.processes.filter(entry => entry.pgid !== undefined);
916
+ if (!groups.length)
917
+ return;
918
+ const deadline = Date.now() + Math.min(30_000, Math.max(0, Number.isFinite(timeoutMs) ? timeoutMs : 0));
919
+ const pause = () => new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(100, deadline - Date.now()))));
920
+ const paths = [run.delegation.workspace.path, ...agentTmpDirLocationEvidence(this.dataDir, runId).paths];
921
+ await this.terminateRecorded({ pids: [], groups, paths }, deadline, pause);
922
+ }
721
923
  /** Best effort: a failed write leaves the working-directory scan as this process's evidence. */
722
924
  recordWorkerProcess(runId, pid) {
723
925
  const generation = this.executions.get(runId)?.generation;
724
926
  if (!generation)
725
927
  return;
726
928
  try {
727
- if (!this.store.appendWorkerProcess(runId, generation, pid))
929
+ if (!this.store.appendWorkerProcess(runId, generation, pid, sessionGroupOf(pid)))
728
930
  console.warn(`[cez] worker ${runId} process record unavailable; relying on the working-directory scan`);
729
931
  }
730
932
  catch {
@@ -746,6 +948,12 @@ export class RunManager {
746
948
  return { workerId: runId, state: !this.executions.has(runId) && this.store.readWorkerExecution(runId)?.phase === 'complete' ? 'terminated' : 'stopping' };
747
949
  }
748
950
  async awaitRunTermination(runId, timeoutMs, opts = {}) {
951
+ const terminated = await this.proveRunTermination(runId, timeoutMs, opts);
952
+ if (terminated && opts.reapOrphans && !this.disposed)
953
+ await this.terminateFinishedWorkerGroups(runId, timeoutMs);
954
+ return terminated;
955
+ }
956
+ async proveRunTermination(runId, timeoutMs, opts) {
749
957
  const execution = this.executions.get(runId);
750
958
  if (!execution) {
751
959
  const generation = this.finalizedWorkers.get(runId);
@@ -767,25 +975,6 @@ export class RunManager {
767
975
  });
768
976
  return !this.executions.has(runId) && this.store.readWorkerExecution(runId)?.phase === 'complete';
769
977
  }
770
- /** Private cleanup capability, never serialized. Obtain after stop/termination
771
- * under the service's destroy lock; each invocation rechecks the same generation. */
772
- getWorkerNoMaterializationProof(runId) {
773
- const initial = this.store.readWorkerExecution(runId);
774
- const recorded = this.store.getRun(runId)?.delegation;
775
- if (!initial?.neverMaterialized || initial.phase !== 'complete' || recorded?.role !== 'worker')
776
- return undefined;
777
- const workspace = { ...recorded.workspace };
778
- const verify = candidate => {
779
- const run = this.store.getRun(runId);
780
- const current = this.store.readWorkerExecution(runId);
781
- const delegation = run?.delegation;
782
- return !this.disposed && !this.isActive(runId) && run?.status === 'cancelled' &&
783
- delegation?.role === 'worker' && !!delegation.destroy &&
784
- current?.generation === initial.generation && current.phase === 'complete' && current.neverMaterialized === true &&
785
- Object.keys(workspace).every(key => candidate[key] === workspace[key] && delegation.workspace[key] === workspace[key]);
786
- };
787
- return verify(workspace) ? verify : undefined;
788
- }
789
978
  /** Irreversible history removal is an execution tombstone, including for legacy children. */
790
979
  historyDeletionPending(runId) {
791
980
  const run = this.store.getRun(runId);
@@ -816,6 +1005,14 @@ export class RunManager {
816
1005
  ciRegistrations = new Map();
817
1006
  ciRetries = new Map();
818
1007
  ciResources;
1008
+ /** #781: the workspace-wide preview host. Absent (`cez run`, tests) means every registration is `headless`. */
1009
+ preview;
1010
+ /** The same host, for a removal outside this manager that must release a run's preview first. */
1011
+ get previewHost() {
1012
+ return this.preview;
1013
+ }
1014
+ /** #781: cezar's own listening port, which no dev server may register. */
1015
+ cezarPort;
819
1016
  ciSupervisor;
820
1017
  workerWaiting = new Set();
821
1018
  workerWakeAdmitted = new Set();
@@ -825,13 +1022,40 @@ export class RunManager {
825
1022
  reconcilingWorkers = false;
826
1023
  reconcilingFamily;
827
1024
  pendingReconciliations = new Set();
828
- recovering = false;
1025
+ /** What the recovery in progress covers: every run at boot (`family` undefined), or the one
1026
+ * family an adoption hands it (#779, plan step 3). Undefined: no recovery in progress. */
1027
+ recoveryScope;
1028
+ /** Settles when the recovery in progress (if any) has finished. */
1029
+ recoveryInFlight;
1030
+ /** Adoptions of dead processes' runs, one at a time (`adoptOrphanedRun`). */
1031
+ adoptionTail = Promise.resolve();
829
1032
  /** Quiet window before unread input is resubmitted or left unconfirmed (#505). */
830
1033
  unreadInputGraceMs;
1034
+ /** Quiet window after a final-message nudge before a still-silent session parks (#544). */
1035
+ finalMessageNudgeReplyMs;
831
1036
  disposed = false;
1037
+ /** Whether any recovery is in progress: one at a time (`recover`), and `pump` waits for it. */
1038
+ get recovering() {
1039
+ return this.recoveryScope !== undefined;
1040
+ }
1041
+ /**
1042
+ * Whether a recovery in progress holds this run, so its wakes, input flushes and delegation
1043
+ * reconciles wait for that recovery's own passes: every run during boot recovery, and only the
1044
+ * adopted family's during a recovery scoped to one. The rest of a running project stays live: a
1045
+ * wake held back for an unrelated family would be lost, since the scoped passes never revisit it.
1046
+ */
1047
+ recoveringRun(run) {
1048
+ const scope = this.recoveryScope;
1049
+ if (!scope)
1050
+ return false;
1051
+ if (scope.family === undefined)
1052
+ return true;
1053
+ const record = typeof run === 'string' ? this.store.getRun(run) : run;
1054
+ return record !== undefined && familyOf(record) === scope.family;
1055
+ }
832
1056
  onDelegationRun = (run, source) => {
833
1057
  // Delegation metadata checkpoints do not change task status.
834
- if (!this.disposed && source !== 'delegation-checkpoint')
1058
+ if (!this.disposed && source === undefined)
835
1059
  this.reapTerminalScratch(run.id);
836
1060
  if (!this.disposed && !['queued', 'running', 'waiting'].includes(run.status))
837
1061
  this.withdrawCiWait(run.id);
@@ -839,7 +1063,10 @@ export class RunManager {
839
1063
  this.active.get(run.id)?.revokeDelegation?.();
840
1064
  // Cleanup checkpoints emit terminal records too. With delegation disabled,
841
1065
  // these observations must not replay the project's conversation histories.
842
- if (this.disposed || this.recovering || (source === 'delegation-checkpoint' && process.env.CEZ_DELEGATION !== '1'))
1066
+ if (this.disposed || this.recoveringRun(run) || (source === 'delegation-checkpoint' && process.env.CEZ_DELEGATION !== '1'))
1067
+ return;
1068
+ // A destroy retry changes nothing the family reconcile reads (#880).
1069
+ if (source === 'delegation-destroy-progress')
843
1070
  return;
844
1071
  if (run.delegation && run.delegation.role !== 'invalid' && !['queued', 'running', 'waiting'].includes(run.status)) {
845
1072
  this.reconcileWorkerWaits(run.delegation.role === 'root' ? run.id : run.delegation.parentRunId);
@@ -850,6 +1077,10 @@ export class RunManager {
850
1077
  * explicit user Continue, these are bulk scheduler work and must re-enter
851
1078
  * through `pump()` so both workspace and per-project caps are honored. */
852
1079
  pendingContinuations = new Map();
1080
+ /** Accepted Continues supersede in-flight settlement even before launch or after
1081
+ * idle close. Retain these across dropActive/dispose so absence cannot look like
1082
+ * the earlier execution again; disposal alone never revokes terminal intent. */
1083
+ continuationGenerations = new Map();
853
1084
  /** Per-run image counter behind `pasted-<n>` / `screenshot-<n>` (#472). Lives on
854
1085
  * the manager rather than the `ActiveRun` so a *queued* run — which has no
855
1086
  * `ActiveRun` at all — can persist attachments. Seeded lazily from disk. */
@@ -904,7 +1135,11 @@ export class RunManager {
904
1135
  this.store = store;
905
1136
  this.repoRoot = repoRoot;
906
1137
  this.dataDir = join(repoRoot, '.ai/cezar');
1138
+ this.workerScratchCleanup = new WorkerScratchCleanup(store, this.dataDir, id => this.isActive(id) || this.executions.has(id) || this.reclaiming.has(id));
1139
+ this.preview = options.preview;
1140
+ this.cezarPort = options.cezarPort;
907
1141
  this.unreadInputGraceMs = options.unreadInputGraceMs ?? UNREAD_INPUT_GRACE_MS;
1142
+ this.finalMessageNudgeReplyMs = options.finalMessageNudgeReplyMs ?? FINAL_MESSAGE_NUDGE_REPLY_MS;
908
1143
  this.semaphore = options.semaphore ?? new WorkspaceSemaphore();
909
1144
  this.ciResources = acquireCiResources(this.semaphore);
910
1145
  this.ciSupervisor = this.ciResources.supervisor;
@@ -933,6 +1168,7 @@ export class RunManager {
933
1168
  */
934
1169
  dispose() {
935
1170
  this.disposed = true;
1171
+ this.workerScratchCleanup.pause();
936
1172
  for (const task of this.ciWatches.values())
937
1173
  task.abort.abort();
938
1174
  this.ciWatches.clear();
@@ -945,8 +1181,9 @@ export class RunManager {
945
1181
  this.ciResources.release();
946
1182
  this.delegationProvisioner = undefined;
947
1183
  this.finalizedWorkers.clear();
948
- for (const runId of [...this.orphanReprobes.keys()])
949
- this.clearOrphanReprobe(runId);
1184
+ this.orphanDue.clear();
1185
+ clearTimeout(this.orphanTimer);
1186
+ this.orphanTimer = undefined;
950
1187
  this.orphanProbes.clear();
951
1188
  this.orphanBlockers.clear();
952
1189
  this.reportedOrphanBlockers.clear();
@@ -1209,10 +1446,77 @@ export class RunManager {
1209
1446
  const controller = await this.ciResources.controller();
1210
1447
  if (!controller || this.disposed || state.cancelled || this.active.get(runId) !== state)
1211
1448
  return;
1212
- const provisioned = controller.provision((request, signal) => this.registerCiWait(runId, request, generation, signal));
1449
+ const provisioned = controller.provision((request, signal) => this.registerCiWait(runId, request, generation, signal), (request, signal) => this.registerPreviewServer(runId, request, signal), (request, signal) => this.stopPreviewServer(runId, request, signal));
1213
1450
  state.revokeCiTools = provisioned.revoke;
1214
1451
  return provisioned;
1215
1452
  }
1453
+ async stopPreviewServer(runId, request, signal) {
1454
+ const parsed = previewStopRequestSchema.parse(request);
1455
+ if (!previewToolEnabled())
1456
+ return previewStopResult('preview_disabled');
1457
+ if (!this.preview)
1458
+ return previewStopResult('headless');
1459
+ if (this.disposed || signal?.aborted)
1460
+ return previewStopResult('unavailable');
1461
+ const run = this.store.getRun(runId);
1462
+ if (!run?.worktreePath || !existsSync(run.worktreePath))
1463
+ return previewStopResult('worktree_missing');
1464
+ const owner = this.preview.portOwner(parsed.port);
1465
+ if (owner && owner.runId !== runId)
1466
+ return previewStopResult('port_held');
1467
+ if (!run.previewServers?.some(server => server.port === parsed.port))
1468
+ return previewStopResult('not_registered');
1469
+ return this.preview.stopPreview(runId, parsed, signal);
1470
+ }
1471
+ /**
1472
+ * #781: record a dev server the agent registered. One probe at registration becomes
1473
+ * `answeredAtRegistration`; the run is re-validated after it, since the probe awaits.
1474
+ */
1475
+ async registerPreviewServer(runId, request, signal) {
1476
+ const parsed = previewServeRequestSchema.parse(request);
1477
+ const validate = () => {
1478
+ const run = this.store.getRun(runId);
1479
+ const owner = this.preview?.portOwner(parsed.port);
1480
+ const result = validateRegistration({
1481
+ request: parsed,
1482
+ worktreePath: run?.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : undefined,
1483
+ cezarPort: this.cezarPort?.(),
1484
+ existing: run?.previewServers ?? [],
1485
+ owner,
1486
+ runId,
1487
+ enabled: previewToolEnabled(),
1488
+ headless: !this.preview,
1489
+ });
1490
+ const withoutWorktree = !!run && !run.worktreePath;
1491
+ return { ...result, answer: () => previewResult(result.code, { ...parsed, ownerTitle: owner?.title, withoutWorktree }) };
1492
+ };
1493
+ const first = validate();
1494
+ if (!first.server || !this.preview)
1495
+ return first.answer();
1496
+ // A server process supplies `cezarPort`, but recovered runs can call before the cockpit binds:
1497
+ // until it knows its port, `cezar_port` cannot be checked, so the tool asks for a retry instead.
1498
+ if (this.cezarPort && this.cezarPort() === undefined)
1499
+ return previewResult('unavailable', parsed);
1500
+ const answered = await this.preview.probe(parsed.port).catch(() => false);
1501
+ // A session revoked during the probe (cancel, stop) must leave no card behind.
1502
+ signal?.throwIfAborted();
1503
+ const checked = validate();
1504
+ if (!checked.server)
1505
+ return checked.answer();
1506
+ const server = { ...checked.server, answeredAtRegistration: answered };
1507
+ const existing = this.store.getRun(runId)?.previewServers ?? [];
1508
+ const previous = existing.find(entry => entry.port === server.port);
1509
+ const changed = !!previous && (previous.command !== server.command || previous.cwd !== server.cwd || previous.path !== server.path);
1510
+ const previewServers = checked.code === 'replaced'
1511
+ ? existing.map(entry => entry.port === server.port ? server : entry)
1512
+ : [...existing, server];
1513
+ this.store.updateRun(runId, { previewServers });
1514
+ this.store.appendEvent(runId, { type: 'preview.server-registered', server });
1515
+ // A copy cezar runs from the old command must not keep answering for the new registration.
1516
+ if (changed)
1517
+ await this.preview.replaced(runId, server.port).catch(() => { });
1518
+ return checked.answer();
1519
+ }
1216
1520
  /** Trusted capability callbacks supply run identity; the model supplies only a PR and deadline. */
1217
1521
  async registerCiWait(runId, request, generation, capabilitySignal) {
1218
1522
  const parsed = ciWaitRequestSchema.parse(request);
@@ -1390,7 +1694,7 @@ export class RunManager {
1390
1694
  queueCiWake(runId) {
1391
1695
  const run = this.store.getRun(runId);
1392
1696
  const wait = run?.ciWait;
1393
- if (this.disposed || this.recovering || !run || !wait?.result || !wait.wakeId ||
1697
+ if (this.disposed || this.recoveringRun(run) || !run || !wait?.result || !wait.wakeId ||
1394
1698
  !['running', 'waiting', 'queued'].includes(run.status) || run.stopping ||
1395
1699
  this.hasPendingHumanAsk(runId) || this.workerExecutionStopped(runId) || this.executionBlockedByRootFinish(run))
1396
1700
  return;
@@ -1693,6 +1997,7 @@ export class RunManager {
1693
1997
  * root (spec 006 degradation rule), which is always the tighter bound.
1694
1998
  */
1695
1999
  async pump() {
2000
+ // Any recovery, scoped or not: each ends with a pump of its own, so nothing waits for long.
1696
2001
  if (this.disposed || this.recovering)
1697
2002
  return;
1698
2003
  this.reconcileMonitoringWakeTimers();
@@ -2004,13 +2309,25 @@ export class RunManager {
2004
2309
  * - `running` → mark interrupted, then immediately resume the last agent
2005
2310
  * session via the Continue path, pointing the agent at its handoff file.
2006
2311
  * Call once, before the server starts taking requests.
2312
+ *
2313
+ * With `familyRootId`, the same recovery for the one delegation family a dead process left
2314
+ * behind, which `adoptOrphanedRun` has just loaded. Either way only runs this store owns are
2315
+ * touched: another live process's runs are not in the live set, and the passes that read every
2316
+ * worker or root skip their families (#779, plan step 3).
2007
2317
  */
2008
- async recover() {
2009
- if (this.disposed || this.recovering)
2010
- return;
2011
- this.recovering = true;
2318
+ async recover(familyRootId) {
2319
+ // A store that could not open (#779) knows none of its runs: recovering it would read "none".
2320
+ if (this.disposed || this.recovering || this.store.unavailable)
2321
+ return;
2322
+ this.recoveryScope = { ...(familyRootId === undefined ? {} : { family: familyRootId }) };
2323
+ let settled;
2324
+ this.recoveryInFlight = new Promise((resolve) => { settled = resolve; });
2325
+ const inScope = (run) => familyRootId === undefined || familyOf(run) === familyRootId;
2012
2326
  try {
2013
- for (const run of this.store.listRuns()) {
2327
+ // Recovery reads the live set (#779): `listRuns()` is the runs `open` loaded by the `live`
2328
+ // column, and finished runs have nothing to recover. The worker passes below go through
2329
+ // indexed queries instead.
2330
+ for (const run of this.store.listRuns().filter(inScope)) {
2014
2331
  this.store.clearExpiredInboxClaims(run.id, new Date().toISOString());
2015
2332
  this.armInboxClaimExpiry(run.id);
2016
2333
  // #505: the session that accepted this input died before the model read it.
@@ -2023,26 +2340,29 @@ export class RunManager {
2023
2340
  }
2024
2341
  // #469: a generation whose processes died with the old controller is finalized first, so it
2025
2342
  // re-launches through the ordinary Continue path; live task scratch is retained.
2026
- for (const run of this.store.listRuns())
2027
- if (run.delegation?.role === 'worker' && !this.settleOrphanedWorkerExecution(run.id))
2028
- this.armOrphanReprobe(run.id);
2029
- this.reconcileWorkerWaits();
2343
+ // Any worker, finished or not, may hold a generation a dead controller left `starting`. Its
2344
+ // private proof is read first, so only those workers' records are decoded: settling and
2345
+ // re-probing are no-ops for every other proof phase.
2346
+ // Another process's workers are its own to settle.
2347
+ const workerIds = familyRootId === undefined ? this.store.listWorkerIds() : this.store.listWorkersOf(familyRootId).map((run) => run.id);
2348
+ for (const id of workerIds) {
2349
+ if (this.store.readWorkerExecution(id)?.phase === 'starting' && !this.store.writeRefusal(id) &&
2350
+ !this.settleOrphanedWorkerExecution(id))
2351
+ this.armOrphanReprobe(id);
2352
+ }
2353
+ this.reconcileWorkerWaits(familyRootId);
2030
2354
  const live = this.store
2031
2355
  .listRuns()
2032
- .filter((r) => ['queued', 'waiting', 'running'].includes(r.status))
2356
+ .filter((r) => inScope(r) && ['queued', 'waiting', 'running'].includes(r.status))
2033
2357
  .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2034
- // A crash never reaches `dropActive`, so its temp directory (#785) outlived the run.
2035
- // Startup is the one moment we know which runs are still live, so sweep every other
2036
- // per-run directory here — bounded to `<dataDir>/tmp`, never a sibling.
2037
- // An owned process can survive its controller, including behind terminal
2038
- // public status. Unknown private termination retains that process's scratch.
2039
- const retained = this.store.listRuns().filter(run => run.delegation?.role === 'worker' &&
2040
- this.store.readWorkerExecution(run.id)?.phase !== 'complete');
2041
- // `dispose()` deliberately does not terminate live sessions, so a waiting
2042
- // record may still own a process even while this manager is recovering.
2043
- // Idle-close ends a process, not its task. Preserve all live task scratch,
2044
- // including workers whose previous process generation has completed.
2045
- sweepAgentTmpDirs(this.dataDir, [...live, ...retained].map(run => run.id));
2358
+ // Scratch cleanup discovers private evidence independently of the salvageable run index.
2359
+ // Terminal worker cleanup/retries and the generic orphan sweep share this boundary. An
2360
+ // adopted family only schedules its own members: the sweep belongs to restart.
2361
+ if (familyRootId === undefined)
2362
+ this.workerScratchCleanup.recover();
2363
+ else
2364
+ for (const run of this.familyMembers(familyRootId))
2365
+ this.workerScratchCleanup.schedule(run.id);
2046
2366
  for (const run of live) {
2047
2367
  if (this.isActive(run.id))
2048
2368
  continue;
@@ -2066,6 +2386,13 @@ export class RunManager {
2066
2386
  continue;
2067
2387
  }
2068
2388
  if (run.delegation && run.delegation.role !== 'invalid' && this.hasPendingHumanAsk(run.id)) {
2389
+ // A crash between the prose checkpoint and wait withdrawal must not
2390
+ // recover the superseded wait as an autonomous wake.
2391
+ const gateSeq = this.pendingHumanAskSeq(run.id);
2392
+ if (this.store.readEvents(run.id).some(event => event.seq === gateSeq && event.type === 'note' && event.code === PROSE_HUMAN_GATE)) {
2393
+ this.withdrawCiWait(run.id);
2394
+ this.withdrawWorkerWait(run.id);
2395
+ }
2069
2396
  this.store.updateRun(run.id, { status: 'waiting', activity: undefined });
2070
2397
  // Only the durable, explicitly human payload may resume an unanswered
2071
2398
  // question. A refused recovery leaves attention, not terminal failure.
@@ -2147,6 +2474,18 @@ export class RunManager {
2147
2474
  });
2148
2475
  const interruptedIndex = run.workflowDef?.steps.findIndex(step => step.id === interruptedStepId) ?? -1;
2149
2476
  const interruptedDef = interruptedIndex >= 0 ? run.workflowDef?.steps[interruptedIndex] : undefined;
2477
+ const requeueInput = () => ({ task: run.task, runner: run.runner, model: run.model, effort: run.effort,
2478
+ agentProfile: run.agentProfile, systemPrompt: run.systemPrompt, autonomous: run.autonomous,
2479
+ generateFollowups: run.generateFollowups, worktree: run.worktree });
2480
+ // Worktree setup (#917) interrupted before any agent session: there is no session to
2481
+ // continue, so start the workflow over. `execute` sees setup still `running` and reruns it.
2482
+ if (run.workflowDef && run.worktreeSetup?.status === 'running' && !run.steps.some(step => step.sessionId)) {
2483
+ this.store.updateRun(run.id, { status: 'queued', error: undefined, finishedAt: undefined, currentStepId: undefined });
2484
+ this.store.flush();
2485
+ this.pendingJobs.set(run.id, { workflow: run.workflowDef, startAt: 0, input: requeueInput() });
2486
+ this.queue.push(run.id);
2487
+ continue;
2488
+ }
2150
2489
  if (run.workflowDef && interruptedDef && stepKind(interruptedDef) === 'check') {
2151
2490
  this.store.updateStep(run.id, interruptedDef.id, { status: 'pending', error: undefined, finishedAt: undefined });
2152
2491
  this.store.updateRun(run.id, { status: 'queued', error: undefined, finishedAt: undefined, currentStepId: undefined });
@@ -2154,9 +2493,7 @@ export class RunManager {
2154
2493
  this.pendingJobs.set(run.id, {
2155
2494
  workflow: run.workflowDef,
2156
2495
  startAt: interruptedIndex,
2157
- input: { task: run.task, runner: run.runner, model: run.model, effort: run.effort,
2158
- agentProfile: run.agentProfile, systemPrompt: run.systemPrompt, autonomous: run.autonomous,
2159
- generateFollowups: run.generateFollowups, worktree: run.worktree },
2496
+ input: requeueInput(),
2160
2497
  });
2161
2498
  this.queue.push(run.id);
2162
2499
  continue;
@@ -2177,17 +2514,27 @@ export class RunManager {
2177
2514
  // one — see `reconcileAutoResumes`.
2178
2515
  }
2179
2516
  finally {
2180
- this.recovering = false;
2517
+ this.recoveryScope = undefined;
2518
+ this.recoveryInFlight = undefined;
2519
+ settled();
2181
2520
  }
2182
2521
  // A question a crash caught before or after its commit to the parent still gets routed (#505).
2183
- for (const run of this.store.listRuns())
2522
+ // Only a family with a live member has anyone left to route to or fall back from.
2523
+ const families = familyRootId === undefined ? this.liveFamilyMembers() : this.familyMembers(familyRootId);
2524
+ for (const run of families)
2184
2525
  if (run.delegation?.role === 'worker')
2185
2526
  this.routeWorkerQuestion(run.id);
2186
- this.reconcileWorkerWaits();
2187
- // A parent reply accepted just before the crash still answers its worker (#505).
2188
- for (const run of this.store.listRuns())
2527
+ if (familyRootId === undefined)
2528
+ this.reconcileAllWorkerFamilies();
2529
+ else
2530
+ this.reconcileWorkerWaits(familyRootId);
2531
+ // A parent reply accepted just before the crash still answers its worker (#505), and only a
2532
+ // live worker takes one.
2533
+ for (const run of this.store.listRuns().filter(inScope))
2189
2534
  if (run.delegation?.role === 'worker')
2190
2535
  this.answerRoutedQuestion(run.id);
2536
+ // The rest re-arms wakes from durable state and is idempotent, so it runs for every live run
2537
+ // even after one adopted family: anything the recovery window held back goes out now.
2191
2538
  for (const run of this.store.listRuns()) {
2192
2539
  if (run.ciWait && !['running', 'waiting', 'queued'].includes(run.status))
2193
2540
  this.withdrawCiWait(run.id);
@@ -2197,6 +2544,33 @@ export class RunManager {
2197
2544
  this.reconcileAutoResumes();
2198
2545
  void this.pump();
2199
2546
  }
2547
+ /**
2548
+ * Take over a run whose owning process is gone (#779, plan step 3): a control (Stop, Continue,
2549
+ * archive…) reached a run `runOwnership` calls `orphaned`. Its family is claimed, its live runs
2550
+ * are settled rather than resumed (`settleOrphanedRun`: interrupted, or cancelled before they
2551
+ * began), and the family is recovered as a restart would, which repairs it but finds nothing live
2552
+ * to resume. The control that follows then acts on a run this process now owns — rather than on
2553
+ * a record nobody owns, a dead end until a restart — and only Continue starts agent work, with
2554
+ * the user's own input. With `stop` the control is Stop, which the adoption carries out itself:
2555
+ * `runId` ends cancelled, as an accepted Stop reads, rather than interrupted. False when a live
2556
+ * process holds it after all (or it is gone): the control is refused as another process's.
2557
+ * Adoptions run one at a time, after any recovery already in progress.
2558
+ */
2559
+ adoptOrphanedRun(runId, opts = {}) {
2560
+ const adoption = this.adoptionTail.then(async () => {
2561
+ while (this.recovering && this.recoveryInFlight)
2562
+ await this.recoveryInFlight;
2563
+ if (this.disposed)
2564
+ return false;
2565
+ const family = this.store.adoptFamily(runId, opts);
2566
+ if (family === undefined)
2567
+ return false;
2568
+ await this.recover(family);
2569
+ return true;
2570
+ });
2571
+ this.adoptionTail = adoption.catch(() => undefined);
2572
+ return adoption;
2573
+ }
2200
2574
  /** The persisted definition when it looks sane, else the catalog by name. */
2201
2575
  async reviveWorkflow(run) {
2202
2576
  // "Looks sane" is the STORE's job now: it parses `workflowDef` against the definition schema
@@ -2216,6 +2590,17 @@ export class RunManager {
2216
2590
  preserveRunAfterDisposal(runId, state) {
2217
2591
  return this.isDisposedDelegatedRun(runId) && !state.cancelled && !state.finishRequested;
2218
2592
  }
2593
+ /**
2594
+ * Register an executing run: the ONE way an `ActiveRun` enters `active`. Both construction
2595
+ * sites (`execute` and `runContinuation`, which serves Continue and restart recovery) call it,
2596
+ * so the store pins every run this manager executes (#779) — its record stays the held object
2597
+ * every turn-end handler reads and writes until `dropActive` lets it go. A second site that set
2598
+ * `active` directly would execute a run the store could evict mid-turn (the #811 shape).
2599
+ */
2600
+ activate(runId, state) {
2601
+ this.active.set(runId, state);
2602
+ this.store.pin(runId, 'active');
2603
+ }
2219
2604
  /** Remove a run from the live registries — keeps `waiting ⊆ active`. */
2220
2605
  dropActive(runId) {
2221
2606
  const state = this.active.get(runId);
@@ -2225,6 +2610,7 @@ export class RunManager {
2225
2610
  state.releaseRepoRoot = undefined;
2226
2611
  this.clearIdleTimer(state);
2227
2612
  this.clearAutosaveTimer(state);
2613
+ this.clearHeldHumanInputTimer(state);
2228
2614
  }
2229
2615
  this.waiting.delete(runId);
2230
2616
  this.monitoring.delete(runId);
@@ -2239,6 +2625,7 @@ export class RunManager {
2239
2625
  if (state)
2240
2626
  this.clearMonitoringWakeTimer(state, runId);
2241
2627
  this.active.delete(runId);
2628
+ this.store.unpin(runId, 'active');
2242
2629
  if (!this.disposed && this.store.getRun(runId)?.ciWait)
2243
2630
  this.queueCiWake(runId);
2244
2631
  if (!this.executions.has(runId) && this.store.getRun(runId)?.stopping)
@@ -2261,9 +2648,16 @@ export class RunManager {
2261
2648
  // left a window — measured as exactly one extra task — where the queue saw a free slot and
2262
2649
  // an account that looked healthy, and started work that was already doomed.
2263
2650
  this.scheduleAutoResumeIfLimited(runId);
2264
- // Closed-session success may have parked a completion wait without a live wire.
2265
- if (this.store.getRun(runId)?.delegation)
2651
+ // Closed-session success may have parked a completion wait without a live wire. The global
2652
+ // pass covers the families still in memory (#779); this run's own family may have just left
2653
+ // the live set with it, so it is reconciled by name first.
2654
+ const settled = this.store.getRun(runId);
2655
+ if (settled?.delegation) {
2656
+ const familyRoot = settled.delegation.role === 'root' ? settled.id : settled.delegation.role === 'worker' ? settled.delegation.parentRunId : undefined;
2657
+ if (familyRoot !== undefined)
2658
+ this.reconcileWorkerWaits(familyRoot);
2266
2659
  this.reconcileWorkerWaits();
2660
+ }
2267
2661
  this.releaseSlot();
2268
2662
  // A run leaving the active registry may have reached a terminal transition
2269
2663
  // (done/review/failed/cancelled), increasing the finished-worktree count.
@@ -2273,6 +2667,18 @@ export class RunManager {
2273
2667
  void this.enforceRetention();
2274
2668
  this.reapTerminalScratch(runId);
2275
2669
  }
2670
+ workerScratchCleanup;
2671
+ /** Project detach cancels autonomous cleanup; reattach reconstructs the durable intents. */
2672
+ pauseWorkerCleanup() { this.workerScratchCleanup.pause(); }
2673
+ recoverWorkerCleanup() { if (!this.disposed)
2674
+ this.workerScratchCleanup.recover(); }
2675
+ /** Hold off every admission path until destructive work (including async Git) completes. */
2676
+ claimWorkerCleanup(runId) {
2677
+ if (this.disposed || this.reclaiming.has(runId) || this.isActive(runId) || this.executions.has(runId))
2678
+ return undefined;
2679
+ this.reclaiming.add(runId);
2680
+ return () => { this.reclaiming.delete(runId); this.workerScratchCleanup.schedule(runId); };
2681
+ }
2276
2682
  /** Scratch belongs to the task, not its current agent process (#515).
2277
2683
  * Terminal status alone is not process-exit proof: an active session or an
2278
2684
  * unfinished private worker generation must retain its files until exit. */
@@ -2282,8 +2688,11 @@ export class RunManager {
2282
2688
  return;
2283
2689
  if (this.active.has(runId) || this.starting.has(runId) || this.queue.includes(runId))
2284
2690
  return;
2285
- if (run.delegation?.role === 'worker' && this.store.readWorkerExecution(runId)?.phase !== 'complete')
2691
+ const privateWorkers = workerEvidenceRunIds(this.dataDir);
2692
+ if (run.delegation?.role === 'worker' || run.delegation?.role === 'invalid' || !privateWorkers || privateWorkers.includes(runId)) {
2693
+ this.workerScratchCleanup.schedule(runId);
2286
2694
  return;
2695
+ }
2287
2696
  removeAgentTmpDir(this.dataDir, runId);
2288
2697
  }
2289
2698
  // ---- usage-limit auto-resume (spec 2026-08-03-auto-resume-after-usage-limit) --------------
@@ -2625,7 +3034,7 @@ export class RunManager {
2625
3034
  async enforceRetention() {
2626
3035
  try {
2627
3036
  const keep = await resolveWorktreeRetention(this.repoRoot);
2628
- await reclaimWorktrees(this.repoRoot, this.store, keep, { claim: (run) => this.claimWorktreeReclaim(run.id) });
3037
+ await reclaimWorktrees(this.repoRoot, this.store, keep, { claim: (run) => this.claimWorktreeReclaim(run.id), previewHost: this.preview });
2629
3038
  }
2630
3039
  catch {
2631
3040
  // retention is best-effort; swallow so terminal transitions never break.
@@ -2691,6 +3100,9 @@ export class RunManager {
2691
3100
  return true;
2692
3101
  }
2693
3102
  cancel(runId) {
3103
+ // Another process's run (#779, plan step 3): only its owner can stop it.
3104
+ if (this.store.writeRefusal(runId))
3105
+ return false;
2694
3106
  if (this.store.getRun(runId)?.delegation?.role === 'worker') {
2695
3107
  const proof = this.store.readWorkerExecution(runId);
2696
3108
  this.store.commitWorkerCancellation(runId);
@@ -2753,13 +3165,38 @@ export class RunManager {
2753
3165
  * forced removal is deleting — nor a removal start under a session that was just admitted.
2754
3166
  */
2755
3167
  claimWorktreeReclaim(runId) {
2756
- if (this.reclaiming.has(runId) || this.isActive(runId))
3168
+ if (this.reclaiming.has(runId) || this.isActive(runId) || this.store.writeRefusal(runId))
2757
3169
  return null;
2758
3170
  const run = this.store.getRun(runId);
2759
- if (!run || !isReclaimable(run, this.store.listRuns()))
3171
+ if (!run || !isReclaimable(run, (id) => this.store.getRun(id)))
2760
3172
  return null;
2761
- this.reclaiming.add(runId);
2762
- return () => { this.reclaiming.delete(runId); };
3173
+ return this.holdForMaintenance([runId]);
3174
+ }
3175
+ /**
3176
+ * Hold `runIds` against Continue, reclaim, publish and branch cleanup here AND in every other
3177
+ * cezar process for the length of an async operation (#779, plan step 3; PR #851 review). An
3178
+ * ownership check alone is a moment: pinning claims each run's family, so another process
3179
+ * cannot claim and Continue it while this one rematerializes, publishes or deletes. Null when
3180
+ * any hold fails (another process took the claim first); nothing stays held then.
3181
+ */
3182
+ holdForMaintenance(runIds) {
3183
+ const pinned = [];
3184
+ for (const id of runIds) {
3185
+ if (!this.store.pin(id, 'maintenance')) {
3186
+ for (const done of pinned)
3187
+ this.store.unpin(done, 'maintenance');
3188
+ return null;
3189
+ }
3190
+ pinned.push(id);
3191
+ }
3192
+ for (const id of runIds)
3193
+ this.reclaiming.add(id);
3194
+ return () => {
3195
+ for (const id of runIds) {
3196
+ this.reclaiming.delete(id);
3197
+ this.store.unpin(id, 'maintenance');
3198
+ }
3199
+ };
2763
3200
  }
2764
3201
  /**
2765
3202
  * Claim every run that owns a branch about to be deleted (issue 08 §B3), or null when any is in
@@ -2774,19 +3211,15 @@ export class RunManager {
2774
3211
  * branch cleanup and Continue off the checkout until the push and `gh` are done with it.
2775
3212
  */
2776
3213
  claimForPublish(runId) {
2777
- if (this.reclaiming.has(runId) || this.isActive(runId))
3214
+ if (this.reclaiming.has(runId) || this.isActive(runId) || this.store.writeRefusal(runId))
2778
3215
  return null;
2779
- this.reclaiming.add(runId);
2780
- return () => { this.reclaiming.delete(runId); };
3216
+ return this.holdForMaintenance([runId]);
2781
3217
  }
2782
3218
  claimForBranchCleanup(runIds) {
2783
3219
  const unfinished = (id) => !['done', 'failed', 'cancelled'].includes(this.store.getRun(id)?.status ?? 'done');
2784
- if (runIds.some((id) => this.reclaiming.has(id) || this.isActive(id) || unfinished(id)))
3220
+ if (runIds.some((id) => this.reclaiming.has(id) || this.isActive(id) || unfinished(id) || this.store.writeRefusal(id)))
2785
3221
  return null;
2786
- for (const id of runIds)
2787
- this.reclaiming.add(id);
2788
- return () => { for (const id of runIds)
2789
- this.reclaiming.delete(id); };
3222
+ return this.holdForMaintenance(runIds);
2790
3223
  }
2791
3224
  /**
2792
3225
  * Fold a queued run's persisted prompt — `run.task` plus everything stacked
@@ -3098,7 +3531,7 @@ export class RunManager {
3098
3531
  if (parent?.delegation?.role !== 'root')
3099
3532
  return [];
3100
3533
  const ids = new Set([...parent.delegation.receipts.map(receipt => receipt.workerId),
3101
- ...this.store.listRuns().filter(run => run.delegation?.role === 'worker' && run.delegation.parentRunId === runId).map(run => run.id)]);
3534
+ ...this.store.listWorkersOf(runId).map(run => run.id)]);
3102
3535
  return parentReadiness(parent, [...ids].map(workerId => ({ workerId, run: this.store.getRun(workerId),
3103
3536
  terminated: this.store.readWorkerExecution(workerId)?.phase === 'complete', result: this.store.getRun(workerId)
3104
3537
  ? this.store.readWorkerResult(runId, workerId) : this.store.readDeletedWorkerResult(runId, workerId),
@@ -3432,7 +3865,7 @@ export class RunManager {
3432
3865
  }
3433
3866
  this.queueWorkerWake(runId);
3434
3867
  }
3435
- else if (!this.recovering)
3868
+ else if (!this.recoveringRun(runId))
3436
3869
  this.flushAgentInputs(runId);
3437
3870
  }
3438
3871
  /** A worker's pending question goes to its owning parent as a conversation request
@@ -3442,11 +3875,8 @@ export class RunManager {
3442
3875
  const worker = this.store.getRun(workerId);
3443
3876
  if (worker?.delegation?.role !== 'worker')
3444
3877
  return;
3445
- const events = this.store.readEvents(workerId);
3446
- const ask = pendingHumanAsk(events);
3447
- if (!ask)
3448
- return;
3449
- if (events.some(event => (event.type === 'worker-question-routed' || event.type === 'worker-question-fallback') && event.askSeq === ask.seq))
3878
+ const ask = this.store.transcriptFacts(workerId).pendingAsk;
3879
+ if (!ask || ask.routedMessageId !== undefined || ask.fallback)
3450
3880
  return;
3451
3881
  const fallback = (reason) => { this.store.appendEvent(workerId, { type: 'worker-question-fallback', askSeq: ask.seq, reason }); };
3452
3882
  const request = askRequestSchema.safeParse({ questions: ask.questions });
@@ -3494,23 +3924,20 @@ export class RunManager {
3494
3924
  answersFallenBackAsk(runId, opts, deferred) {
3495
3925
  if (deferred || !(opts.text?.trim() || opts.images?.length))
3496
3926
  return false;
3497
- const events = this.store.readEvents(runId);
3498
- const ask = pendingHumanAsk(events);
3499
- return !!ask && events.some(event => event.type === 'worker-question-fallback' && event.askSeq === ask.seq);
3927
+ return !!this.store.transcriptFacts(runId).pendingAsk?.fallback;
3500
3928
  }
3501
3929
  /** The worker's pending ask when it went to the parent and has not fallen back to a human. */
3502
3930
  routedAsk(runId) {
3503
3931
  const run = this.store.getRun(runId);
3504
3932
  if (run?.delegation?.role !== 'worker')
3505
3933
  return undefined;
3506
- const events = this.store.readEvents(runId);
3507
- const ask = pendingHumanAsk(events);
3508
- if (!ask || events.some(event => event.type === 'worker-question-fallback' && event.askSeq === ask.seq))
3934
+ // Indexed at append time (#880): the reconcile and every message to a worker ask this.
3935
+ const ask = this.store.transcriptFacts(runId).pendingAsk;
3936
+ if (!ask || ask.fallback || ask.routedMessageId === undefined)
3509
3937
  return undefined;
3510
- const routed = events.find(event => event.type === 'worker-question-routed' && event.askSeq === ask.seq);
3511
3938
  const root = this.store.getRun(run.delegation.parentRunId);
3512
- const message = routed && root?.delegation?.role === 'root'
3513
- ? root.delegation.conversation?.messages.find(entry => entry.id === routed.messageId) : undefined;
3939
+ const message = root?.delegation?.role === 'root'
3940
+ ? root.delegation.conversation?.messages.find(entry => entry.id === ask.routedMessageId) : undefined;
3514
3941
  return message ? { askSeq: ask.seq, message } : undefined;
3515
3942
  }
3516
3943
  /** The parent's reply answers the routed question like a human answer would: through the
@@ -3620,9 +4047,7 @@ export class RunManager {
3620
4047
  }
3621
4048
  /** Routed questions this parent has not answered yet, by asking worker. */
3622
4049
  unansweredQuestions(parentId) {
3623
- return this.store.listRuns().flatMap(run => {
3624
- if (run.delegation?.role !== 'worker' || run.delegation.parentRunId !== parentId)
3625
- return [];
4050
+ return this.store.listWorkersOf(parentId).flatMap(run => {
3626
4051
  const routed = this.routedAsk(run.id);
3627
4052
  const root = this.store.getRun(parentId);
3628
4053
  const answered = root?.delegation?.role === 'root' && root.delegation.conversation?.outcomes.some(outcome => outcome.requestId === routed?.message.id);
@@ -3636,8 +4061,8 @@ export class RunManager {
3636
4061
  if (parent?.delegation?.role !== 'root')
3637
4062
  return;
3638
4063
  const state = parent.delegation.conversation ?? { messages: [], outcomes: [] };
3639
- const routed = this.store.listRuns().flatMap(run => {
3640
- const ask = run.delegation?.role === 'worker' && run.delegation.parentRunId === parentId ? this.routedAsk(run.id) : undefined;
4064
+ const routed = this.store.listWorkersOf(parentId).flatMap(run => {
4065
+ const ask = this.routedAsk(run.id);
3641
4066
  // An accepted reply still on its way to a live worker answers it; no human card beside it.
3642
4067
  const replied = ask && state.outcomes.some(outcome => outcome.requestId === ask.message.id && outcome.status === 'replied');
3643
4068
  return ask && !(replied && ['queued', 'running', 'waiting'].includes(run.status) && !run.stopping) ? [{ workerId: run.id, ...ask }] : [];
@@ -3725,7 +4150,7 @@ export class RunManager {
3725
4150
  for (const root of family) {
3726
4151
  if (root.delegation?.role !== 'root' || !root.delegation.conversation)
3727
4152
  continue;
3728
- const next = reconcileConversationState(root, this.store.listRuns(), now, run => run.delegation?.role === 'worker' ? this.store.readWorkerExecution(run.id)?.phase === 'complete' : !this.isActive(run.id));
4153
+ const next = reconcileConversationState(root, (id) => this.store.getRun(id), now, run => run.delegation?.role === 'worker' ? this.store.readWorkerExecution(run.id)?.phase === 'complete' : !this.isActive(run.id));
3729
4154
  if (next && next !== root.delegation.conversation)
3730
4155
  this.store.commitConversation(root.id, next);
3731
4156
  projectConversationEvents(this.store, this.store.getRun(root.id));
@@ -3742,7 +4167,10 @@ export class RunManager {
3742
4167
  const arm = (delay) => {
3743
4168
  const handle = setTimeout(() => {
3744
4169
  this.workerWaitTimers.delete(key);
4170
+ // This root's own family by name: it may have no live member left by its deadline
4171
+ // (#779), which the pass over live families would skip.
3745
4172
  try {
4173
+ this.reconcileWorkerWaits(root.id);
3746
4174
  this.reconcileWorkerWaits();
3747
4175
  }
3748
4176
  catch {
@@ -3759,7 +4187,41 @@ export class RunManager {
3759
4187
  }
3760
4188
  }
3761
4189
  }
3762
- /** Authority and wait live on disk; these collections are admission/timer caches only. */
4190
+ /** One delegation family: its root, then the root's direct workers (the store's
4191
+ * `parent_run_id` query). Workers cannot delegate, so this never recurses. */
4192
+ familyMembers(rootId) {
4193
+ const root = this.store.getRun(rootId);
4194
+ return [...(root ? [root] : []), ...this.store.listWorkersOf(rootId)];
4195
+ }
4196
+ /** Every member of every family with a live member, each once: the families the store holds
4197
+ * in memory (#779), which is where every delegation sweep that is not about one family looks. */
4198
+ liveFamilyMembers() {
4199
+ const roots = new Set();
4200
+ for (const run of this.store.listRuns()) {
4201
+ if (run.delegation?.role === 'root')
4202
+ roots.add(run.id);
4203
+ else if (run.delegation?.role === 'worker')
4204
+ roots.add(run.delegation.parentRunId);
4205
+ }
4206
+ return [...roots].flatMap(rootId => this.familyMembers(rootId));
4207
+ }
4208
+ /**
4209
+ * Recovery's full pass (#661): every family whose root carries a conversation — settled ones
4210
+ * too, so a projection or outcome a crash interrupted is repaired — then every live family.
4211
+ * Only roots with a conversation are read; a settled family without one has nothing to settle:
4212
+ * every other repair the sweep makes on a finished member (a stale `wait`, a cancelled root's
4213
+ * Finish intent) is a clause of `isLiveRecord`, so open already holds that family.
4214
+ * Everything else asks `reconcileWorkerWaits()`, which covers the live families (#779).
4215
+ */
4216
+ reconcileAllWorkerFamilies() {
4217
+ // Another process's families are its own to repair (#779, plan step 3).
4218
+ for (const rootId of this.store.listConversationRootIds())
4219
+ if (!this.store.writeRefusal(rootId))
4220
+ this.reconcileWorkerWaits(rootId);
4221
+ this.reconcileWorkerWaits();
4222
+ }
4223
+ /** Authority and wait live on disk; these collections are admission/timer caches only. One
4224
+ * family by its root's id, or (no id) every family with a live member. */
3763
4225
  reconcileWorkerWaits(familyRootId) {
3764
4226
  if (this.disposed)
3765
4227
  return;
@@ -3783,20 +4245,21 @@ export class RunManager {
3783
4245
  this.reconcilingWorkers = true;
3784
4246
  this.reconcilingFamily = familyRootId;
3785
4247
  try {
3786
- const inFamily = (run) => familyRootId === undefined || run.id === familyRootId ||
3787
- (run.delegation?.role === 'worker' && run.delegation.parentRunId === familyRootId);
3788
- this.reconcileConversations(this.store.listRuns().filter(inFamily));
3789
- for (const parent of this.store.listRuns().filter(inFamily)) {
4248
+ // One family by name, or every family with a live member (#779): a family nobody in it is
4249
+ // live has nothing left to settle, and its members are no longer in memory.
4250
+ const family = familyRootId === undefined ? this.liveFamilyMembers() : this.familyMembers(familyRootId);
4251
+ this.reconcileConversations(family);
4252
+ for (const parent of family) {
3790
4253
  if (!parent.delegation || parent.delegation.role === 'invalid' || this.historyDeletionPending(parent.id))
3791
4254
  continue;
3792
4255
  if (parent.status === 'cancelled' && parent.delegation.role === 'root' && parent.delegation.finishRequestedAt) {
3793
4256
  this.store.commitRootFinishCancellation(parent.id);
3794
4257
  }
3795
4258
  if (!['queued', 'running', 'waiting'].includes(parent.status)) {
3796
- if (!this.recovering) {
4259
+ if (!this.recoveringRun(parent)) {
3797
4260
  this.withdrawWorkerWait(parent.id);
3798
4261
  this.fallbackRoutedQuestions(parent.id, `parent-${parent.status}`);
3799
- for (const child of this.store.listRuns().filter(child => parent.delegation?.role === 'root' && parent.status !== 'review' && child.delegation?.role === 'worker' && child.delegation.parentRunId === parent.id)) {
4262
+ for (const child of parent.delegation.role === 'root' && parent.status !== 'review' ? this.store.listWorkersOf(parent.id) : []) {
3800
4263
  if (['queued', 'running', 'waiting'].includes(child.status))
3801
4264
  this.cancel(child.id);
3802
4265
  }
@@ -3812,7 +4275,7 @@ export class RunManager {
3812
4275
  if (delivered) {
3813
4276
  // Receipt persistence precedes the live status update. After a crash
3814
4277
  // this is interrupted execution, not generic waiting-run success.
3815
- if (this.recovering && !delivered.inboxClaim?.acknowledgedAt && parent.status === 'waiting' && !this.hasPendingHumanAsk(parent.id)) {
4278
+ if (this.recoveringRun(parent) && !delivered.inboxClaim?.acknowledgedAt && parent.status === 'waiting' && !this.hasPendingHumanAsk(parent.id)) {
3816
4279
  this.store.updateRun(parent.id, { status: 'running', activity: undefined });
3817
4280
  }
3818
4281
  const state = this.active.get(parent.id);
@@ -3829,7 +4292,7 @@ export class RunManager {
3829
4292
  continue;
3830
4293
  }
3831
4294
  const now = new Date().toISOString();
3832
- const outcomes = this.store.listRuns().filter(child => child.delegation?.role === 'worker' && child.delegation.parentRunId === parent.id).flatMap(child => {
4295
+ const outcomes = this.store.listWorkersOf(parent.id).flatMap(child => {
3833
4296
  const outcome = workerOutcome(child, now);
3834
4297
  return outcome && this.store.readWorkerExecution(child.id)?.phase === 'complete' ? [outcome] : [];
3835
4298
  });
@@ -3892,9 +4355,7 @@ export class RunManager {
3892
4355
  } }]);
3893
4356
  }
3894
4357
  for (const outcome of next.outcomes) {
3895
- if (!this.store.readEvents(parent.id).some(event => event.type === 'worker-outcome' &&
3896
- event.waitId === wait.id && event.outcome?.workerId === outcome.workerId &&
3897
- (event.outcome?.revision ?? 0) === (outcome.revision ?? 0))) {
4358
+ if (!this.store.transcriptFacts(parent.id).workerOutcomeKeys.includes(workerOutcomeKey(wait.id, outcome.workerId, outcome.revision ?? 0))) {
3898
4359
  this.store.appendEvent(parent.id, { type: 'worker-outcome', waitId: wait.id, outcome });
3899
4360
  }
3900
4361
  }
@@ -3974,7 +4435,7 @@ export class RunManager {
3974
4435
  this.store.commitAgentInputs(parentId, (run.agentInputs ?? []).map(entry => entry.id === input.id ? input : entry));
3975
4436
  }
3976
4437
  // Rebuild ordinary queued work before appending new wake admissions on restart.
3977
- if (this.recovering)
4438
+ if (this.recoveringRun(run))
3978
4439
  return;
3979
4440
  const state = this.active.get(parentId);
3980
4441
  // A parent's reply to a routed question (#505) is the one message a pending ask admits.
@@ -4058,7 +4519,9 @@ export class RunManager {
4058
4519
  /** Transcript bubbles include refused sends. Only a successful delivery
4059
4520
  * checkpoint can answer the specific ask observed before that send. */
4060
4521
  pendingHumanAskSeq(runId) {
4061
- return pendingHumanAsk(this.store.readEvents(runId))?.seq;
4522
+ // Fallback human gates use the same exact successful-delivery receipt as asks,
4523
+ // without inventing a structured question card or changing its summary. Indexed (#880).
4524
+ return this.store.transcriptFacts(runId).pendingGateSeq;
4062
4525
  }
4063
4526
  hasPendingHumanAsk(runId) {
4064
4527
  return this.pendingHumanAskSeq(runId) !== undefined;
@@ -4190,6 +4653,7 @@ export class RunManager {
4190
4653
  * goes back to the queue so a later session delivers it; never shown as delivered. */
4191
4654
  requeueUnreadAtClose(runId, state, session) {
4192
4655
  this.clearUnreadInputTimer(state);
4656
+ this.clearHeldHumanInputTimer(state);
4193
4657
  // Everything this session accepted but never saw read: steered input, messages bundled
4194
4658
  // behind a human answer, and an opening instruction whose turn never succeeded.
4195
4659
  const opening = state.openingAgentInputId && this.store.getRun(runId)?.agentInputs?.some(input => input.id === state.openingAgentInputId && input.deliveredAt && !input.consumedAt) ? [state.openingAgentInputId] : [];
@@ -4209,7 +4673,11 @@ export class RunManager {
4209
4673
  * run parks as waiting or monitoring. Shared by acknowledgements that land after a
4210
4674
  * turn ended and by the unread-input grace timer (#505). */
4211
4675
  settleIdleBoundary(runId, state, session) {
4212
- if (state.atTurnBoundary !== session || state.pendingHumanAsk || this.workerWait(runId) || this.store.getRun(runId)?.ciWait || this.hasQueuedAgentInputs(runId))
4676
+ // An in-flight acknowledgement still owns this boundary; its .then re-settles.
4677
+ if (this.harnessHoldsHumanInput(state) || state.agentInputFlight)
4678
+ return;
4679
+ const proseGate = state.parkAfterAck?.session === session && state.parkAfterAck.humanGate;
4680
+ if (state.atTurnBoundary !== session || (state.pendingHumanAsk && !proseGate) || this.workerWait(runId) || this.store.getRun(runId)?.ciWait || (!proseGate && this.hasQueuedAgentInputs(runId)))
4213
4681
  return;
4214
4682
  if (state.doneAtBoundary === session) {
4215
4683
  if (this.deferParentCompletion(runId))
@@ -4219,9 +4687,20 @@ export class RunManager {
4219
4687
  session.end();
4220
4688
  }
4221
4689
  else if (state.parkAfterAck?.session === session && !this.waiting.has(runId) && !this.monitoring.has(runId)) {
4690
+ // The nudged turn may finish before its transport ACK (OpenCode HTTP/SSE).
4691
+ // Its turn-end deferred to agentInputFlight; now apply the same policy
4692
+ // before parking, with DONE and queued/accepted input still taking priority.
4693
+ if (!state.parkAfterAck.humanGate && state.currentStepId && this.tryAutonomousNudge(runId, state, state.currentStepId, null)) {
4694
+ appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — status=running (autonomous nudge)');
4695
+ return;
4696
+ }
4697
+ if (state.autonomousNudgePending === session) {
4698
+ appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — status=running (awaiting input readiness)');
4699
+ return;
4700
+ }
4222
4701
  // A wake turn can finish before its HTTP/RPC acknowledgement. Retiring
4223
4702
  // that wait must release its completed turn, not invent another turn.
4224
- if ((state.parkAfterAck.monitoring || this.hasLiveWorkers(runId)) && !this.parentCompletionAttention(runId)) {
4703
+ if ((state.parkAfterAck.monitoring || (!state.parkAfterAck.humanGate && this.hasLiveWorkers(runId))) && !this.parentCompletionAttention(runId)) {
4225
4704
  this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
4226
4705
  if (state.currentStepId)
4227
4706
  this.store.updateStep(runId, state.currentStepId, { status: 'running' });
@@ -4229,6 +4708,16 @@ export class RunManager {
4229
4708
  this.clearIdleTimer(state);
4230
4709
  this.armMonitoringWakeTimer(runId, state);
4231
4710
  }
4711
+ else if (state.currentStepId && this.maybeFinalMessageNudge(runId, state, state.currentStepId, {
4712
+ ask: false,
4713
+ humanGate: !!state.parkAfterAck.humanGate,
4714
+ monitoring: !!state.parkAfterAck.monitoring,
4715
+ silentTail: !!state.parkAfterAck.silentTail,
4716
+ alreadyNudged: !!state.parkAfterAck.alreadyFinalMessageNudged,
4717
+ })) {
4718
+ appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — status=running (final message nudge)');
4719
+ return;
4720
+ }
4232
4721
  else {
4233
4722
  this.store.updateRun(runId, { status: 'waiting', activity: undefined });
4234
4723
  if (state.currentStepId)
@@ -4237,6 +4726,8 @@ export class RunManager {
4237
4726
  this.armIdleTimer(runId, state);
4238
4727
  }
4239
4728
  this.waiting.add(runId);
4729
+ if (state.autonomous)
4730
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${this.monitoring.has(runId) ? 'monitoring' : 'waiting'}`);
4240
4731
  this.releaseSlot();
4241
4732
  }
4242
4733
  }
@@ -4246,12 +4737,12 @@ export class RunManager {
4246
4737
  armUnreadInputTimer(runId, state) {
4247
4738
  this.clearUnreadInputTimer(state);
4248
4739
  const session = state.session;
4249
- if (!session?.open || !this.harnessOwesInput(state) || state.agentInputFlight)
4740
+ if (!session?.open || state.pendingHumanAsk || !this.harnessOwesInput(state) || state.agentInputFlight)
4250
4741
  return;
4251
4742
  const timer = setTimeout(() => {
4252
4743
  state.unreadInputTimer = undefined;
4253
4744
  if (this.disposed || this.active.get(runId) !== state || state.session !== session || !session.open || state.cancelled ||
4254
- state.agentInputFlight || !this.harnessOwesInput(state))
4745
+ state.pendingHumanAsk || state.agentInputFlight || !this.harnessOwesInput(state))
4255
4746
  return;
4256
4747
  const ids = [...state.unreadInputIds];
4257
4748
  const retried = ids.filter(id => state.unreadRetried?.has(id));
@@ -4263,7 +4754,7 @@ export class RunManager {
4263
4754
  this.store.commitAgentInputsUnconfirmed(runId, retried);
4264
4755
  }
4265
4756
  catch { /* receipt stays awaiting; a restart replays it */ }
4266
- this.store.appendEvent(runId, { type: 'note', message: `delivered ${retried.length} message${retried.length === 1 ? '' : 's'}; the agent did not confirm reading ${retried.length === 1 ? 'it' : 'them'}` });
4757
+ this.store.appendEvent(runId, { type: 'note', message: unreadInputUnconfirmedNote(retried.length) });
4267
4758
  }
4268
4759
  if (fresh.length) {
4269
4760
  state.unreadRetried ??= new Set();
@@ -4287,14 +4778,85 @@ export class RunManager {
4287
4778
  clearTimeout(state.unreadInputTimer);
4288
4779
  state.unreadInputTimer = undefined;
4289
4780
  }
4781
+ /** #486: a turn-end deferred close/park only because the harness still held a human follow-up. */
4782
+ armHeldHumanInputTimer(runId, state) {
4783
+ this.clearHeldHumanInputTimer(state, false);
4784
+ const session = state.session;
4785
+ if (!session?.open || !this.harnessHoldsHumanInput(state)) {
4786
+ state.heldHumanInputSince = undefined;
4787
+ return;
4788
+ }
4789
+ state.heldHumanInputSince ??= Date.now();
4790
+ const timer = setTimeout(() => {
4791
+ state.heldHumanInputTimer = undefined;
4792
+ if (this.disposed || this.active.get(runId) !== state || state.session !== session || !session.open ||
4793
+ state.cancelled || state.idleClosed || state.atTurnBoundary !== session)
4794
+ return;
4795
+ if (this.harnessHoldsHumanInput(state)) {
4796
+ // An in-flight ACK still owns this boundary; its .then re-settles.
4797
+ if (state.agentInputFlight) {
4798
+ this.armHeldHumanInputTimer(runId, state);
4799
+ return;
4800
+ }
4801
+ if (Date.now() - (state.heldHumanInputSince ?? 0) >= this.unreadInputGraceMs) {
4802
+ const held = session.heldHumanInputCount?.() ?? 0;
4803
+ this.store.appendEvent(runId, {
4804
+ type: 'note',
4805
+ message: held > 0
4806
+ ? unreadInputUnconfirmedNote(held)
4807
+ : 'delivered held messages; the agent did not confirm reading them',
4808
+ });
4809
+ state.heldHumanInputExpired = true;
4810
+ this.settleIdleBoundary(runId, state, session);
4811
+ return;
4812
+ }
4813
+ this.armHeldHumanInputTimer(runId, state);
4814
+ return;
4815
+ }
4816
+ this.settleIdleBoundary(runId, state, session);
4817
+ }, HELD_HUMAN_INPUT_RECHECK_MS);
4818
+ timer.unref?.();
4819
+ state.heldHumanInputTimer = timer;
4820
+ }
4821
+ clearHeldHumanInputTimer(state, resetClock = true) {
4822
+ if (state.heldHumanInputTimer)
4823
+ clearTimeout(state.heldHumanInputTimer);
4824
+ state.heldHumanInputTimer = undefined;
4825
+ if (resetClock) {
4826
+ state.heldHumanInputSince = undefined;
4827
+ state.heldHumanInputExpired = undefined;
4828
+ }
4829
+ }
4290
4830
  /** #505: the harness accepted input this session has not reported reading yet. */
4291
4831
  harnessOwesInput(state) {
4292
4832
  return !!state?.unreadInputIds?.size;
4293
4833
  }
4834
+ /** #486: a human follow-up is still queued in the live session and will still be delivered. */
4835
+ harnessHoldsHumanInput(state) {
4836
+ return !state.heldHumanInputExpired && !!state.session?.open && state.session.holdsHumanInput();
4837
+ }
4294
4838
  /** A callback from an old/replaced/disposed session carries no authority. */
4295
4839
  handleAgentInputReady(runId, state, session) {
4296
- if (session && this.active.get(runId) === state && state.session === session)
4297
- this.flushAgentInputs(runId);
4840
+ if (!session || this.disposed || this.active.get(runId) !== state || state.session !== session ||
4841
+ !session.open || state.idleClosed || state.cancelled || state.finishRequested || state.agentInputError || state.agentSessionError)
4842
+ return;
4843
+ // Durable input always gets the first opportunity. Only the same refused,
4844
+ // completed boundary may retry; ordinary/native question parks are not wakes.
4845
+ if (this.flushAgentInputs(runId))
4846
+ return;
4847
+ // #486: held human input drained at this still-current boundary with no new turn.
4848
+ // Runners fire onAgentInputReady from the transport ACK finally/callback while
4849
+ // agentInputFlight is still set; settling here parks before the ACK .then can nudge.
4850
+ if (state.atTurnBoundary === session && !this.harnessHoldsHumanInput(state) &&
4851
+ !state.agentInputFlight && !this.harnessOwesInput(state)) {
4852
+ this.settleIdleBoundary(runId, state, session);
4853
+ }
4854
+ if (state.autonomousNudgePending !== session || state.atTurnBoundary !== session ||
4855
+ state.doneAtBoundary === session || !state.currentStepId)
4856
+ return;
4857
+ if (this.tryAutonomousNudge(runId, state, state.currentStepId, null)) {
4858
+ appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — status=running (autonomous nudge)');
4859
+ }
4298
4860
  }
4299
4861
  /** Reservation is synchronous; only an acknowledged transport may checkpoint delivery. */
4300
4862
  submitAgentInput(runId, state, content, inputIds = []) {
@@ -4524,6 +5086,8 @@ export class RunManager {
4524
5086
  /** Restore active lifecycle/accounting when either Cezar or the backend
4525
5087
  * resumes work in a parked session. */
4526
5088
  resumeParkedRun(runId, state) {
5089
+ this.clearHeldHumanInputTimer(state);
5090
+ state.autonomousNudgePending = undefined;
4527
5091
  state.atTurnBoundary = undefined;
4528
5092
  state.doneAtBoundary = undefined;
4529
5093
  state.parkAfterAck = undefined;
@@ -4595,6 +5159,9 @@ export class RunManager {
4595
5159
  : this.submitAgentInput(runId, state, deliverable);
4596
5160
  if (delivered) {
4597
5161
  if (userAuthored) {
5162
+ state.finalMessageNudged = undefined;
5163
+ state.finalMessageNudgeParked = undefined;
5164
+ state.finalMessageNudgeSawContent = undefined;
4598
5165
  if (ciHumanMessage)
4599
5166
  this.store.commitQueuedMessageDelivery(runId, ciHumanMessage.id);
4600
5167
  if (answeringAskSeq !== undefined) {
@@ -4682,6 +5249,13 @@ export class RunManager {
4682
5249
  const current = steps.findIndex((step) => step.id === run.currentStepId);
4683
5250
  return current < 0 || current < steps.length - 1;
4684
5251
  }
5252
+ /** A clean process exit cannot complete a workflow still blocked on a question.
5253
+ * Keep its existing durable waiting step so Continue can resume the tail. */
5254
+ unansweredWorkflowAsk(runId, state) {
5255
+ const run = this.store.getRun(runId);
5256
+ return !state.finishRequested && !!run && this.hasPendingHumanAsk(runId) &&
5257
+ this.waitingBeforeFinalWorkflowStep(run);
5258
+ }
4685
5259
  /** Locate the original workflow step whose closed session a synthetic Continue is resuming. */
4686
5260
  waitingWorkflowStepIndex(run) {
4687
5261
  const steps = run.workflowDef?.steps ?? run.steps.filter(step => !isSyntheticContinuation(run, step));
@@ -4727,6 +5301,26 @@ export class RunManager {
4727
5301
  /** Restart recovery may discover several interrupted tasks at once. Those
4728
5302
  * continuations are queued; an explicit user Continue remains immediate. */
4729
5303
  deferForCapacity = false, conversation) {
5304
+ // Another process's run (#779, plan step 3) is its owner's to continue.
5305
+ const refusal = this.store.writeRefusal(runId);
5306
+ if (refusal)
5307
+ return { ok: false, error: refusal };
5308
+ // A finished run is loaded and held for the whole admission (#779): every read below sees
5309
+ // the record its own writes change, exactly as for a live run. Admission makes it live
5310
+ // (queued or running) or refuses it; either way the `continue` pin ends here.
5311
+ // The check above is a moment: another process may claim the run before the hold lands
5312
+ // (PR #851 review). Holding it is what claims it, so a failed hold refuses the Continue.
5313
+ if (!this.store.pin(runId, 'continue')) {
5314
+ return { ok: false, error: this.store.getRun(runId) ? this.store.writeRefusal(runId) ?? RUN_IN_USE_ELSEWHERE : 'not found' };
5315
+ }
5316
+ try {
5317
+ return this.admitContinuation(runId, opts, deferForCapacity, conversation);
5318
+ }
5319
+ finally {
5320
+ this.store.unpin(runId, 'continue');
5321
+ }
5322
+ }
5323
+ admitContinuation(runId, opts, deferForCapacity, conversation) {
4730
5324
  if (agentModelsLocked(this.repoRoot) && (opts.model?.trim() || opts.effort?.trim())) {
4731
5325
  return { ok: false, error: AGENT_MODELS_LOCKED_ERROR };
4732
5326
  }
@@ -4967,6 +5561,7 @@ export class RunManager {
4967
5561
  this.store.updateRun(runId, acceptedPatch);
4968
5562
  this.store.flush();
4969
5563
  }
5564
+ this.continuationGenerations.set(runId, Symbol());
4970
5565
  // Keep fresh viewable images even if persistence failed; recovery uses saved URLs.
4971
5566
  const images = contentBlocksOf(opts.images ?? []).filter((block) => block.type === 'image');
4972
5567
  if (deferForCapacity) {
@@ -5028,6 +5623,8 @@ export class RunManager {
5028
5623
  this.workerIdentity(runId);
5029
5624
  const record = this.store.getRun(runId);
5030
5625
  let cwd;
5626
+ // Retention (#483) rebuilt a reclaimed directory: a bare checkout that needs setup again (#917).
5627
+ let rematerialized = false;
5031
5628
  if (record?.delegation?.role === 'worker') {
5032
5629
  try {
5033
5630
  if (record.delegation.destroy)
@@ -5047,7 +5644,7 @@ export class RunManager {
5047
5644
  }
5048
5645
  }
5049
5646
  else {
5050
- await rematerializeReclaimedWorktree(this.repoRoot, this.store, runId);
5647
+ rematerialized = await rematerializeReclaimedWorktree(this.repoRoot, this.store, runId);
5051
5648
  cwd = record?.worktreePath && existsSync(record.worktreePath) ? record.worktreePath : this.repoRoot;
5052
5649
  }
5053
5650
  if (this.store.getRun(runId)?.stopping) {
@@ -5059,8 +5656,14 @@ export class RunManager {
5059
5656
  // The env is a live ceiling: a run created while the inbox was on must not keep writing
5060
5657
  // follow-ups after it is switched off.
5061
5658
  const generateFollowups = followupsEnabled() && record?.generateFollowups !== false;
5062
- const state = { delegationSettings: undefined, revokeDelegation: undefined, cancelled: false, interrupt: () => undefined, cwd, pendingHumanAsk: this.hasPendingHumanAsk(runId) };
5063
- this.active.set(runId, state);
5659
+ // Continue/recovery constructs a second ActiveRun: hydrate the durable mode here too.
5660
+ const state = {
5661
+ delegationSettings: undefined, revokeDelegation: undefined,
5662
+ cancelled: false, interrupt: () => undefined, cwd,
5663
+ pendingHumanAsk: this.hasPendingHumanAsk(runId),
5664
+ autonomous: record?.autonomous === true, autoContinues: 0,
5665
+ };
5666
+ this.activate(runId, state);
5064
5667
  this.starting.delete(runId);
5065
5668
  if (state.cwd === this.repoRoot) {
5066
5669
  if (repositoryRootLockDisabled()) {
@@ -5089,6 +5692,9 @@ export class RunManager {
5089
5692
  this.armAutosave(runId, state);
5090
5693
  if (record)
5091
5694
  seedHandoffFile(this.dataDir, record); // idempotent — normally already there
5695
+ // Worktree setup (#917) for a directory retention rebuilt, or one a restart interrupted.
5696
+ const setupNote = await this.prepareWorktree(runId, state, (await loadConfig(this.repoRoot)).worktreeSetup, (event) => this.store.appendEvent(runId, event), rematerialized ? 'rematerialized' : 'resume');
5697
+ // A Stop during setup settles below, at the prelaunch cancellation check before any spawn.
5092
5698
  // Registry snapshot for `/skill` expansion. `execute` loads this for the workflow's own
5093
5699
  // sessions; a continuation builds its OWN ActiveRun, and without this the resumed session
5094
5700
  // expanded against an empty registry and leaked `/om-...` verbatim to the backend, which
@@ -5141,6 +5747,7 @@ export class RunManager {
5141
5747
  state.pendingHumanAsk = openingAnswerAskSeq === undefined && this.hasPendingHumanAsk(runId);
5142
5748
  let turnText = '';
5143
5749
  let completedAssistantText = '';
5750
+ let turnTail = 'none';
5144
5751
  let sawClaudeScheduleWakeup = false;
5145
5752
  let sessionError;
5146
5753
  const sink = this.makeUiSink(runId, stepId);
@@ -5158,6 +5765,8 @@ export class RunManager {
5158
5765
  }
5159
5766
  if (event.type === 'text') {
5160
5767
  this.clearUnreadInputTimer(state); // real content: the harness is working
5768
+ this.clearHeldHumanInputTimer(state);
5769
+ this.noteFinalMessageNudgeContent(runId, state, event);
5161
5770
  turnText = appendTurnText(turnText, event.text);
5162
5771
  const text = stripAskMarker(stripTaskMarkers(stripMonitoringMarker(stripDoneMarker(event.text))), false);
5163
5772
  if (text)
@@ -5168,8 +5777,11 @@ export class RunManager {
5168
5777
  this.retireUnreadInputs(runId, state, event.inputIds, true);
5169
5778
  return;
5170
5779
  }
5171
- if (event.type === 'tool-call' || event.type === 'tool-result')
5780
+ if (event.type === 'tool-call' || event.type === 'tool-result') {
5172
5781
  this.clearUnreadInputTimer(state);
5782
+ this.clearHeldHumanInputTimer(state);
5783
+ this.noteFinalMessageNudgeContent(runId, state, event);
5784
+ }
5173
5785
  this.store.appendEvent(runId, { ...event, stepId });
5174
5786
  if (event.type === 'cost')
5175
5787
  this.recordReportedCost(runId, stepId, state, backend, event.usd);
@@ -5244,36 +5856,45 @@ export class RunManager {
5244
5856
  sink.flushAll();
5245
5857
  this.trackTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
5246
5858
  const sessionOpen = !state.cancelled && state.session?.open;
5247
- const done = sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
5248
- // `CEZ:ASK` → the user is genuinely blocked; wins over `CEZ:MONITORING`
5249
- // (a pending question is always attention), loses to `CEZ:DONE` (#473).
5250
- const askTurn = resolveAskTurn(turnText, completedAssistantText, Boolean(sessionOpen) && !done);
5251
- const { ask, notes: askNotes } = askTurn;
5252
- discardQueuedMessagesOnAsk(state.session, askTurn);
5253
- const monitoring = !this.parentCompletionAttention(runId) &&
5254
- sessionOpen &&
5255
- !done &&
5256
- !ask &&
5257
- !state.pendingHumanAsk &&
5258
- (MONITORING_MARKER_RE.test(turnText.trimEnd()) || sawClaudeScheduleWakeup || this.hasLiveWorkers(runId));
5859
+ const turn = classifyTurnEnd(turnText, completedAssistantText, {
5860
+ sessionOpen: !!sessionOpen, interactive: true, pendingHumanAsk: !!state.pendingHumanAsk,
5861
+ completionAttention: this.parentCompletionAttention(runId), scheduledWake: sawClaudeScheduleWakeup,
5862
+ liveWorkers: this.hasLiveWorkers(runId),
5863
+ dependencyWait: !!(this.workerWait(runId) || this.store.getRun(runId)?.ciWait),
5864
+ });
5865
+ const { done, monitoring, humanGate, ask, notes: askNotes } = turn;
5866
+ discardQueuedMessagesOnAsk(state.session, turn);
5867
+ const silentTail = turnTail === 'silent';
5868
+ const alreadyFinalMessageNudged = state.finalMessageNudged === state.session;
5869
+ state.finalMessageNudged = undefined;
5259
5870
  turnText = '';
5260
5871
  completedAssistantText = '';
5872
+ turnTail = 'none';
5261
5873
  sawClaudeScheduleWakeup = false;
5262
5874
  for (const note of askNotes)
5263
5875
  this.store.appendEvent(runId, { type: 'note', ...note, stepId });
5264
5876
  this.store.updateRun(runId, { invalidAsk: !ask && askNotes.length > 0 ? true : undefined });
5265
- if (ask)
5877
+ // Only a newly parsed portable ASK can be overridden. Do this before
5878
+ // prepareHumanAsk latches it; an existing/native ask still blocks the helper.
5879
+ let autoContinued = !!ask && !!sessionOpen && this.tryAutonomousNudge(runId, state, stepId, ask);
5880
+ if (ask && !autoContinued)
5266
5881
  this.prepareHumanAsk(runId, state);
5267
- const ciWaitParked = !!sessionOpen && this.parkCiWait(runId, state);
5882
+ if (humanGate && !ask && !state.pendingHumanAsk)
5883
+ this.prepareProseHumanGate(runId, state);
5884
+ const ciWaitParked = !humanGate && !!sessionOpen && this.parkCiWait(runId, state);
5268
5885
  const completionBlocked = !ciWaitParked && !!done && this.deferParentCompletion(runId);
5269
- const workerWaitParked = ciWaitParked || completionBlocked || (!!sessionOpen && this.parkWorkerWait(runId, state));
5886
+ const workerWaitParked = ciWaitParked || completionBlocked || (!humanGate && !!sessionOpen && this.parkWorkerWait(runId, state));
5270
5887
  state.doneAtBoundary = done ? state.session : undefined;
5271
- state.parkAfterAck = sessionOpen && state.session ? { session: state.session, monitoring: !!monitoring } : undefined;
5888
+ state.parkAfterAck = sessionOpen && state.session ? { session: state.session, monitoring, humanGate, silentTail, alreadyFinalMessageNudged } : undefined;
5272
5889
  // #505: input the harness accepted but has not read yet runs as its next turn.
5273
5890
  // An in-flight submission is settled by its acknowledgement (parkAfterAck), not here.
5274
- const agentInputDelivered = !ask && !workerWaitParked && (this.flushAgentInputs(runId) || this.harnessOwesInput(state) || !!state.agentInputFlight);
5891
+ const agentInputDelivered = autoContinued || (!ask && !workerWaitParked && (this.flushAgentInputs(runId) || (!humanGate && this.harnessOwesInput(state)) || !!state.agentInputFlight || this.harnessHoldsHumanInput(state)));
5275
5892
  if (!ask && !workerWaitParked)
5276
5893
  this.armUnreadInputTimer(runId, state);
5894
+ if (!ask && !workerWaitParked && this.harnessHoldsHumanInput(state))
5895
+ this.armHeldHumanInputTimer(runId, state);
5896
+ else
5897
+ this.clearHeldHumanInputTimer(state);
5277
5898
  if (state.agentInputError)
5278
5899
  return;
5279
5900
  if (done && !workerWaitParked && !state.pendingHumanAsk && !agentInputDelivered && !state.agentInputFlight && !this.hasQueuedAgentInputs(runId)) {
@@ -5284,26 +5905,13 @@ export class RunManager {
5284
5905
  return;
5285
5906
  }
5286
5907
  if (sessionOpen && !agentInputDelivered && !workerWaitParked && !this.workerWakeAdmitted.has(runId)) {
5287
- // Autonomous (#autonomous): never hand the ball back to the user. Nudge the agent to
5288
- // keep going (bounded by MAX_AUTO_CONTINUES) instead of parking at `waiting`.
5289
- const autoContinued = state.autonomous &&
5290
- !this.parentCompletionPending(runId) &&
5291
- !state.pendingHumanAsk &&
5292
- !this.hasQueuedAgentInputs(runId) &&
5293
- (state.autoContinues ?? 0) < MAX_AUTO_CONTINUES &&
5294
- !state.cancelled &&
5295
- (() => {
5296
- const sent = this.submitAgentInput(runId, state, [{ type: 'text', text: AUTONOMOUS_NUDGE }]);
5297
- if (!sent)
5298
- return false;
5299
- state.autoContinues = (state.autoContinues ?? 0) + 1;
5300
- this.store.appendEvent(runId, {
5301
- type: 'note',
5302
- message: `autonomous — continuing without pausing (${state.autoContinues}/${MAX_AUTO_CONTINUES})`,
5303
- });
5304
- return true;
5305
- })();
5306
- if (!autoContinued) {
5908
+ if (!ask && !humanGate)
5909
+ autoContinued = this.tryAutonomousNudge(runId, state, stepId, null);
5910
+ if (!autoContinued)
5911
+ autoContinued = this.maybeFinalMessageNudge(runId, state, stepId, {
5912
+ ask: !!ask, humanGate, monitoring, silentTail, alreadyNudged: alreadyFinalMessageNudged,
5913
+ });
5914
+ if (!autoContinued && state.autonomousNudgePending !== state.session) {
5307
5915
  // `CEZ:ASK` → park `waiting` (attention) AND surface the structured
5308
5916
  // question as an ask card (#473). `CEZ:MONITORING` or Claude's native
5309
5917
  // `ScheduleWakeup` → non-attention `running`/`activity:'monitoring'`
@@ -5341,7 +5949,7 @@ export class RunManager {
5341
5949
  if (this.store.getRun(runId)?.autoResumeAttempts !== undefined) {
5342
5950
  this.store.updateRun(runId, { autoResumeAttempts: undefined });
5343
5951
  }
5344
- appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${agentInputDelivered ? 'running' : monitoring ? 'monitoring' : sessionOpen ? 'waiting' : 'running'}`);
5952
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${autoContinued ? (state.finalMessageNudged === state.session ? 'running (final message nudge)' : 'running (autonomous nudge)') : state.autonomousNudgePending && state.autonomousNudgePending === state.session ? 'running (awaiting input readiness)' : agentInputDelivered ? 'running' : monitoring ? 'monitoring' : sessionOpen ? 'waiting' : 'running'}`);
5345
5953
  }
5346
5954
  };
5347
5955
  // Backend + model come off the record: the run's current backend by default, or the
@@ -5466,13 +6074,70 @@ export class RunManager {
5466
6074
  effort: continueEffort, agentProfile: continueProfile.profileId, accountBinding: continueProfile.accountBinding,
5467
6075
  systemPrompt: record?.systemPrompt, allowedTools: grants.allowedTools, bashAllowlist: grants.bashAllowlist };
5468
6076
  const runner = createRunner(continueBackend);
6077
+ // Re-expand the continued step's skill from the current registry (#790) BEFORE
6078
+ // provisionSession. `await materializeSkillDir` used to sit after the pre-launch
6079
+ // revalidation, so Stop during that await still launched with revoked tools.
6080
+ // `record.systemPrompt` is only the extra prompt; the opening session put
6081
+ // `skillSystemPrompt(skill)` in front. Skip that prefix only when this
6082
+ // session resumes into its own thread on an in-thread runner — those
6083
+ // already carry the playbook in history. A missing skill degrades to
6084
+ // today's extra-only prompt with one lifecycle warning; in-thread skips
6085
+ // do not warn. Workers still inherit only the extra prompt (#778).
6086
+ const skipSkillOnResume = sessionId !== undefined && runner.systemPromptOnResume === 'in-thread';
6087
+ let continuationSkillPrompt;
6088
+ let fallbackSkillPrompt;
6089
+ const continuedSkillName = toolsStep?.skill;
6090
+ if (continuedSkillName) {
6091
+ let skill = (state.skills ?? []).find((candidate) => candidate.name === continuedSkillName);
6092
+ if (!skill) {
6093
+ // Boot recovery can outrun the async team-skills cache (`getTeamSkillsCached`
6094
+ // returns [] until `initialTeamSkillsLoad` resolves). Wait briefly, then
6095
+ // re-discover; never hang Continue on the network. The on-disk clone's
6096
+ // list arrives before the passive fetch (#859), so stop as soon as it
6097
+ // names this skill instead of waiting out a slow first fetch of the day.
6098
+ try {
6099
+ await new Promise((resolve) => {
6100
+ const timeout = setTimeout(resolve, 1_500);
6101
+ timeout.unref?.();
6102
+ const settle = () => { clearTimeout(timeout); resolve(); };
6103
+ waitForTeamSkills(this.repoRoot).then(settle, settle);
6104
+ waitForCachedTeamSkills(this.repoRoot).then((cached) => {
6105
+ if (cached.some((candidate) => candidate.name === continuedSkillName))
6106
+ settle();
6107
+ }, () => undefined);
6108
+ });
6109
+ state.skills = await discoverSkills(this.repoRoot).catch(() => state.skills ?? []);
6110
+ skill = (state.skills ?? []).find((candidate) => candidate.name === continuedSkillName);
6111
+ }
6112
+ catch {
6113
+ // Best-effort — a slow or failing team-skills load must not fail Continue.
6114
+ }
6115
+ }
6116
+ if (skill) {
6117
+ const playbook = skillSystemPrompt(skill);
6118
+ if (skipSkillOnResume)
6119
+ fallbackSkillPrompt = playbook;
6120
+ else
6121
+ continuationSkillPrompt = playbook;
6122
+ if (skill.source === 'team' && skill.team?.dir) {
6123
+ // Rematerialize into a #483 rematerialized worktree; do not note it on Continue.
6124
+ await materializeSkillDir(state.cwd, skill).catch(() => false);
6125
+ }
6126
+ }
6127
+ else if (!skipSkillOnResume) {
6128
+ this.store.appendEvent(runId, {
6129
+ type: 'lifecycle',
6130
+ message: `skill /${continuedSkillName} is not in the skill registry — its instructions were not re-sent to the continued session`,
6131
+ });
6132
+ }
6133
+ }
5469
6134
  state.currentStepId = stepId;
5470
6135
  this.beginUsageInvocation(runId, state, stepId);
5471
6136
  // A continuation's opening message becomes the session's `userPrompt` and never passes
5472
6137
  // through `deliverMessage`, so it needs the SAME delivery-only `/skill` rewrite the
5473
6138
  // live path applies (#811). Delivery-only: the `user-message` event above already
5474
6139
  // persisted the user's original text, and the transcript must keep showing that.
5475
- const openingPrompt = expandRegistrySlashSkillText(prompt, state.skills ?? []);
6140
+ const openingPrompt = expandRegistrySlashSkillText(prompt, state.skills ?? []) + (setupNote ? `\n\n${setupNote}` : '');
5476
6141
  state.agentSessionError = undefined;
5477
6142
  let session;
5478
6143
  if (this.workerExecutionStopped(runId)) {
@@ -5506,11 +6171,17 @@ export class RunManager {
5506
6171
  state.unreadInputIds = new Set();
5507
6172
  state.consumedBeforeAck = new Set();
5508
6173
  try {
6174
+ const continuationTail = composeSystemPrompt(record?.systemPrompt, delegation?.instructions, artifactInstructions(continueProfile.env.CEZ_ARTIFACTS_DIR), generateFollowups ? HANDOFF_INSTRUCTIONS : HANDOFF_ONLY_INSTRUCTIONS);
5509
6175
  session = startManagedSession(runner, {
5510
- // The Continue step is a fresh agent session on the same run — the
5511
- // run's extra system prompt (already resolved at execute time and
5512
- // echoed on the record) rides along with the handoff contract.
5513
- systemPrompt: composeSystemPrompt(record?.systemPrompt, delegation?.instructions, artifactInstructions(continueProfile.env.CEZ_ARTIFACTS_DIR), generateFollowups ? HANDOFF_INSTRUCTIONS : HANDOFF_ONLY_INSTRUCTIONS),
6176
+ // Skill body (when resent), then the run's extra system prompt, then
6177
+ // the handoff contract — the same order as the opening session (#790).
6178
+ // In-thread resume omits the skill from systemPrompt; OpenCode's 404
6179
+ // fallback uses resumeFallbackSystemPrompt so the fresh session still
6180
+ // gets the playbook.
6181
+ systemPrompt: composeSystemPrompt(continuationSkillPrompt, continuationTail),
6182
+ ...(skipSkillOnResume ? {
6183
+ resumeFallbackSystemPrompt: composeSystemPrompt(fallbackSkillPrompt, continuationTail),
6184
+ } : {}),
5514
6185
  userPrompt: attachments.length
5515
6186
  ? `${openingPrompt}\n\n${pastedAttachmentsText(attachments)}`
5516
6187
  : openingPrompt,
@@ -5530,12 +6201,14 @@ export class RunManager {
5530
6201
  }, onEvent, {
5531
6202
  onUiEvent: (event) => {
5532
6203
  completedAssistantText = appendCompletedAssistantText(completedAssistantText, event);
6204
+ turnTail = trackTurnTail(turnTail, event);
5533
6205
  if (event.type === 'turn.started')
5534
6206
  this.recordOpeningAccepted(runId, state);
5535
6207
  this.handleRunnerUiEvent(runId, state, sink, event);
5536
6208
  },
5537
6209
  onAgentInputReady: () => this.handleAgentInputReady(runId, state, session),
5538
6210
  onAgentInputConsumed: (ids) => this.handleAgentInputConsumed(runId, state, session, ids),
6211
+ onPidChange: (pid) => { registerRunProcess(runId, pid); this.recordWorkerProcess(runId, pid); },
5539
6212
  });
5540
6213
  }
5541
6214
  catch (error) {
@@ -5580,7 +6253,7 @@ export class RunManager {
5580
6253
  this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
5581
6254
  appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=cancelled`);
5582
6255
  }
5583
- else if (!state.idleClosed) {
6256
+ else if (!state.idleClosed && !this.unansweredWorkflowAsk(runId, state)) {
5584
6257
  this.store.updateStep(runId, stepId, { status: 'done', finishedAt: finishedAt() });
5585
6258
  this.store.appendEvent(runId, { type: 'step-end', stepId, status: 'done' });
5586
6259
  const completed = this.store.getRun(runId);
@@ -5589,7 +6262,10 @@ export class RunManager {
5589
6262
  const waitingStep = completed.workflowDef.steps[waitingIndex];
5590
6263
  this.store.updateStep(runId, waitingStep.id, { status: 'done', finishedAt: finishedAt() });
5591
6264
  this.store.appendEvent(runId, { type: 'step-end', stepId: waitingStep.id, status: 'done' });
5592
- if (waitingIndex < completed.workflowDef.steps.length - 1) {
6265
+ // Finish accepts this resumed step before the async settlement gate,
6266
+ // but must not launch the remaining workflow. A later cancellation
6267
+ // may cancel still-live steps, never this completed one (R30).
6268
+ if (!state.finishRequested && waitingIndex < completed.workflowDef.steps.length - 1) {
5593
6269
  resumeWorkflow = {
5594
6270
  workflow: completed.workflowDef,
5595
6271
  startAt: waitingIndex + 1,
@@ -5622,7 +6298,7 @@ export class RunManager {
5622
6298
  }
5623
6299
  }
5624
6300
  else {
5625
- await this.settleIdleClosedWorker(runId);
6301
+ await this.settleIdleClosedRun(runId, state);
5626
6302
  }
5627
6303
  }
5628
6304
  catch (err) {
@@ -5696,7 +6372,7 @@ export class RunManager {
5696
6372
  autonomous: input.autonomous === true,
5697
6373
  autoContinues: 0,
5698
6374
  };
5699
- this.active.set(runId, state);
6375
+ this.activate(runId, state);
5700
6376
  this.starting.delete(runId);
5701
6377
  const emit = (event) => this.store.appendEvent(runId, event);
5702
6378
  // Resolve the agent backend for this run: the task choice (GUI) wins over
@@ -5872,6 +6548,9 @@ export class RunManager {
5872
6548
  const seeded = this.store.getRun(runId);
5873
6549
  if (seeded)
5874
6550
  seedHandoffFile(this.dataDir, seeded);
6551
+ // Worktree setup (#917): before the first step, so the agent starts in a prepared tree. Its
6552
+ // note rides the first agent step's opening message only, like the task's attachments.
6553
+ let startSetupNote = await this.prepareWorktree(runId, state, config.worktreeSetup, emit, 'start');
5875
6554
  const skills = await discoverSkills(this.repoRoot);
5876
6555
  // Every ActiveRun construction site must carry the registry — `runContinuation` builds
5877
6556
  // its own, and the one that skipped this leaked raw `/skill` text to the backend (#811).
@@ -5943,9 +6622,10 @@ export class RunManager {
5943
6622
  // The last agent step of the workflow is interactive: after its turn
5944
6623
  // the session stays open for follow-ups until finish/idle/cancel.
5945
6624
  const interactive = i === lastAgentIdx && i === workflow.steps.length - 1;
5946
- const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages, taskBackend, extraSystemPrompt, chainStepNote(workflow.steps, i), startAttachments);
6625
+ const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages, taskBackend, extraSystemPrompt, chainStepNote(workflow.steps, i), startAttachments, startSetupNote);
5947
6626
  startImages = undefined;
5948
6627
  startAttachments = [];
6628
+ startSetupNote = undefined;
5949
6629
  checkFailure = null;
5950
6630
  // Stale setup cannot finalize a replacement owner. Disposal also clears
5951
6631
  // active, but must still honor a previously accepted Stop/Finish intent.
@@ -5970,9 +6650,12 @@ export class RunManager {
5970
6650
  runError = `step "${step.id}" failed: ${failure}`;
5971
6651
  break;
5972
6652
  }
5973
- if (state.idleClosed)
6653
+ if (state.idleClosed || this.unansweredWorkflowAsk(runId, state))
5974
6654
  break;
5975
6655
  this.finishStep(runId, step.id, 'done', undefined, emit);
6656
+ // Finish is an explicit stop, not an answer or permission to run checks.
6657
+ if (state.finishRequested)
6658
+ break;
5976
6659
  i++;
5977
6660
  continue;
5978
6661
  }
@@ -6037,8 +6720,8 @@ export class RunManager {
6037
6720
  this.store.updateRun(runId, { status: 'failed', error: runError, finishedAt, currentStepId: undefined });
6038
6721
  emit({ type: 'lifecycle', message: `run failed — ${runError}` });
6039
6722
  }
6040
- else if (state.idleClosed) {
6041
- await this.settleIdleClosedWorker(runId);
6723
+ else if (state.idleClosed || this.unansweredWorkflowAsk(runId, state)) {
6724
+ await this.settleIdleClosedRun(runId, state);
6042
6725
  this.dropActive(runId);
6043
6726
  return;
6044
6727
  }
@@ -6056,7 +6739,9 @@ export class RunManager {
6056
6739
  /** Pasted attachments already materialized to disk (#357) — their absolute
6057
6740
  * paths are appended to `userPrompt` so the agent can operate on the
6058
6741
  * real files, not just view the inline image blocks. */
6059
- attachments = []) {
6742
+ attachments = [],
6743
+ /** Worktree setup's paragraph for the agent (#917) — appended to the opening message. */
6744
+ openingNote) {
6060
6745
  let systemPrompt;
6061
6746
  if (step.skill) {
6062
6747
  const skill = skills.find((s) => s.name === step.skill);
@@ -6117,6 +6802,8 @@ export class RunManager {
6117
6802
  // location of.
6118
6803
  if (attachments.length)
6119
6804
  userPrompt += `\n\n${pastedAttachmentsText(attachments)}`;
6805
+ if (openingNote)
6806
+ userPrompt += `\n\n${openingNote}`;
6120
6807
  const sessionId = randomUUID();
6121
6808
  const backend = step.runner ?? taskBackend;
6122
6809
  this.store.updateStep(runId, step.id, { sessionId, backend });
@@ -6124,6 +6811,7 @@ export class RunManager {
6124
6811
  const startTokens = stepRecord?.tokensUsed ?? 0;
6125
6812
  let turnText = '';
6126
6813
  let completedAssistantText = '';
6814
+ let turnTail = 'none';
6127
6815
  let sawClaudeScheduleWakeup = false;
6128
6816
  let sessionError;
6129
6817
  const sink = this.makeUiSink(runId, step.id);
@@ -6141,6 +6829,8 @@ export class RunManager {
6141
6829
  }
6142
6830
  if (event.type === 'text') {
6143
6831
  this.clearUnreadInputTimer(state); // real content: the harness is working
6832
+ this.clearHeldHumanInputTimer(state);
6833
+ this.noteFinalMessageNudgeContent(runId, state, event);
6144
6834
  turnText = appendTurnText(turnText, event.text);
6145
6835
  const text = stripAskMarker(stripTaskMarkers(stripMonitoringMarker(stripDoneMarker(event.text))), false);
6146
6836
  if (text)
@@ -6151,8 +6841,11 @@ export class RunManager {
6151
6841
  this.retireUnreadInputs(runId, state, event.inputIds, true);
6152
6842
  return;
6153
6843
  }
6154
- if (event.type === 'tool-call' || event.type === 'tool-result')
6844
+ if (event.type === 'tool-call' || event.type === 'tool-result') {
6155
6845
  this.clearUnreadInputTimer(state);
6846
+ this.clearHeldHumanInputTimer(state);
6847
+ this.noteFinalMessageNudgeContent(runId, state, event);
6848
+ }
6156
6849
  emit({ ...event, stepId: step.id });
6157
6850
  if (event.type === 'cost')
6158
6851
  this.recordReportedCost(runId, step.id, state, backend, event.usd);
@@ -6186,37 +6879,45 @@ export class RunManager {
6186
6879
  sink.flushAll();
6187
6880
  this.trackTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
6188
6881
  const sessionOpen = !state.cancelled && state.session?.open;
6189
- const done = interactive && sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
6190
- // `CEZ:ASK` → the user is blocked; wins over `CEZ:MONITORING`, loses to
6191
- // `CEZ:DONE` (#473).
6192
- const askTurn = resolveAskTurn(turnText, completedAssistantText, Boolean((interactive || this.workerWait(runId) || this.store.getRun(runId)?.ciWait) && sessionOpen) && !done);
6193
- const { ask, notes: askNotes } = askTurn;
6194
- discardQueuedMessagesOnAsk(state.session, askTurn);
6195
- const monitoring = !this.parentCompletionAttention(runId) &&
6196
- interactive &&
6197
- sessionOpen &&
6198
- !done &&
6199
- !ask &&
6200
- !state.pendingHumanAsk &&
6201
- (MONITORING_MARKER_RE.test(turnText.trimEnd()) || sawClaudeScheduleWakeup || this.hasLiveWorkers(runId));
6882
+ const turn = classifyTurnEnd(turnText, completedAssistantText, {
6883
+ sessionOpen: !!sessionOpen, interactive, pendingHumanAsk: !!state.pendingHumanAsk,
6884
+ completionAttention: this.parentCompletionAttention(runId), scheduledWake: sawClaudeScheduleWakeup,
6885
+ liveWorkers: this.hasLiveWorkers(runId),
6886
+ dependencyWait: !!(this.workerWait(runId) || this.store.getRun(runId)?.ciWait),
6887
+ });
6888
+ const { done, monitoring, humanGate, ask, notes: askNotes } = turn;
6889
+ discardQueuedMessagesOnAsk(state.session, turn);
6890
+ const silentTail = turnTail === 'silent';
6891
+ const alreadyFinalMessageNudged = state.finalMessageNudged === state.session;
6892
+ state.finalMessageNudged = undefined;
6202
6893
  turnText = '';
6203
6894
  completedAssistantText = '';
6895
+ turnTail = 'none';
6204
6896
  sawClaudeScheduleWakeup = false;
6205
6897
  for (const note of askNotes)
6206
6898
  emit({ type: 'note', stepId: step.id, ...note });
6207
6899
  this.store.updateRun(runId, { invalidAsk: !ask && askNotes.length > 0 ? true : undefined });
6208
- if (ask)
6900
+ // Only a newly parsed portable ASK can be overridden. Do this before
6901
+ // prepareHumanAsk latches it; an existing/native ask still blocks the helper.
6902
+ let autoContinued = !!ask && !!sessionOpen && this.tryAutonomousNudge(runId, state, step.id, ask);
6903
+ if (ask && !autoContinued)
6209
6904
  this.prepareHumanAsk(runId, state);
6210
- const ciWaitParked = !!sessionOpen && this.parkCiWait(runId, state);
6905
+ if (humanGate && !ask && !state.pendingHumanAsk)
6906
+ this.prepareProseHumanGate(runId, state);
6907
+ const ciWaitParked = !humanGate && !!sessionOpen && this.parkCiWait(runId, state);
6211
6908
  const completionBlocked = !ciWaitParked && !!done && this.deferParentCompletion(runId);
6212
- const workerWaitParked = ciWaitParked || completionBlocked || (!!sessionOpen && this.parkWorkerWait(runId, state));
6909
+ const workerWaitParked = ciWaitParked || completionBlocked || (!humanGate && !!sessionOpen && this.parkWorkerWait(runId, state));
6213
6910
  state.doneAtBoundary = done ? state.session : undefined;
6214
- state.parkAfterAck = interactive && sessionOpen && state.session ? { session: state.session, monitoring: !!monitoring } : undefined;
6911
+ state.parkAfterAck = (interactive || humanGate) && sessionOpen && state.session ? { session: state.session, monitoring, humanGate, silentTail, alreadyFinalMessageNudged } : undefined;
6215
6912
  // #505: input the harness accepted but has not read yet runs as its next turn.
6216
6913
  // An in-flight submission is settled by its acknowledgement (parkAfterAck), not here.
6217
- const agentInputDelivered = !ask && !workerWaitParked && (this.flushAgentInputs(runId) || this.harnessOwesInput(state) || !!state.agentInputFlight);
6914
+ const agentInputDelivered = autoContinued || (!ask && !workerWaitParked && (this.flushAgentInputs(runId) || (!humanGate && this.harnessOwesInput(state)) || !!state.agentInputFlight || this.harnessHoldsHumanInput(state)));
6218
6915
  if (!ask && !workerWaitParked)
6219
6916
  this.armUnreadInputTimer(runId, state);
6917
+ if (!ask && !workerWaitParked && this.harnessHoldsHumanInput(state))
6918
+ this.armHeldHumanInputTimer(runId, state);
6919
+ else
6920
+ this.clearHeldHumanInputTimer(state);
6220
6921
  if (state.agentInputError)
6221
6922
  return;
6222
6923
  if (done && !workerWaitParked && !state.pendingHumanAsk && !agentInputDelivered && !state.agentInputFlight && !this.hasQueuedAgentInputs(runId)) {
@@ -6227,8 +6928,14 @@ export class RunManager {
6227
6928
  state.session?.end();
6228
6929
  return;
6229
6930
  }
6230
- const waiting = (interactive || !!ask) && sessionOpen && !agentInputDelivered && !workerWaitParked && !this.workerWakeAdmitted.has(runId);
6231
- if (waiting) {
6931
+ const waiting = (interactive || !!ask || humanGate) && sessionOpen && !agentInputDelivered && !workerWaitParked && !this.workerWakeAdmitted.has(runId);
6932
+ if (waiting && interactive && !ask && !humanGate)
6933
+ autoContinued = this.tryAutonomousNudge(runId, state, step.id, null);
6934
+ if (waiting && interactive && !autoContinued)
6935
+ autoContinued = this.maybeFinalMessageNudge(runId, state, step.id, {
6936
+ ask: !!ask, humanGate, monitoring, silentTail, alreadyNudged: alreadyFinalMessageNudged,
6937
+ });
6938
+ if (waiting && !autoContinued && state.autonomousNudgePending !== state.session) {
6232
6939
  // Turn over, session open. Either the ball is in the user's court
6233
6940
  // (`waiting`) — optionally with a structured `CEZ:ASK` question the
6234
6941
  // cockpit renders as an ask card (#473) — or the agent declared it is
@@ -6264,7 +6971,7 @@ export class RunManager {
6264
6971
  }
6265
6972
  // Cez's own heartbeat — the handoff stays current even when the
6266
6973
  // agent forgets to write (spec 007).
6267
- appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : waiting ? 'waiting' : 'running'}`);
6974
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${autoContinued ? (state.finalMessageNudged === state.session ? 'running (final message nudge)' : 'running (autonomous nudge)') : state.autonomousNudgePending && state.autonomousNudgePending === state.session ? 'running (awaiting input readiness)' : agentInputDelivered ? 'running' : monitoring ? 'monitoring' : waiting ? 'waiting' : 'running'}`);
6268
6975
  }
6269
6976
  };
6270
6977
  const stepBackend = step.runner ?? taskBackend;
@@ -6326,15 +7033,17 @@ export class RunManager {
6326
7033
  const grants = stepIdentity?.grants ?? {
6327
7034
  allowedTools: allowedToolsForStep(step, stepBackend), bashAllowlist: step.bashAllowlist,
6328
7035
  };
7036
+ // Workers inherit only the extra prompt, never the parent's selected skill (#778).
6329
7037
  state.delegationSettings = { cwd: state.cwd, runner: stepBackend, model: backendModel,
6330
7038
  effort: effectiveEffort,
6331
- agentProfile: stepProfile.profileId, accountBinding: stepProfile.accountBinding, systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt),
7039
+ agentProfile: stepProfile.profileId, accountBinding: stepProfile.accountBinding, systemPrompt: extraSystemPrompt,
6332
7040
  allowedTools: grants.allowedTools, bashAllowlist: grants.bashAllowlist };
6333
7041
  const runner = createRunner(stepBackend);
6334
7042
  state.agentSessionError = undefined;
6335
7043
  let session;
6336
7044
  const shouldAutoEnd = () => this.active.get(runId) !== state || state.session !== session ||
6337
- (!this.workerWait(runId) && !this.store.getRun(runId)?.ciWait && !this.parentCompletionPending(runId) &&
7045
+ (state.atTurnBoundary === session && !state.pendingHumanAsk && !this.hasPendingHumanAsk(runId) &&
7046
+ !this.workerWait(runId) && !this.store.getRun(runId)?.ciWait && !this.parentCompletionPending(runId) &&
6338
7047
  state.workerWakeTurn !== session && state.ciWakeTurn !== session && !state.agentInputFlight &&
6339
7048
  !this.hasQueuedAgentInputs(runId));
6340
7049
  state.currentStepId = step.id;
@@ -6385,14 +7094,18 @@ export class RunManager {
6385
7094
  timeoutMs: step.timeoutMs ?? 0,
6386
7095
  }, onEvent, {
6387
7096
  autoEndAfterFirstTurn: !interactive,
6388
- // Unread inbox claims must survive this window so release/expiry can replay them.
7097
+ // Questions and unread inbox claims must survive this close window.
7098
+ // Keep the native timer: the answer's completed turn rearms it, and
7099
+ // admission/worker/CI guards retain authority over when it may close.
6389
7100
  shouldAutoEnd,
6390
7101
  onUiEvent: (event) => {
6391
7102
  completedAssistantText = appendCompletedAssistantText(completedAssistantText, event);
7103
+ turnTail = trackTurnTail(turnTail, event);
6392
7104
  this.handleRunnerUiEvent(runId, state, sink, event);
6393
7105
  },
6394
7106
  onAgentInputReady: () => this.handleAgentInputReady(runId, state, session),
6395
7107
  onAgentInputConsumed: (ids) => this.handleAgentInputConsumed(runId, state, session, ids),
7108
+ onPidChange: (pid) => { registerRunProcess(runId, pid); this.recordWorkerProcess(runId, pid); },
6396
7109
  });
6397
7110
  }
6398
7111
  catch (err) {
@@ -6517,6 +7230,7 @@ export class RunManager {
6517
7230
  /** Ask precedence also releases an admitted but not yet delivered worker wake.
6518
7231
  * Its durable input/wait remain queued until an actual human answer. */
6519
7232
  prepareHumanAsk(runId, state) {
7233
+ state.autonomousNudgePending = undefined;
6520
7234
  this.withdrawCiWait(runId);
6521
7235
  state.pendingHumanAsk = true;
6522
7236
  this.workerWaiting.delete(runId);
@@ -6527,6 +7241,17 @@ export class RunManager {
6527
7241
  this.queue.splice(at, 1);
6528
7242
  }
6529
7243
  }
7244
+ /** A prose or rejected-ASK fallback supersedes a registered autonomous wait. Keep the
7245
+ * checkpoint through idle closure/restart and hold worker input until a
7246
+ * successful human delivery, just as for a structured ask. */
7247
+ prepareProseHumanGate(runId, state) {
7248
+ this.store.appendEvent(runId, { type: 'note', stepId: state.currentStepId,
7249
+ code: PROSE_HUMAN_GATE, message: 'waiting for a human response to the assistant’s request' });
7250
+ this.prepareHumanAsk(runId, state);
7251
+ this.withdrawWorkerWait(runId);
7252
+ this.clearUnreadInputTimer(state);
7253
+ this.clearHeldHumanInputTimer(state);
7254
+ }
6530
7255
  /** Native backend asks arrive before turn-end. Persist and park immediately
6531
7256
  * so the cockpit shows attention and the run releases its workspace slot. */
6532
7257
  handleRunnerUiEvent(runId, state, sink, event) {
@@ -6536,7 +7261,13 @@ export class RunManager {
6536
7261
  sink.handle(event);
6537
7262
  if (state.cancelled)
6538
7263
  return;
7264
+ this.noteFinalMessageNudgeContent(runId, state, event);
6539
7265
  if (isRunnerActivity(event)) {
7266
+ if (state.autonomousNudgePending) {
7267
+ state.autonomousNudgePending = undefined;
7268
+ this.clearIdleTimer(state);
7269
+ }
7270
+ this.clearHeldHumanInputTimer(state);
6540
7271
  state.atTurnBoundary = undefined;
6541
7272
  state.doneAtBoundary = undefined;
6542
7273
  state.parkAfterAck = undefined;
@@ -6860,6 +7591,8 @@ export class RunManager {
6860
7591
  * off — settle straight to `done`, leaving the diff in the worktree untouched.
6861
7592
  */
6862
7593
  async settleSuccess(runId, durableRootFinish = false) {
7594
+ const state = this.active.get(runId);
7595
+ const continuationGeneration = this.continuationGenerations.get(runId);
6863
7596
  if (this.deferParentCompletion(runId))
6864
7597
  return;
6865
7598
  const run = this.store.getRun(runId);
@@ -6872,7 +7605,29 @@ export class RunManager {
6872
7605
  const config = await loadConfig(this.repoRoot);
6873
7606
  review = hasDiff && reviewGateEnabled(config) && run.autonomous !== true;
6874
7607
  }
6875
- // Diff/config I/O can race a worker's accepted continuation or a new child.
7608
+ // Diff/config I/O can race cancellation or a replacement execution. Re-read
7609
+ // the record before touching steps, and never settle another session's work.
7610
+ const current = this.store.getRun(runId);
7611
+ const active = this.active.get(runId);
7612
+ // Disposal clears active, but cannot revoke an already accepted Finish/Stop.
7613
+ // A different active state is still a replacement, even after disposal.
7614
+ const disposedWithTerminalIntent = this.disposed && active === undefined && (state?.finishRequested || state?.cancelled);
7615
+ if (!current || !['queued', 'running', 'waiting'].includes(current.status) ||
7616
+ this.continuationGenerations.get(runId) !== continuationGeneration ||
7617
+ (active !== state && !disposedWithTerminalIntent))
7618
+ return;
7619
+ if (state?.cancelled) {
7620
+ const finishedAt = new Date().toISOString();
7621
+ for (const step of current.steps) {
7622
+ if (step.status === 'running' || step.status === 'waiting') {
7623
+ this.store.updateStep(runId, step.id, { status: 'cancelled', finishedAt: step.finishedAt ?? finishedAt });
7624
+ }
7625
+ }
7626
+ this.store.updateRun(runId, { status: 'cancelled', finishedAt, currentStepId: undefined });
7627
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
7628
+ return;
7629
+ }
7630
+ // A worker's accepted continuation or a new child can defer parent completion.
6876
7631
  if (this.deferParentCompletion(runId))
6877
7632
  return;
6878
7633
  if (durableRootFinish) {
@@ -6882,9 +7637,17 @@ export class RunManager {
6882
7637
  return;
6883
7638
  }
6884
7639
  else {
7640
+ const finishedAt = new Date().toISOString();
7641
+ // An idle-closed Continue remains waiting until the whole task succeeds.
7642
+ // Complete those intermediate steps before publishing terminal run status.
7643
+ for (const step of this.store.getRun(runId)?.steps ?? []) {
7644
+ if (step.status === 'running' || step.status === 'waiting') {
7645
+ this.store.updateStep(runId, step.id, { status: 'done', finishedAt: step.finishedAt ?? finishedAt });
7646
+ }
7647
+ }
6885
7648
  this.store.updateRun(runId, {
6886
7649
  status: review ? 'review' : 'done',
6887
- finishedAt: new Date().toISOString(),
7650
+ finishedAt,
6888
7651
  currentStepId: undefined,
6889
7652
  // A run that got all the way to a settled turn is not in a limit loop, so the resume
6890
7653
  // counter starts over — otherwise a task that legitimately met the limit once a week would
@@ -6899,6 +7662,25 @@ export class RunManager {
6899
7662
  : 'run finished',
6900
7663
  });
6901
7664
  }
7665
+ /** Called only after session/process settlement. A refused nudge retained its
7666
+ * slot and running status until idle close; ordinary roots must now become
7667
+ * resumable. Previously parked roots and delegated lifecycle policy stay intact. */
7668
+ async settleIdleClosedRun(runId, state) {
7669
+ const run = this.store.getRun(runId);
7670
+ if (run?.delegation?.role === 'worker') {
7671
+ await this.settleIdleClosedWorker(runId);
7672
+ return;
7673
+ }
7674
+ if (!run || !state.autonomousNudgePending || run.ciWait || this.workerWait(runId) || this.hasPendingHumanAsk(runId))
7675
+ return;
7676
+ for (const step of run.steps) {
7677
+ if (step.status === 'running')
7678
+ this.store.updateStep(runId, step.id, { status: 'waiting' });
7679
+ }
7680
+ this.store.updateRun(runId, { status: 'waiting', activity: undefined });
7681
+ appendHandoffHeartbeat(this.dataDir, runId, 'session idle-closed — status=waiting; Continue to resume');
7682
+ this.store.flush();
7683
+ }
6902
7684
  /** An ordinary owned worker cannot accept an inactive Continue. Once its
6903
7685
  * idle-closed process has ended, publish the same terminal outcome recovery
6904
7686
  * would produce so its parent can collect it. Explicit asks and worker waits
@@ -7005,6 +7787,161 @@ export class RunManager {
7005
7787
  return null;
7006
7788
  }
7007
7789
  }
7790
+ /** Shared stop/input/liveness guards for an automatic continuation. */
7791
+ canAutoContinue(runId, state) {
7792
+ const run = this.store.getRun(runId);
7793
+ return !!run && !this.disposed && !run.stopping && !state.cancelled && !state.finishRequested &&
7794
+ !state.agentInputError && !state.pendingHumanAsk && !this.hasPendingHumanAsk(runId) && !!state.session?.open &&
7795
+ !this.workerExecutionStopped(runId) && !this.executionBlockedByRootFinish(run) && !this.parentCompletionPending(runId) &&
7796
+ !this.workerWait(runId) && !run.ciWait && !this.workerWakeAdmitted.has(runId) &&
7797
+ !state.agentInputFlight && !this.harnessOwesInput(state) && !this.hasQueuedAgentInputs(runId);
7798
+ }
7799
+ /**
7800
+ * Both turn-end paths keep a nudged session running, with no user-wait timer or
7801
+ * released slot. The next turn can finish with DONE, nudge again, or park at the
7802
+ * cap (waiting/monitoring with their existing wake/idle rules). Queued/accepted
7803
+ * input, worker/CI waits and parent completion keep priority. Cancel, Finish,
7804
+ * disposal and memory pause stop nudging; provider errors/exits use normal
7805
+ * teardown. Native mid-turn asks still park for an answer (outside this policy).
7806
+ * A refused ordinary nudge waits for readiness at this exact boundary; the idle
7807
+ * timeout bounds a missing readiness signal. New activity/input invalidates it.
7808
+ * A refused portable override falls back to normal question parking.
7809
+ */
7810
+ tryAutonomousNudge(runId, state, stepId, ask) {
7811
+ const session = state.session;
7812
+ if (!state.autonomous || !session || !this.canAutoContinue(runId, state))
7813
+ return false;
7814
+ state.autonomousNudgePending = undefined;
7815
+ this.clearIdleTimer(state);
7816
+ if ((state.autoContinues ?? 0) >= MAX_AUTO_CONTINUES) {
7817
+ this.store.appendEvent(runId, { type: 'note', stepId, message: `autonomous — safety cap reached (${MAX_AUTO_CONTINUES}); automatic continuation paused` });
7818
+ return false;
7819
+ }
7820
+ const content = [{ type: 'text', text: AUTONOMOUS_NUDGE }];
7821
+ // sendAgentMessage deliberately refuses portable asks on some native wires.
7822
+ // The existing answer transport clears that marker, but is safe ONLY for this
7823
+ // fresh turn-end ASK: the guards above exclude every pending native/persisted
7824
+ // question. Do not route via deliverMessage, which attributes a human answer.
7825
+ // Ordinary nudges keep #505's acknowledged agent-input failure semantics.
7826
+ const sent = ask ? session.sendMessage(content) : this.submitAgentInput(runId, state, content);
7827
+ if (!sent) {
7828
+ // A portable override uses sendMessage: its POST may still be awaiting ACK
7829
+ // after the next SSE turn ends. It is not an agentInputFlight. Keep this
7830
+ // exact boundary running until onAgentInputReady retries, without charging
7831
+ // the cap or claiming delivery. Missing readiness closes via the idle bound.
7832
+ if (!ask && session.open && !state.agentInputError && !state.agentSessionError) {
7833
+ state.autonomousNudgePending = session;
7834
+ this.armIdleTimer(runId, state);
7835
+ }
7836
+ return false;
7837
+ }
7838
+ if (ask)
7839
+ this.resumeParkedRun(runId, state);
7840
+ state.autoContinues = (state.autoContinues ?? 0) + 1;
7841
+ this.store.appendEvent(runId, {
7842
+ type: 'note', stepId,
7843
+ message: `autonomous — continuing without pausing (${state.autoContinues}/${MAX_AUTO_CONTINUES})`,
7844
+ });
7845
+ // The marker is stripped from visible text. Retain the question as a note,
7846
+ // never an ask card or a fabricated human-input-delivered acknowledgement.
7847
+ if (ask)
7848
+ this.store.appendEvent(runId, {
7849
+ type: 'note', stepId,
7850
+ message: `autonomous — question overridden by the auto-continue nudge: ${ask.questions.map(question => question.question).join(' | ')}`,
7851
+ });
7852
+ return true;
7853
+ }
7854
+ /** Shared silent-tail conditions for the one-shot visible-message nudge. Callers must
7855
+ * invoke this only when nothing else already continued. */
7856
+ maybeFinalMessageNudge(runId, state, stepId, turn) {
7857
+ if (turn.ask || turn.humanGate || turn.monitoring || !turn.silentTail || turn.alreadyNudged)
7858
+ return false;
7859
+ if (state.autonomousNudgePending === state.session)
7860
+ return false;
7861
+ return this.tryFinalMessageNudge(runId, state, stepId);
7862
+ }
7863
+ /** One-shot visible-message nudge when a turn ends on reasoning or a tool (#544, #119).
7864
+ * Liveness: this path has no inputIds, so #505's unread-input timer does not apply, and
7865
+ * resumeParkedRun / clearIdleTimer drop the user-wait idle bound. Do not re-arm idle until
7866
+ * content arrives — some harnesses omit `turn.started` and still run (N8); idle would then
7867
+ * kill a live turn. OpenCode heartbeats re-arm the managed-session no-progress guard without
7868
+ * opening a turn, so an ACK that stays silent is bounded by FINAL_MESSAGE_NUDGE_REPLY_MS:
7869
+ * parent-level v1 content (text, tool-call, tool-result) and UiEvent items count at any time,
7870
+ * including before the timer is armed; `turn.started` counts after arm or while
7871
+ * `finalMessageNudgeParked` matches the session (OpenCode/Cursor emit a synthetic one inside
7872
+ * sendAgentMessage). Nested `item.*` with `parentItemId` is ignored.
7873
+ * Expiry parks `waiting` and latches `finalMessageNudgeParked`; a late reply from that session
7874
+ * resumes. User-authored delivery resets the one-shot latch; expiry does not. */
7875
+ tryFinalMessageNudge(runId, state, stepId) {
7876
+ if (state.finalMessageNudged === state.session || !this.canAutoContinue(runId, state))
7877
+ return false;
7878
+ state.finalMessageNudgeSawContent = false;
7879
+ const sent = this.submitAgentInput(runId, state, [{ type: 'text', text: FINAL_MESSAGE_NUDGE }]);
7880
+ if (!sent)
7881
+ return false;
7882
+ state.finalMessageNudged = state.session;
7883
+ this.clearIdleTimer(state);
7884
+ this.armFinalMessageNudgeReplyTimer(runId, state);
7885
+ this.store.appendEvent(runId, { type: 'note', stepId, message: 'no final message — asking the agent for a visible reply' });
7886
+ return true;
7887
+ }
7888
+ /** Heartbeats and nested subagent items are not a reply. OpenCode/Cursor synthetic `turn.started` inside `sendAgentMessage` cannot false-resume after expiry because a later send goes through `submitAgentInput` → `resumeParkedRun`. */
7889
+ isFinalMessageNudgeReply(state, event) {
7890
+ if (event.type === 'turn.started')
7891
+ return state.finalMessageNudgeReplyTimer !== undefined || state.finalMessageNudgeParked === state.session;
7892
+ if (event.type === 'text' || event.type === 'tool-call' || event.type === 'tool-result')
7893
+ return true;
7894
+ if (event.type === 'item.delta' || !event.type.startsWith('item.'))
7895
+ return false;
7896
+ return event.item?.parentItemId === undefined;
7897
+ }
7898
+ noteFinalMessageNudgeContent(runId, state, event) {
7899
+ if (!this.isFinalMessageNudgeReply(state, event))
7900
+ return;
7901
+ state.finalMessageNudgeSawContent = true;
7902
+ this.clearFinalMessageNudgeReplyTimer(state);
7903
+ if (state.finalMessageNudgeParked !== state.session || !this.waiting.has(runId))
7904
+ return;
7905
+ if (state.pendingHumanAsk || this.hasPendingHumanAsk(runId))
7906
+ return;
7907
+ this.resumeParkedRun(runId, state);
7908
+ state.finalMessageNudgeParked = undefined;
7909
+ }
7910
+ armFinalMessageNudgeReplyTimer(runId, state) {
7911
+ this.clearFinalMessageNudgeReplyTimer(state);
7912
+ if (state.finalMessageNudgeSawContent)
7913
+ return;
7914
+ const session = state.session;
7915
+ if (!session?.open)
7916
+ return;
7917
+ const timer = setTimeout(() => {
7918
+ state.finalMessageNudgeReplyTimer = undefined;
7919
+ if (this.disposed || this.active.get(runId) !== state || state.session !== session || !session.open ||
7920
+ state.cancelled || state.finishRequested || this.waiting.has(runId) || this.monitoring.has(runId))
7921
+ return;
7922
+ state.finalMessageNudgeParked = session;
7923
+ this.store.appendEvent(runId, {
7924
+ type: 'note', stepId: state.currentStepId,
7925
+ message: 'no reply to the final-message nudge — waiting for you',
7926
+ });
7927
+ this.store.updateRun(runId, { status: 'waiting', activity: undefined });
7928
+ if (state.currentStepId)
7929
+ this.store.updateStep(runId, state.currentStepId, { status: 'waiting' });
7930
+ this.store.flush();
7931
+ this.waiting.add(runId);
7932
+ this.armIdleTimer(runId, state);
7933
+ this.releaseSlot();
7934
+ appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — status=waiting');
7935
+ }, this.finalMessageNudgeReplyMs);
7936
+ timer.unref?.();
7937
+ state.finalMessageNudgeReplyTimer = timer;
7938
+ }
7939
+ clearFinalMessageNudgeReplyTimer(state) {
7940
+ if (state.finalMessageNudgeReplyTimer) {
7941
+ clearTimeout(state.finalMessageNudgeReplyTimer);
7942
+ state.finalMessageNudgeReplyTimer = undefined;
7943
+ }
7944
+ }
7008
7945
  armIdleTimer(runId, state) {
7009
7946
  this.clearIdleTimer(state);
7010
7947
  state.idleClosed = false;
@@ -7025,6 +7962,7 @@ export class RunManager {
7025
7962
  clearTimeout(state.idleTimer);
7026
7963
  state.idleTimer = undefined;
7027
7964
  }
7965
+ this.clearFinalMessageNudgeReplyTimer(state);
7028
7966
  }
7029
7967
  reconcileMonitoringWakeTimers() {
7030
7968
  for (const runId of this.monitoring) {
@@ -7136,114 +8074,94 @@ export class RunManager {
7136
8074
  state.autosaveTimer = undefined;
7137
8075
  }
7138
8076
  }
7139
- runCheckStep(state, step, emit) {
8077
+ /**
8078
+ * Worktree setup (#917, spec `.ai/specs/2026-10-07-worktree-setup.md`): run the project's
8079
+ * `worktreeSetup` commands in this run's isolated worktree when it needs them, and return the
8080
+ * paragraph for the agent's opening message. `start` is a new task or worker, `rematerialized`
8081
+ * a Continue that rebuilt a reclaimed directory, and `resume` any other Continue, which only
8082
+ * finishes a setup a restart interrupted. In-place and non-Git runs never get setup: that tree
8083
+ * is the user's own checkout.
8084
+ */
8085
+ async prepareWorktree(runId, state, raw, emit, trigger) {
8086
+ const record = this.store.getRun(runId);
8087
+ if (!record || state.cwd === this.repoRoot)
8088
+ return undefined;
8089
+ const plan = resolveWorktreeSetup(raw);
8090
+ const interrupted = record.worktreeSetup?.status === 'running';
8091
+ if (plan.kind === 'none') {
8092
+ // A restart interrupted setup the project has since removed: nothing is left to finish.
8093
+ if (interrupted)
8094
+ this.store.updateRun(runId, { worktreeSetup: undefined });
8095
+ return undefined;
8096
+ }
8097
+ const fresh = trigger === 'start' && record.worktreeSetup === undefined &&
8098
+ !record.steps.some((step) => step.kind === 'agent' && step.iterations > 0);
8099
+ if (!interrupted && !fresh && trigger !== 'rematerialized')
8100
+ return undefined;
8101
+ const startedAt = new Date().toISOString();
8102
+ let outcome;
8103
+ if (plan.kind === 'invalid') {
8104
+ outcome = { status: 'invalid', issue: plan.issue };
8105
+ }
8106
+ else {
8107
+ // Durable before the first command: recovery reads `running` to requeue a run a restart
8108
+ // interrupted here, instead of failing it with no agent session to resume.
8109
+ this.store.updateRun(runId, { worktreeSetup: { status: 'running', startedAt } });
8110
+ this.store.flush();
8111
+ emit({ type: 'note', message: worktreeSetupStartNote(plan.commands.length) });
8112
+ let tmpEnv = {};
8113
+ try {
8114
+ tmpEnv = agentTmpEnv(this.dataDir, runId);
8115
+ }
8116
+ catch (err) {
8117
+ if (!(err instanceof AgentTempDirError))
8118
+ throw err;
8119
+ }
8120
+ outcome = await runWorktreeSetup({
8121
+ plan,
8122
+ cwd: state.cwd,
8123
+ env: worktreeSetupEnv({ projectRoot: this.repoRoot, runId, tmpEnv }),
8124
+ setInterrupt: (stop) => { state.interrupt = stop; },
8125
+ isCancelled: () => state.cancelled,
8126
+ onCommandResult: (result) => emit({
8127
+ type: 'check-output',
8128
+ command: result.command,
8129
+ text: result.output,
8130
+ exitCode: result.exitCode ?? -1,
8131
+ }),
8132
+ });
8133
+ }
8134
+ const finishedAt = new Date().toISOString();
8135
+ const error = worktreeSetupRecordError(outcome);
8136
+ this.store.updateRun(runId, {
8137
+ worktreeSetup: {
8138
+ status: outcome.status === 'done' ? 'done' : 'failed',
8139
+ startedAt,
8140
+ finishedAt,
8141
+ durationMs: 'durationMs' in outcome ? outcome.durationMs : 0,
8142
+ ...(error !== undefined ? { error } : {}),
8143
+ },
8144
+ });
8145
+ const endNote = worktreeSetupEndNote(outcome);
8146
+ emit({ type: 'note', message: endNote });
8147
+ appendHandoffHeartbeat(this.dataDir, runId, endNote);
8148
+ return worktreeSetupAgentNote(outcome);
8149
+ }
8150
+ async runCheckStep(state, step, emit) {
7140
8151
  const command = step.command;
7141
8152
  emit({ type: 'note', stepId: step.id, message: `$ ${command}` });
7142
- return new Promise((resolve) => {
7143
- // Check steps run in the same cwd as the agent steps — the worktree.
7144
- // Own process group (POSIX): Stop signals the group we created, never a
7145
- // recycled pid or an unrelated process.
7146
- const grouped = process.platform !== 'win32';
7147
- const child = spawn('bash', ['-lc', command], { cwd: state.cwd, env: process.env, detached: grouped });
7148
- const pid = child.pid;
7149
- let output = '';
7150
- let finished = false;
7151
- let stopping = false;
7152
- let killTimer;
7153
- let stopAt = 0;
7154
- const signalGroup = (signal) => {
7155
- if (finished || !pid)
7156
- return;
7157
- try {
7158
- if (grouped)
7159
- process.kill(-pid, signal);
7160
- else
7161
- child.kill(signal);
7162
- }
7163
- catch { /* already gone */ }
7164
- };
7165
- const groupAlive = () => {
7166
- if (!grouped || !pid)
7167
- return false;
7168
- try {
7169
- process.kill(-pid, 0);
7170
- return true;
7171
- }
7172
- catch (err) {
7173
- return err.code === 'EPERM';
7174
- }
7175
- };
7176
- const finish = (code) => {
7177
- if (finished)
7178
- return;
7179
- finished = true;
7180
- if (killTimer)
7181
- clearTimeout(killTimer);
7182
- state.interrupt = () => undefined;
7183
- child.stdout?.destroy();
7184
- child.stderr?.destroy();
7185
- const trimmed = output.trim() || '(no output)';
7186
- emit({ type: 'check-output', stepId: step.id, command, text: trimmed, exitCode: code ?? -1 });
7187
- resolve({ ok: code === 0, output: trimmed });
7188
- };
7189
- // After Stop: the KILL timer escalates; resolve only once the group is
7190
- // confirmed gone (or the confirmation bound passes, reported honestly).
7191
- let reaping = false;
7192
- const confirmTermination = async (code) => {
7193
- if (reaping)
7194
- return;
7195
- reaping = true;
7196
- const deadline = stopAt + CHECK_KILL_GRACE_MS + CHECK_TERMINATION_CONFIRM_MS;
7197
- while (groupAlive() && Date.now() < deadline)
7198
- await new Promise((r) => setTimeout(r, 25));
7199
- if (groupAlive()) {
7200
- output += '\n… (process group did not confirm termination)';
7201
- }
7202
- finish(code);
7203
- };
7204
- state.interrupt = () => {
7205
- if (stopping || finished)
7206
- return;
7207
- stopping = true;
7208
- stopAt = Date.now();
7209
- signalGroup('SIGTERM');
7210
- killTimer = setTimeout(() => signalGroup('SIGKILL'), CHECK_KILL_GRACE_MS);
7211
- killTimer.unref?.();
7212
- // Leader already gone but a descendant holds the pipes: no `exit` will follow.
7213
- if (child.exitCode !== null || child.signalCode !== null)
7214
- void confirmTermination(child.exitCode);
7215
- };
7216
- const collect = (chunk) => {
7217
- if (output.length < CHECK_OUTPUT_CAP) {
7218
- output += chunk.toString('utf8');
7219
- if (output.length >= CHECK_OUTPUT_CAP)
7220
- output += '\n… (output truncated)';
7221
- }
7222
- };
7223
- child.stdout.on('data', collect);
7224
- child.stderr.on('data', collect);
7225
- child.on('error', (err) => {
7226
- finished = true;
7227
- if (killTimer)
7228
- clearTimeout(killTimer);
7229
- state.interrupt = () => undefined;
7230
- const message = `failed to spawn: ${err.message}`;
7231
- emit({ type: 'check-output', stepId: step.id, command, text: message, exitCode: -1 });
7232
- resolve({ ok: false, output: message });
7233
- });
7234
- // A descendant can hold the inherited pipes open, so `close` may never
7235
- // fire after Stop. `exit` of the leader is the signal to reap the group.
7236
- child.on('exit', (code) => {
7237
- if (stopping)
7238
- void confirmTermination(code);
7239
- });
7240
- child.on('close', (code) => {
7241
- if (stopping)
7242
- void confirmTermination(code);
7243
- else
7244
- finish(code);
7245
- });
8153
+ // Check steps run in the same cwd as the agent steps — the worktree — in their own process
8154
+ // group, shared with worktree setup (#917).
8155
+ const result = await runGroupedCommand({
8156
+ command,
8157
+ cwd: state.cwd,
8158
+ env: process.env,
8159
+ setInterrupt: (stop) => { state.interrupt = stop; },
8160
+ keep: 'head',
8161
+ cap: CHECK_OUTPUT_CAP,
7246
8162
  });
8163
+ emit({ type: 'check-output', stepId: step.id, command, text: result.output, exitCode: result.exitCode ?? -1 });
8164
+ return { ok: result.exitCode === 0, output: result.output };
7247
8165
  }
7248
8166
  finishStep(runId, stepId, status, error, emit) {
7249
8167
  this.store.updateStep(runId, stepId, {