@moretea-labs/forge 1.5.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (903) hide show
  1. package/AGENTS.md +120 -0
  2. package/CHANGELOG.md +36 -0
  3. package/CLAUDE.md +120 -0
  4. package/CODE_OF_CONDUCT.md +23 -0
  5. package/CONTRIBUTING.md +77 -0
  6. package/LICENSE +22 -0
  7. package/NOTICE +9 -0
  8. package/README.en.md +5 -0
  9. package/README.es.md +12 -0
  10. package/README.fr.md +12 -0
  11. package/README.ja.md +12 -0
  12. package/README.md +132 -0
  13. package/README.zh-CN.md +84 -0
  14. package/SECURITY.md +50 -0
  15. package/SKILL.md +431 -0
  16. package/SUPPORT.md +31 -0
  17. package/THIRD_PARTY_NOTICES.md +18 -0
  18. package/agents/openai.yaml +4 -0
  19. package/assets/AGENTS.md +68 -0
  20. package/assets/CLAUDE.md +68 -0
  21. package/assets/branding/README.md +10 -0
  22. package/assets/branding/forge-controller-flow.mmd +8 -0
  23. package/assets/hooks/AGENTS.md +75 -0
  24. package/assets/hooks/CLAUDE.md +75 -0
  25. package/assets/hooks/anti-simplification.sh +12 -0
  26. package/assets/hooks/changelog-guard.sh +80 -0
  27. package/assets/hooks/codex.hooks.template.json +60 -0
  28. package/assets/hooks/first-principles-guard.sh +72 -0
  29. package/assets/hooks/hook-input.sh +618 -0
  30. package/assets/hooks/lib/session-state.sh +106 -0
  31. package/assets/hooks/lib/workflow-state.sh +1730 -0
  32. package/assets/hooks/post-bash.sh +215 -0
  33. package/assets/hooks/post-edit-guard.sh +256 -0
  34. package/assets/hooks/post-tool-observer.sh +99 -0
  35. package/assets/hooks/pre-edit-guard.sh +240 -0
  36. package/assets/hooks/prompt-guard.sh +1077 -0
  37. package/assets/hooks/run-hook.sh +67 -0
  38. package/assets/hooks/security-sentinel.sh +115 -0
  39. package/assets/hooks/session-start-context.sh +593 -0
  40. package/assets/hooks/settings.template.json +60 -0
  41. package/assets/hooks/stop-orchestrator.sh +171 -0
  42. package/assets/hooks/subagent-return-channel-guard.sh +107 -0
  43. package/assets/hooks/worktree-guard.sh +39 -0
  44. package/assets/initializer-question-pack.v1.json +132 -0
  45. package/assets/initializer-question-pack.v1.schema.json +46 -0
  46. package/assets/initializer-question-pack.v2.json +174 -0
  47. package/assets/initializer-question-pack.v3.json +206 -0
  48. package/assets/initializer-question-pack.v3.schema.json +92 -0
  49. package/assets/initializer-question-pack.v4.json +557 -0
  50. package/assets/initializer-question-pack.v4.schema.json +169 -0
  51. package/assets/partials/01-header.partial.md +10 -0
  52. package/assets/partials/02-iron-rules.partial.md +30 -0
  53. package/assets/partials/03-philosophy.partial.md +32 -0
  54. package/assets/partials/04-project-structure.partial.md +56 -0
  55. package/assets/partials/05-workflow.partial.md +79 -0
  56. package/assets/partials/06-cloudflare.partial.md +20 -0
  57. package/assets/partials/07-footer.partial.md +38 -0
  58. package/assets/partials/08-orchestration.partial.md +49 -0
  59. package/assets/partials/09-compact-instructions.partial.md +11 -0
  60. package/assets/partials/_assembly-order.md +40 -0
  61. package/assets/partials-agents/01-header.partial.md +8 -0
  62. package/assets/partials-agents/02-operating-mode.partial.md +44 -0
  63. package/assets/partials-agents/03-orchestration.partial.md +45 -0
  64. package/assets/partials-agents/04-task-protocol.partial.md +69 -0
  65. package/assets/partials-agents/05-coding-constraints.partial.md +20 -0
  66. package/assets/partials-agents/06-quality-safety.partial.md +23 -0
  67. package/assets/partials-agents/07-cloudflare.partial.md +9 -0
  68. package/assets/partials-agents/08-deep-docs.partial.md +32 -0
  69. package/assets/plan-map.json +301 -0
  70. package/assets/plugin-registry.v1.json +8 -0
  71. package/assets/project-structures/ai-native-collaborative-editor.txt +25 -0
  72. package/assets/project-structures/ai-native-product-copilot.txt +21 -0
  73. package/assets/project-structures/ai-native-runtime-console.txt +25 -0
  74. package/assets/project-structures/ai-native-sidecar-kernel.txt +19 -0
  75. package/assets/project-structures/astro-ssr.txt +25 -0
  76. package/assets/project-structures/expo-nativewind.txt +52 -0
  77. package/assets/project-structures/monorepo.txt +63 -0
  78. package/assets/project-structures/remix.txt +50 -0
  79. package/assets/project-structures/tanstack-start-workers.txt +51 -0
  80. package/assets/project-structures/vite-tanstack.txt +58 -0
  81. package/assets/reference-configs/AGENTS.md +13 -0
  82. package/assets/reference-configs/CLAUDE.md +13 -0
  83. package/assets/reference-configs/agentic-development-flow.md +94 -0
  84. package/assets/reference-configs/ai-workflows.md +14 -0
  85. package/assets/reference-configs/changelog-versioning.md +13 -0
  86. package/assets/reference-configs/coding-standards.md +14 -0
  87. package/assets/reference-configs/development-protocol.md +21 -0
  88. package/assets/reference-configs/document-generation.md +38 -0
  89. package/assets/reference-configs/evaluator-rubric.md +19 -0
  90. package/assets/reference-configs/external-tooling.md +374 -0
  91. package/assets/reference-configs/git-strategy.md +14 -0
  92. package/assets/reference-configs/global-working-rules.md +64 -0
  93. package/assets/reference-configs/handoff-protocol.md +38 -0
  94. package/assets/reference-configs/harness-overview.md +124 -0
  95. package/assets/reference-configs/heartbeat-triage.md +57 -0
  96. package/assets/reference-configs/hook-operations.md +96 -0
  97. package/assets/reference-configs/release-deploy.md +40 -0
  98. package/assets/reference-configs/spa-day-protocol.md +14 -0
  99. package/assets/reference-configs/sprint-contracts.md +89 -0
  100. package/assets/reference-configs/workflow-orchestration.md +14 -0
  101. package/assets/skill-hooks.json +41 -0
  102. package/assets/skill-version.json +189 -0
  103. package/assets/skills/claude-review/SKILL.md +231 -0
  104. package/assets/skills/codex-review/SKILL.md +103 -0
  105. package/assets/templates/AGENTS.md +13 -0
  106. package/assets/templates/CLAUDE.md +13 -0
  107. package/assets/templates/architecture.template.md +80 -0
  108. package/assets/templates/brief.template.md +38 -0
  109. package/assets/templates/contract.template.md +104 -0
  110. package/assets/templates/decisions.template.md +85 -0
  111. package/assets/templates/factor-factory/factor-backtest-report.template.md +36 -0
  112. package/assets/templates/factor-factory/factor-hypothesis.template.md +30 -0
  113. package/assets/templates/factor-factory/factor-registry.template.json +5 -0
  114. package/assets/templates/gitignore.template +53 -0
  115. package/assets/templates/guides/metro-esm-gotchas.template.md +46 -0
  116. package/assets/templates/helpers/architecture-event.ts +822 -0
  117. package/assets/templates/helpers/architecture-queue.sh +647 -0
  118. package/assets/templates/helpers/archive-architecture-request.sh +244 -0
  119. package/assets/templates/helpers/archive-workflow.sh +295 -0
  120. package/assets/templates/helpers/capability-config.ts +394 -0
  121. package/assets/templates/helpers/capability-resolver.ts +505 -0
  122. package/assets/templates/helpers/capture-plan.sh +335 -0
  123. package/assets/templates/helpers/check-agent-tooling.sh +1533 -0
  124. package/assets/templates/helpers/check-architecture-sync.sh +287 -0
  125. package/assets/templates/helpers/check-brain-manifest.sh +255 -0
  126. package/assets/templates/helpers/check-context-files.sh +49 -0
  127. package/assets/templates/helpers/check-deploy-sql-order.sh +117 -0
  128. package/assets/templates/helpers/check-skill-version.ts +193 -0
  129. package/assets/templates/helpers/check-task-sync.sh +65 -0
  130. package/assets/templates/helpers/check-task-workflow.sh +1002 -0
  131. package/assets/templates/helpers/codex-handoff-resume.sh +269 -0
  132. package/assets/templates/helpers/context-contract-sync.sh +516 -0
  133. package/assets/templates/helpers/contract-run.ts +465 -0
  134. package/assets/templates/helpers/contract-worktree.sh +725 -0
  135. package/assets/templates/helpers/ensure-task-workflow.sh +1218 -0
  136. package/assets/templates/helpers/harness-trace-grade.sh +150 -0
  137. package/assets/templates/helpers/heartbeat-triage.sh +361 -0
  138. package/assets/templates/helpers/inspect-project-state.ts +280 -0
  139. package/assets/templates/helpers/maintenance-triage.sh +167 -0
  140. package/assets/templates/helpers/migrate-project-template.sh +54 -0
  141. package/assets/templates/helpers/migrate-workflow-docs.ts +413 -0
  142. package/assets/templates/helpers/new-plan.sh +183 -0
  143. package/assets/templates/helpers/new-spec.sh +50 -0
  144. package/assets/templates/helpers/new-sprint.sh +49 -0
  145. package/assets/templates/helpers/plan-to-todo.sh +799 -0
  146. package/assets/templates/helpers/prepare-codex-handoff.sh +203 -0
  147. package/assets/templates/helpers/prepare-handoff.sh +81 -0
  148. package/assets/templates/helpers/refresh-current-status.sh +469 -0
  149. package/assets/templates/helpers/select-agent-context-blocks.sh +66 -0
  150. package/assets/templates/helpers/ship-worktrees.sh +561 -0
  151. package/assets/templates/helpers/sprint-backlog.sh +837 -0
  152. package/assets/templates/helpers/summarize-failures.sh +125 -0
  153. package/assets/templates/helpers/switch-plan.sh +153 -0
  154. package/assets/templates/helpers/sync-brain-docs.sh +381 -0
  155. package/assets/templates/helpers/verify-contract.sh +705 -0
  156. package/assets/templates/helpers/verify-sprint.sh +680 -0
  157. package/assets/templates/helpers/workflow-contract.ts +202 -0
  158. package/assets/templates/helpers/workstream-sync.sh +263 -0
  159. package/assets/templates/implementation-notes.template.md +37 -0
  160. package/assets/templates/packages.template.md +45 -0
  161. package/assets/templates/plan.template.md +77 -0
  162. package/assets/templates/prd.template.md +165 -0
  163. package/assets/templates/research.template.md +25 -0
  164. package/assets/templates/review.template.md +78 -0
  165. package/assets/templates/spec.template.md +31 -0
  166. package/assets/templates/sprint.template.md +69 -0
  167. package/assets/templates/tech-stack.template.md +57 -0
  168. package/assets/versions.json +77 -0
  169. package/assets/workflow-contract.v1.json +671 -0
  170. package/bin/forge-desktop-helper.mjs +204 -0
  171. package/bin/forge-hook.mjs +32 -0
  172. package/bin/forge-runtime-service.mjs +34 -0
  173. package/bin/forge-runtime.mjs +30 -0
  174. package/bin/forge.mjs +32 -0
  175. package/docs/CHANGELOG.md +978 -0
  176. package/docs/README.md +72 -0
  177. package/docs/README.zh-CN.md +58 -0
  178. package/docs/architecture/chatgpt-handoff-facade.md +393 -0
  179. package/docs/architecture/current/README.md +158 -0
  180. package/docs/architecture/current/approved-target-architecture.zh-CN.md +1501 -0
  181. package/docs/architecture/current/architecture-invariants.md +331 -0
  182. package/docs/architecture/current/automation-and-schedule-engine.md +453 -0
  183. package/docs/architecture/current/codegraph-context-provider.md +226 -0
  184. package/docs/architecture/current/control-plane-authority-inventory.md +82 -0
  185. package/docs/architecture/current/controller-repository-decoupling.md +148 -0
  186. package/docs/architecture/current/dispatch-and-agent-strategy.md +354 -0
  187. package/docs/architecture/current/entity-model.md +619 -0
  188. package/docs/architecture/current/external-plugin-governance.md +396 -0
  189. package/docs/architecture/current/failure-recovery.md +471 -0
  190. package/docs/architecture/current/governance.md +213 -0
  191. package/docs/architecture/current/human-interaction-plane.md +87 -0
  192. package/docs/architecture/current/implementation-status.md +128 -0
  193. package/docs/architecture/current/job-and-run-lifecycle.md +413 -0
  194. package/docs/architecture/current/local-workspace-target-grants.md +70 -0
  195. package/docs/architecture/current/migration-roadmap.md +105 -0
  196. package/docs/architecture/current/multi-repository-execution.md +371 -0
  197. package/docs/architecture/current/operations-runbook.md +45 -0
  198. package/docs/architecture/current/peer-supercontroller-runtime.md +32 -0
  199. package/docs/architecture/current/personal-assistant-plugin-baseline.md +207 -0
  200. package/docs/architecture/current/repository-access-modes.md +102 -0
  201. package/docs/architecture/current/route-policy.md +15 -0
  202. package/docs/architecture/current/route-session-concurrency.md +85 -0
  203. package/docs/architecture/current/runtime-architecture-simplification.md +214 -0
  204. package/docs/architecture/current/runtime-directory-map.md +49 -0
  205. package/docs/architecture/current/runtime-health-and-resource-lifecycle.md +85 -0
  206. package/docs/architecture/current/scheduler-and-resource-claims.md +432 -0
  207. package/docs/architecture/current/session-aware-execution-and-authorization.md +77 -0
  208. package/docs/architecture/current/system-overview.md +144 -0
  209. package/docs/architecture/current/target-requirements-traceability.md +202 -0
  210. package/docs/architecture/current/thin-harness-governance-policy.md +32 -0
  211. package/docs/architecture/current/thin-harness-v1.md +313 -0
  212. package/docs/architecture/current/verification-and-release-gates.md +391 -0
  213. package/docs/architecture/decisions/20260801-controller-home-sqlite-state.md +100 -0
  214. package/docs/architecture/decisions/20260803-canonical-skill-surface.md +40 -0
  215. package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +53 -0
  216. package/docs/architecture/diagrams/.gitkeep +1 -0
  217. package/docs/architecture/diagrams/agentic-dev-plugin-architecture.html +186 -0
  218. package/docs/architecture/domains/.gitkeep +0 -0
  219. package/docs/architecture/domains/public-surface.md +30 -0
  220. package/docs/architecture/domains/runtime-harness.md +33 -0
  221. package/docs/architecture/domains/verification.md +33 -0
  222. package/docs/architecture/domains/workflow-engine.md +30 -0
  223. package/docs/architecture/global-hook-runtime.md +162 -0
  224. package/docs/architecture/history.md +38 -0
  225. package/docs/architecture/index.md +114 -0
  226. package/docs/architecture/ios-semantic-automation-provider-v2.md +166 -0
  227. package/docs/architecture/modules/.gitkeep +0 -0
  228. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +24 -0
  229. package/docs/architecture/requests/.gitkeep +1 -0
  230. package/docs/architecture/requests/archive/2026/20260528-154740-ai-hooks-ai-hooks-hook-input-sh.md +59 -0
  231. package/docs/architecture/requests/archive/2026/20260528-154816-assets-hooks-assets-hooks-hook-input-sh.md +59 -0
  232. package/docs/architecture/requests/archive/2026/20260528-161851-ai-harness-workflow-contract-json-ai-harness-workflow-contract-json.md +59 -0
  233. package/docs/architecture/requests/archive/2026/20260528-161853-scripts-check-task-workflow-sh-scripts-check-task-workflow-sh.md +59 -0
  234. package/docs/architecture/requests/archive/2026/20260528-161911-assets-workflow-contract-v1-json-assets-workflow-contract-v1-json.md +59 -0
  235. package/docs/architecture/requests/archive/2026/20260528-222416-ai-hooks-ai-hooks-hook-input-sh.md +59 -0
  236. package/docs/architecture/requests/archive/2026/20260528-222444-ai-hooks-ai-hooks-pre-edit-guard-sh.md +59 -0
  237. package/docs/architecture/requests/archive/2026/20260528-222457-ai-hooks-ai-hooks-pre-edit-guard-sh.md +59 -0
  238. package/docs/architecture/requests/archive/2026/20260528-222648-ai-hooks-ai-hooks-pre-edit-guard-sh.md +59 -0
  239. package/docs/architecture/requests/archive/2026/20260528-222705-ai-hooks-ai-hooks-worktree-guard-sh.md +59 -0
  240. package/docs/architecture/requests/archive/2026/20260528-222726-ai-hooks-ai-hooks-prompt-guard-sh.md +59 -0
  241. package/docs/architecture/requests/archive/2026/20260528-222727-ai-hooks-ai-hooks-prompt-guard-sh.md +59 -0
  242. package/docs/architecture/requests/archive/2026/20260528-224736-ai-hooks-ai-hooks-hook-input-sh.md +59 -0
  243. package/docs/architecture/requests/archive/2026/20260528-233259-ai-hooks-ai-hooks-prompt-guard-sh.md +59 -0
  244. package/docs/architecture/requests/archive/2026/20260528-233335-assets-hooks-assets-hooks-prompt-guard-sh.md +59 -0
  245. package/docs/architecture/requests/archive/2026/20260528-233456-ai-hooks-ai-hooks-prompt-guard-sh.md +59 -0
  246. package/docs/architecture/requests/archive/2026/20260528-233457-assets-hooks-assets-hooks-prompt-guard-sh.md +59 -0
  247. package/docs/architecture/requests/archive/2026/20260528-233637-ai-hooks-ai-hooks-prompt-guard-sh.md +59 -0
  248. package/docs/architecture/requests/archive/2026/20260528-233638-assets-hooks-assets-hooks-prompt-guard-sh.md +59 -0
  249. package/docs/architecture/requests/archive/2026/20260529-000322-root-package-json.md +59 -0
  250. package/docs/architecture/requests/archive/2026/20260529-004238-ai-hooks-ai-hooks-lib-workflow-state-sh.md +59 -0
  251. package/docs/architecture/requests/archive/2026/20260529-020654-ai-harness-policy-json-ai-harness-policy-json.md +63 -0
  252. package/docs/architecture/requests/archive/2026/20260529-094236-ai-hooks-ai-hooks-prompt-guard-sh.md +60 -0
  253. package/docs/architecture/requests/archive/2026/20260529-094323-assets-hooks-assets-hooks-prompt-guard-sh.md +60 -0
  254. package/docs/architecture/requests/archive/2026/20260529-094331-ai-hooks-ai-hooks-prompt-guard-sh.md +60 -0
  255. package/docs/architecture/requests/archive/2026/20260529-094339-assets-hooks-assets-hooks-prompt-guard-sh.md +60 -0
  256. package/docs/architecture/requests/archive/2026/20260529-094430-assets-hooks-assets-hooks-session-start-context-sh.md +60 -0
  257. package/docs/architecture/requests/archive/2026/20260529-094446-ai-hooks-ai-hooks-session-start-context-sh.md +60 -0
  258. package/docs/architecture/requests/archive/2026/20260613-002040-runtime-harness-hook-adapters.md +68 -0
  259. package/docs/architecture/requests/archive/2026/root.md +68 -0
  260. package/docs/architecture/requests/archive/2026/runtime-harness-hook-adapters.md +76 -0
  261. package/docs/architecture/requests/archive/2026/verification-evals-checks.md +68 -0
  262. package/docs/architecture/requests/archive/2026/workflow-engine-contract-assets.md +69 -0
  263. package/docs/architecture/reviews/2026-07-11-access-surface-review.md +99 -0
  264. package/docs/architecture/snapshots/.gitkeep +1 -0
  265. package/docs/forge-autonomous-goal-loop.md +7 -0
  266. package/docs/forge-chatgpt-browser-engine.md +11 -0
  267. package/docs/forge-chatgpt-mcp-setup.md +239 -0
  268. package/docs/forge-chatgpt-supervised-automation.md +11 -0
  269. package/docs/forge-github-issue-launcher.md +7 -0
  270. package/docs/forge-plugin-management.md +95 -0
  271. package/docs/forge-provider-configuration.md +7 -0
  272. package/docs/forge-runtime-self-healing-loop.md +7 -0
  273. package/docs/images/forge-banner-cn.svg +1 -0
  274. package/docs/images/forge-banner.svg +1 -0
  275. package/docs/images/forge-controller-flow.svg +1 -0
  276. package/docs/images/image.png +0 -0
  277. package/docs/images/repo-harness-gptpro-cn.png +0 -0
  278. package/docs/images/repo-harness-gptpro.png +0 -0
  279. package/docs/images/repo-harness-hook-carrot.png +0 -0
  280. package/docs/operations/chatgpt-personal-assistant-runtime.md +83 -0
  281. package/docs/operations/checkout-route-integrity.md +22 -0
  282. package/docs/operations/controller-browser-plugin.md +299 -0
  283. package/docs/operations/controller-desktop-plugin.md +112 -0
  284. package/docs/operations/controller-performance-and-502.md +126 -0
  285. package/docs/operations/controller-reliability-runbook.md +134 -0
  286. package/docs/operations/environment-hardening.md +50 -0
  287. package/docs/operations/features.md +45 -0
  288. package/docs/operations/features.zh-CN.md +45 -0
  289. package/docs/operations/github-repository.md +60 -0
  290. package/docs/operations/gmail-assistant-routines.md +186 -0
  291. package/docs/operations/homebrew.md +26 -0
  292. package/docs/operations/local-system-assistant.md +46 -0
  293. package/docs/operations/local-system-assistant.zh-CN.md +46 -0
  294. package/docs/operations/mcp-tool-exposure.md +58 -0
  295. package/docs/operations/mobile-intents-shortcuts.md +117 -0
  296. package/docs/operations/open-source-release-hygiene.md +31 -0
  297. package/docs/operations/personal-assistant-operator-guide.md +61 -0
  298. package/docs/operations/platform-support.md +62 -0
  299. package/docs/operations/platform-support.zh-CN.md +61 -0
  300. package/docs/operations/provider-configuration.md +31 -0
  301. package/docs/operations/releasing.md +86 -0
  302. package/docs/operations/releasing.zh-CN.md +86 -0
  303. package/docs/operations/route-session-concurrency-baseline.json +893 -0
  304. package/docs/operations/route-session-concurrency-baseline.md +94 -0
  305. package/docs/operations/runtime-performance-diagnostics.md +14 -0
  306. package/docs/operations/sqlite-control-plane-recovery.md +65 -0
  307. package/docs/operations/standalone-disaster-recovery.md +136 -0
  308. package/docs/operations/troubleshooting.md +72 -0
  309. package/docs/operations/troubleshooting.zh-CN.md +70 -0
  310. package/docs/public-usage-guide.md +33 -0
  311. package/docs/public-usage-guide.zh-CN.md +33 -0
  312. package/docs/tutorials/01-install-and-start.md +93 -0
  313. package/docs/tutorials/01-install-and-start.zh-CN.md +93 -0
  314. package/docs/tutorials/02-connect-chatgpt.md +82 -0
  315. package/docs/tutorials/02-connect-chatgpt.zh-CN.md +80 -0
  316. package/docs/tutorials/03-first-repository-task.md +57 -0
  317. package/docs/tutorials/03-first-repository-task.zh-CN.md +57 -0
  318. package/docs/tutorials/README.md +13 -0
  319. package/docs/tutorials/README.zh-CN.md +13 -0
  320. package/docs/wiki/Architecture.md +85 -0
  321. package/docs/wiki/Core-Concepts.md +27 -0
  322. package/docs/wiki/Home.md +36 -0
  323. package/docs/wiki/Implementation.md +172 -0
  324. package/docs/wiki/Installation.md +50 -0
  325. package/docs/wiki/Integrations.md +29 -0
  326. package/docs/wiki/Operations.md +26 -0
  327. package/docs/wiki/Quick-Start.md +46 -0
  328. package/docs/wiki/Releases-and-Upgrades.md +34 -0
  329. package/docs/wiki/Runtime-Architecture.md +50 -0
  330. package/docs/wiki/Security-Model.md +31 -0
  331. package/docs/wiki/Troubleshooting.md +38 -0
  332. package/docs/wiki/Work-Lifecycle.md +31 -0
  333. package/docs/wiki/_Sidebar.md +21 -0
  334. package/install.ps1 +48 -0
  335. package/install.sh +66 -0
  336. package/package.json +156 -0
  337. package/scripts/activate-source-baseline.ts +335 -0
  338. package/scripts/architecture-event.ts +822 -0
  339. package/scripts/architecture-queue.sh +647 -0
  340. package/scripts/archive-architecture-request.sh +244 -0
  341. package/scripts/archive-workflow.sh +295 -0
  342. package/scripts/assemble-template.ts +1172 -0
  343. package/scripts/benchmark-controller-hotpath.ts +212 -0
  344. package/scripts/benchmark-controller-round2.ts +139 -0
  345. package/scripts/benchmark-ios-agent-device.ts +514 -0
  346. package/scripts/benchmark-route-session-concurrency.ts +714 -0
  347. package/scripts/benchmark-thin-harness-gateway-ab.ts +632 -0
  348. package/scripts/benchmark-thin-harness-mcp-e2e.ts +479 -0
  349. package/scripts/benchmark-thin-harness.ts +251 -0
  350. package/scripts/bootstrap-runtime-maintenance-recovery.sh +183 -0
  351. package/scripts/canary-global-hook.sh +263 -0
  352. package/scripts/capability-config.ts +394 -0
  353. package/scripts/capability-resolver.ts +505 -0
  354. package/scripts/capture-plan.sh +335 -0
  355. package/scripts/check-agent-tooling.sh +1549 -0
  356. package/scripts/check-architecture-sync.sh +373 -0
  357. package/scripts/check-brain-manifest.sh +255 -0
  358. package/scripts/check-ci.sh +16 -0
  359. package/scripts/check-context-files.sh +49 -0
  360. package/scripts/check-deploy-sql-order.sh +117 -0
  361. package/scripts/check-mcp-compatibility.ts +93 -0
  362. package/scripts/check-npm-release.sh +32 -0
  363. package/scripts/check-open-source-tracked-surface.sh +146 -0
  364. package/scripts/check-package-identity.mjs +117 -0
  365. package/scripts/check-platform-support.mjs +79 -0
  366. package/scripts/check-public-docs.sh +105 -0
  367. package/scripts/check-public-release-surface.sh +19 -0
  368. package/scripts/check-release-published.sh +57 -0
  369. package/scripts/check-release-readiness.sh +43 -0
  370. package/scripts/check-release-version.mjs +74 -0
  371. package/scripts/check-runtime-architecture.mjs +593 -0
  372. package/scripts/check-runtime-env.mjs +89 -0
  373. package/scripts/check-skill-version.ts +193 -0
  374. package/scripts/check-tarball-install-smoke.sh +66 -0
  375. package/scripts/check-task-sync.sh +65 -0
  376. package/scripts/check-task-workflow.sh +1002 -0
  377. package/scripts/check-third-party-notices.mjs +36 -0
  378. package/scripts/codex-handoff-resume.sh +269 -0
  379. package/scripts/complete-control-plane-cutover.ts +126 -0
  380. package/scripts/context-contract-sync.sh +516 -0
  381. package/scripts/contract-run.ts +465 -0
  382. package/scripts/contract-worktree.sh +725 -0
  383. package/scripts/controller-ngrok-rotation.sh +678 -0
  384. package/scripts/create-project-dirs.sh +282 -0
  385. package/scripts/ensure-codegraph.sh +16 -0
  386. package/scripts/ensure-task-workflow.sh +1218 -0
  387. package/scripts/export-requirement-portfolio.ts +28 -0
  388. package/scripts/factor-lab-check.sh +73 -0
  389. package/scripts/factor-lab-new.sh +96 -0
  390. package/scripts/factor-lab-promote.sh +86 -0
  391. package/scripts/factor-lab-reject.sh +68 -0
  392. package/scripts/harness-trace-grade.sh +150 -0
  393. package/scripts/heartbeat-triage.sh +361 -0
  394. package/scripts/hook-dispatch-diet-report.ts +234 -0
  395. package/scripts/hook-shim.sh +93 -0
  396. package/scripts/init-project.sh +505 -0
  397. package/scripts/initializer-question-pack.ts +200 -0
  398. package/scripts/inspect-project-state.ts +280 -0
  399. package/scripts/install-desktop-operator-registration.ts +45 -0
  400. package/scripts/install-standalone-recovery.ts +74 -0
  401. package/scripts/lib/AGENTS.md +13 -0
  402. package/scripts/lib/CLAUDE.md +13 -0
  403. package/scripts/lib/controller-home-env.sh +69 -0
  404. package/scripts/lib/project-init-lib.sh +2759 -0
  405. package/scripts/load-standalone-recovery.sh +17 -0
  406. package/scripts/loop-engine-cutover-gate.ts +324 -0
  407. package/scripts/maintenance-triage.sh +167 -0
  408. package/scripts/migrate-project-template.sh +1189 -0
  409. package/scripts/migrate-requirement-portfolio.ts +86 -0
  410. package/scripts/migrate-workflow-docs.ts +413 -0
  411. package/scripts/new-plan.sh +183 -0
  412. package/scripts/new-spec.sh +50 -0
  413. package/scripts/new-sprint.sh +49 -0
  414. package/scripts/open-source-audit-allowlist.txt +46 -0
  415. package/scripts/package-source-archive.sh +147 -0
  416. package/scripts/plan-to-todo.sh +799 -0
  417. package/scripts/prepare-codex-handoff.sh +203 -0
  418. package/scripts/prepare-handoff.sh +81 -0
  419. package/scripts/public-release-files.txt +39 -0
  420. package/scripts/public-release.ts +174 -0
  421. package/scripts/publish-release-tarball.sh +27 -0
  422. package/scripts/refresh-current-status.sh +469 -0
  423. package/scripts/refresh-ios-physical-runtime.ts +196 -0
  424. package/scripts/repair-executable-modes.ts +8 -0
  425. package/scripts/rollout-all-registered-repos.sh +7 -0
  426. package/scripts/route-nl-vs-ts-eval.ts +679 -0
  427. package/scripts/run-bun-test-file.ts +182 -0
  428. package/scripts/run-governed-gate.ts +140 -0
  429. package/scripts/run-mobile-monitor-bridge.ts +71 -0
  430. package/scripts/run-skill-evals.ts +1022 -0
  431. package/scripts/run-skill-hook.ts +244 -0
  432. package/scripts/run-supervised-command.ts +24 -0
  433. package/scripts/run-tests-portable.sh +20 -0
  434. package/scripts/select-agent-context-blocks.sh +66 -0
  435. package/scripts/setup-plugins.sh +43 -0
  436. package/scripts/ship-worktrees.sh +561 -0
  437. package/scripts/smoke-browser-cdp-bridge.ts +93 -0
  438. package/scripts/smoke-chatgpt-supervised-automation.ts +208 -0
  439. package/scripts/smoke-ios-physical-device.ts +114 -0
  440. package/scripts/smoke-mcp-http-runtime.ts +216 -0
  441. package/scripts/smoke-mcp-tool-surface.ts +117 -0
  442. package/scripts/smoke-runtime-recovery.ts +220 -0
  443. package/scripts/smoke-schedule-engine.ts +283 -0
  444. package/scripts/sprint-backlog.sh +837 -0
  445. package/scripts/stage-runtime-release.ts +38 -0
  446. package/scripts/summarize-failures.sh +125 -0
  447. package/scripts/switch-plan.sh +153 -0
  448. package/scripts/sync-brain-docs.sh +381 -0
  449. package/scripts/sync-codex-installed-copies.sh +183 -0
  450. package/scripts/test-governance.ts +75 -0
  451. package/scripts/verify-contract.sh +705 -0
  452. package/scripts/verify-forge-runtime.sh +31 -0
  453. package/scripts/verify-sprint.sh +680 -0
  454. package/scripts/windows-paths.d.mts +7 -0
  455. package/scripts/windows-paths.mjs +28 -0
  456. package/scripts/workflow-contract.ts +206 -0
  457. package/scripts/workstream-sync.sh +263 -0
  458. package/src/cli/agent-jobs/executable-resolver.ts +289 -0
  459. package/src/cli/agent-jobs/executor-health.ts +252 -0
  460. package/src/cli/agent-jobs/integration.ts +963 -0
  461. package/src/cli/agent-jobs/job-manager.ts +2030 -0
  462. package/src/cli/agent-jobs/types.ts +219 -0
  463. package/src/cli/agent-jobs/worker-lifecycle.ts +288 -0
  464. package/src/cli/chatgpt-browser/bind-server.ts +504 -0
  465. package/src/cli/chatgpt-browser/binding.ts +140 -0
  466. package/src/cli/chatgpt-browser/bridge-extension.ts +221 -0
  467. package/src/cli/chatgpt-browser/bridge-provider.ts +268 -0
  468. package/src/cli/chatgpt-browser/engine.ts +569 -0
  469. package/src/cli/chatgpt-browser/file-policy.ts +158 -0
  470. package/src/cli/chatgpt-browser/native-provider.ts +553 -0
  471. package/src/cli/chatgpt-browser/oracle-provider.ts +437 -0
  472. package/src/cli/chatgpt-browser/prompt-assembler.ts +29 -0
  473. package/src/cli/chatgpt-browser/session-store.ts +301 -0
  474. package/src/cli/chatgpt-browser/types.ts +172 -0
  475. package/src/cli/commands/adopt-plan.ts +186 -0
  476. package/src/cli/commands/brain-root.ts +132 -0
  477. package/src/cli/commands/brain.ts +609 -0
  478. package/src/cli/commands/capability-context.ts +613 -0
  479. package/src/cli/commands/chatgpt.ts +424 -0
  480. package/src/cli/commands/controller.ts +680 -0
  481. package/src/cli/commands/docs.ts +103 -0
  482. package/src/cli/commands/doctor.ts +476 -0
  483. package/src/cli/commands/global-runtime.ts +323 -0
  484. package/src/cli/commands/hook.ts +34 -0
  485. package/src/cli/commands/init-hook.ts +870 -0
  486. package/src/cli/commands/init.ts +829 -0
  487. package/src/cli/commands/install.ts +86 -0
  488. package/src/cli/commands/mcp.ts +315 -0
  489. package/src/cli/commands/migrate.ts +154 -0
  490. package/src/cli/commands/plugin.ts +74 -0
  491. package/src/cli/commands/prompt-guard-decision.ts +238 -0
  492. package/src/cli/commands/recovery.ts +667 -0
  493. package/src/cli/commands/repository.ts +224 -0
  494. package/src/cli/commands/run.ts +23 -0
  495. package/src/cli/commands/runtime.ts +191 -0
  496. package/src/cli/commands/security.ts +440 -0
  497. package/src/cli/commands/status.ts +137 -0
  498. package/src/cli/commands/tools.ts +167 -0
  499. package/src/cli/controller/check-runner.ts +1022 -0
  500. package/src/cli/controller/codex-continuation.ts +153 -0
  501. package/src/cli/controller/completion-backlog.ts +380 -0
  502. package/src/cli/controller/completion-orchestrator.ts +1807 -0
  503. package/src/cli/controller/completion-target.ts +96 -0
  504. package/src/cli/controller/composite-operations.ts +196 -0
  505. package/src/cli/controller/composite-result.ts +130 -0
  506. package/src/cli/controller/context-pack.ts +652 -0
  507. package/src/cli/controller/execution-completion.ts +175 -0
  508. package/src/cli/controller/execution-policy.ts +392 -0
  509. package/src/cli/controller/governance.ts +594 -0
  510. package/src/cli/controller/issue-store.ts +1421 -0
  511. package/src/cli/controller/launch-agents.ts +410 -0
  512. package/src/cli/controller/legacy-issue-cutover.ts +415 -0
  513. package/src/cli/controller/lifecycle-authority.ts +16 -0
  514. package/src/cli/controller/migrated-issue-decision.ts +237 -0
  515. package/src/cli/controller/operational-plan.ts +226 -0
  516. package/src/cli/controller/postcondition.ts +134 -0
  517. package/src/cli/controller/progress.ts +656 -0
  518. package/src/cli/controller/project-state.ts +147 -0
  519. package/src/cli/controller/requirement-portfolio-export.ts +146 -0
  520. package/src/cli/controller/requirement-portfolio-migration.ts +613 -0
  521. package/src/cli/controller/run-evidence.ts +43 -0
  522. package/src/cli/controller/runtime-config.ts +92 -0
  523. package/src/cli/controller/stuck-state-migration.ts +396 -0
  524. package/src/cli/controller/task-ledger.ts +632 -0
  525. package/src/cli/controller/task-status-resolver.ts +403 -0
  526. package/src/cli/controller/types.ts +273 -0
  527. package/src/cli/controller/work-mode.ts +186 -0
  528. package/src/cli/controller/worklog.ts +201 -0
  529. package/src/cli/diagnostic-entry.ts +39 -0
  530. package/src/cli/editing/edit-session.ts +1606 -0
  531. package/src/cli/editing/executable-modes.ts +70 -0
  532. package/src/cli/github/github.ts +274 -0
  533. package/src/cli/github/plugin.ts +345 -0
  534. package/src/cli/github/session.ts +104 -0
  535. package/src/cli/hook/prompt-guard-decision.ts +238 -0
  536. package/src/cli/hook/prompt-intents.ts +534 -0
  537. package/src/cli/hook/route-registry.ts +107 -0
  538. package/src/cli/hook/runtime.ts +303 -0
  539. package/src/cli/hook/state-snapshot.ts +388 -0
  540. package/src/cli/hook-entry.ts +51 -0
  541. package/src/cli/index.ts +574 -0
  542. package/src/cli/installer/managed-entries.ts +78 -0
  543. package/src/cli/installer/shared.ts +65 -0
  544. package/src/cli/installer/targets/claude.ts +136 -0
  545. package/src/cli/installer/targets/codex.ts +174 -0
  546. package/src/cli/installer/targets/registry.ts +28 -0
  547. package/src/cli/installer/types.ts +72 -0
  548. package/src/cli/local-bridge/connector-freshness.ts +529 -0
  549. package/src/cli/local-bridge/console-view-models.ts +324 -0
  550. package/src/cli/local-bridge/dashboard.ts +1312 -0
  551. package/src/cli/local-bridge/facade-api.ts +1877 -0
  552. package/src/cli/local-bridge/job-store.ts +1451 -0
  553. package/src/cli/local-bridge/mobile-intents.ts +344 -0
  554. package/src/cli/local-bridge/mobile-monitor.ts +209 -0
  555. package/src/cli/local-bridge/server.ts +3183 -0
  556. package/src/cli/local-bridge/types.ts +176 -0
  557. package/src/cli/mcp/access-mode.ts +132 -0
  558. package/src/cli/mcp/access-tools.ts +387 -0
  559. package/src/cli/mcp/audit.ts +32 -0
  560. package/src/cli/mcp/auth.ts +402 -0
  561. package/src/cli/mcp/instructions.ts +33 -0
  562. package/src/cli/mcp/legacy-context.ts +16 -0
  563. package/src/cli/mcp/legacy-tool-service.ts +5516 -0
  564. package/src/cli/mcp/multi-repository.ts +423 -0
  565. package/src/cli/mcp/oauth.ts +226 -0
  566. package/src/cli/mcp/paths.ts +134 -0
  567. package/src/cli/mcp/policy.ts +230 -0
  568. package/src/cli/mcp/proxy-env.ts +62 -0
  569. package/src/cli/mcp/readiness-projection.ts +24 -0
  570. package/src/cli/mcp/redaction.ts +21 -0
  571. package/src/cli/mcp/repo.ts +24 -0
  572. package/src/cli/mcp/repository-tools.ts +1295 -0
  573. package/src/cli/mcp/server.ts +341 -0
  574. package/src/cli/mcp/setup.ts +793 -0
  575. package/src/cli/mcp/tool-capability-cache.ts +87 -0
  576. package/src/cli/mcp/tools.ts +9 -0
  577. package/src/cli/mcp/toolset-names.ts +70 -0
  578. package/src/cli/mcp/toolset-selection.ts +41 -0
  579. package/src/cli/mcp/toolset.ts +300 -0
  580. package/src/cli/mcp/transports/AGENTS.md +39 -0
  581. package/src/cli/mcp/transports/CLAUDE.md +39 -0
  582. package/src/cli/mcp/transports/http.ts +1234 -0
  583. package/src/cli/mcp/transports/session-registry.ts +428 -0
  584. package/src/cli/mcp/transports/stdio.ts +8 -0
  585. package/src/cli/mcp/types.ts +41 -0
  586. package/src/cli/mcp/umbrella-tools.ts +117 -0
  587. package/src/cli/repo-adoption/reclaim-runtime.ts +681 -0
  588. package/src/cli/repositories/branch-name-policy.ts +57 -0
  589. package/src/cli/repositories/command-classifier.ts +600 -0
  590. package/src/cli/repositories/command-executor.ts +1066 -0
  591. package/src/cli/repositories/command-normalization.ts +53 -0
  592. package/src/cli/repositories/command-scope.ts +264 -0
  593. package/src/cli/repositories/controller-home.ts +88 -0
  594. package/src/cli/repositories/entity-migration.ts +199 -0
  595. package/src/cli/repositories/ephemeral-workspace.ts +62 -0
  596. package/src/cli/repositories/git-command-executor.ts +301 -0
  597. package/src/cli/repositories/goal-registry.ts +239 -0
  598. package/src/cli/repositories/identity.ts +64 -0
  599. package/src/cli/repositories/local-project-onboarding.ts +539 -0
  600. package/src/cli/repositories/locks.ts +262 -0
  601. package/src/cli/repositories/registry.ts +1057 -0
  602. package/src/cli/repositories/runtime-storage.ts +411 -0
  603. package/src/cli/repositories/safe-patch.ts +421 -0
  604. package/src/cli/repositories/selected-path-actions.ts +341 -0
  605. package/src/cli/repositories/structured-git.ts +257 -0
  606. package/src/cli/repositories/types.ts +155 -0
  607. package/src/cli/repositories/umbrella.ts +134 -0
  608. package/src/cli/repositories/workbench.ts +140 -0
  609. package/src/cli/repositories/worktree-storage.ts +137 -0
  610. package/src/cli/repository/inspector.ts +637 -0
  611. package/src/cli/repository/session-cache.ts +453 -0
  612. package/src/cli/runtime/helper-runner.ts +172 -0
  613. package/src/cli/runtime-invocation.ts +318 -0
  614. package/src/cli/tools/codegraph.ts +676 -0
  615. package/src/cli/v81-entry.ts +29 -0
  616. package/src/core/adoption/gitignore-plan.ts +127 -0
  617. package/src/core/adoption/helper-wrapper-plan.ts +159 -0
  618. package/src/core/adoption/manifest-templates.ts +60 -0
  619. package/src/core/adoption/modes.ts +7 -0
  620. package/src/core/adoption/operations.ts +126 -0
  621. package/src/core/adoption/plan.ts +149 -0
  622. package/src/core/adoption/render.ts +70 -0
  623. package/src/core/adoption/rollback.ts +62 -0
  624. package/src/core/adoption/summary.ts +43 -0
  625. package/src/core/adoption/workflow-contract-asset.ts +14 -0
  626. package/src/core/adoption/workflow-contract-plan.ts +31 -0
  627. package/src/effects/fs-transaction.ts +540 -0
  628. package/src/effects/git-executable.ts +59 -0
  629. package/src/effects/managed-block.ts +86 -0
  630. package/src/effects/path-safety.ts +47 -0
  631. package/src/effects/process-runner.ts +192 -0
  632. package/src/runtime/assistant/action-proposals.ts +279 -0
  633. package/src/runtime/assistant/intent.ts +395 -0
  634. package/src/runtime/assistant/model-provider.ts +460 -0
  635. package/src/runtime/assistant/openapi.ts +223 -0
  636. package/src/runtime/assistant/policy.ts +89 -0
  637. package/src/runtime/assistant/readiness.ts +258 -0
  638. package/src/runtime/assistant/routine-runtime.ts +477 -0
  639. package/src/runtime/assistant/schedule-binding.ts +171 -0
  640. package/src/runtime/assistant/standing-grants.ts +375 -0
  641. package/src/runtime/assistant/store.ts +243 -0
  642. package/src/runtime/assistant/types.ts +135 -0
  643. package/src/runtime/context/codegraph-read-provider.ts +347 -0
  644. package/src/runtime/context/codegraph-sidecar.cjs +207 -0
  645. package/src/runtime/control-plane/dispatch-priority.ts +67 -0
  646. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +151 -0
  647. package/src/runtime/control-plane/execution/execution-identity.ts +456 -0
  648. package/src/runtime/control-plane/execution/session-store.ts +175 -0
  649. package/src/runtime/control-plane/execution/validation.ts +117 -0
  650. package/src/runtime/control-plane/execution/verification-evidence.ts +191 -0
  651. package/src/runtime/control-plane/execution/work-handle-store.ts +225 -0
  652. package/src/runtime/control-plane/execution/work-prepare-request-store.ts +159 -0
  653. package/src/runtime/control-plane/execution/work-task-receipt.ts +445 -0
  654. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +821 -0
  655. package/src/runtime/control-plane/facade/capability-registry.ts +279 -0
  656. package/src/runtime/control-plane/facade/check-normalization.ts +233 -0
  657. package/src/runtime/control-plane/facade/codex-delegation.ts +196 -0
  658. package/src/runtime/control-plane/facade/controller-session-store.ts +199 -0
  659. package/src/runtime/control-plane/facade/facade-result.ts +41 -0
  660. package/src/runtime/control-plane/facade/goal-workloop-access.ts +177 -0
  661. package/src/runtime/control-plane/facade/goal-workloop.ts +1216 -0
  662. package/src/runtime/control-plane/facade/handoff-inbox-store.ts +221 -0
  663. package/src/runtime/control-plane/facade/index.ts +16 -0
  664. package/src/runtime/control-plane/facade/operation-digest.ts +528 -0
  665. package/src/runtime/control-plane/facade/plan-contract-store.ts +403 -0
  666. package/src/runtime/control-plane/facade/policy-gate.ts +149 -0
  667. package/src/runtime/control-plane/facade/requirement-board.ts +280 -0
  668. package/src/runtime/control-plane/facade/self-healing-loop.ts +555 -0
  669. package/src/runtime/control-plane/facade/suggested-actions.ts +108 -0
  670. package/src/runtime/control-plane/facade/types.ts +729 -0
  671. package/src/runtime/control-plane/facade/work-admission-policy.ts +93 -0
  672. package/src/runtime/control-plane/facade/work-continuation.ts +104 -0
  673. package/src/runtime/control-plane/facade/work-contract-store.ts +1118 -0
  674. package/src/runtime/control-plane/facade/work-task-projection.ts +66 -0
  675. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +365 -0
  676. package/src/runtime/control-plane/global-scheduler/scheduler.ts +920 -0
  677. package/src/runtime/control-plane/global-scheduler/wake-signal.ts +89 -0
  678. package/src/runtime/control-plane/goal-loop/config-facade.ts +700 -0
  679. package/src/runtime/control-plane/goal-loop/config-store.ts +570 -0
  680. package/src/runtime/control-plane/goal-loop/config-types.ts +213 -0
  681. package/src/runtime/control-plane/goal-loop/executor-router.ts +109 -0
  682. package/src/runtime/control-plane/goal-loop/failure-taxonomy.ts +149 -0
  683. package/src/runtime/control-plane/goal-loop/goal-contract-store.ts +304 -0
  684. package/src/runtime/control-plane/goal-loop/goal-loop-engine.ts +1001 -0
  685. package/src/runtime/control-plane/goal-loop/handoff-packet.ts +160 -0
  686. package/src/runtime/control-plane/goal-loop/index.ts +13 -0
  687. package/src/runtime/control-plane/goal-loop/local-tools.ts +290 -0
  688. package/src/runtime/control-plane/goal-loop/policy-gates.ts +203 -0
  689. package/src/runtime/control-plane/goal-loop/provider-dispatch.ts +93 -0
  690. package/src/runtime/control-plane/goal-loop/provider-registry.ts +545 -0
  691. package/src/runtime/control-plane/goal-loop/types.ts +378 -0
  692. package/src/runtime/control-plane/governance/access-policy.ts +147 -0
  693. package/src/runtime/control-plane/governance/authorization.ts +195 -0
  694. package/src/runtime/control-plane/governance/external-effects.ts +43 -0
  695. package/src/runtime/control-plane/launcher/thin-launcher.ts +97 -0
  696. package/src/runtime/control-plane/persistence/metadata-payload-policy.ts +53 -0
  697. package/src/runtime/control-plane/persistence/requirement-store.ts +241 -0
  698. package/src/runtime/control-plane/persistence/sqlite-store.ts +539 -0
  699. package/src/runtime/control-plane/release-retention.ts +447 -0
  700. package/src/runtime/control-plane/repo-actor/actor.ts +181 -0
  701. package/src/runtime/control-plane/repo-actor/registry.ts +20 -0
  702. package/src/runtime/control-plane/routing/index.ts +1 -0
  703. package/src/runtime/control-plane/routing/route-policy.ts +379 -0
  704. package/src/runtime/control-plane/runtime-cleanup.ts +727 -0
  705. package/src/runtime/control-plane/runtime-generation.ts +509 -0
  706. package/src/runtime/control-plane/runtime-pid-ownership.ts +11 -0
  707. package/src/runtime/control-plane/runtime-status-client.ts +90 -0
  708. package/src/runtime/control-plane/startup-recovery.ts +198 -0
  709. package/src/runtime/diagnostics/connectivity-observability.ts +475 -0
  710. package/src/runtime/diagnostics/mcp-timing.ts +64 -0
  711. package/src/runtime/diagnostics/performance.ts +379 -0
  712. package/src/runtime/diagnostics/process-facade.ts +350 -0
  713. package/src/runtime/diagnostics/read-only-tool.ts +226 -0
  714. package/src/runtime/evidence/artifact-store.ts +154 -0
  715. package/src/runtime/evidence/event-ledger.ts +80 -0
  716. package/src/runtime/evidence/evidence-store.ts +68 -0
  717. package/src/runtime/evidence/process-check-receipt.ts +43 -0
  718. package/src/runtime/evidence/result-store.ts +209 -0
  719. package/src/runtime/evidence/sensitive-output.ts +353 -0
  720. package/src/runtime/execution/jobs/child-reference.ts +124 -0
  721. package/src/runtime/execution/jobs/legacy-adapter.ts +22 -0
  722. package/src/runtime/execution/jobs/receipt-recovery.ts +119 -0
  723. package/src/runtime/execution/jobs/receipt-store.ts +243 -0
  724. package/src/runtime/execution/jobs/store.ts +737 -0
  725. package/src/runtime/execution/jobs/timeouts.ts +109 -0
  726. package/src/runtime/execution/jobs/types.ts +230 -0
  727. package/src/runtime/execution/jobs/wait.ts +45 -0
  728. package/src/runtime/execution/process-runtime/canary.ts +26 -0
  729. package/src/runtime/execution/process-runtime/check-facade.ts +272 -0
  730. package/src/runtime/execution/process-runtime/check-receipt.ts +185 -0
  731. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +60 -0
  732. package/src/runtime/execution/process-runtime/command-facade.ts +321 -0
  733. package/src/runtime/execution/process-runtime/gc.ts +171 -0
  734. package/src/runtime/execution/process-runtime/index.ts +9 -0
  735. package/src/runtime/execution/process-runtime/interactive-admission.ts +23 -0
  736. package/src/runtime/execution/process-runtime/process-runner-entry.ts +466 -0
  737. package/src/runtime/execution/process-runtime/resource-claims.ts +440 -0
  738. package/src/runtime/execution/process-runtime/runtime.ts +1816 -0
  739. package/src/runtime/execution/process-runtime/store.ts +687 -0
  740. package/src/runtime/execution/process-runtime/types.ts +352 -0
  741. package/src/runtime/execution/thin-harness/async-process.ts +181 -0
  742. package/src/runtime/execution/thin-harness/batch-executor.ts +671 -0
  743. package/src/runtime/execution/thin-harness/execution-router.ts +700 -0
  744. package/src/runtime/execution/thin-harness/fast-executor.ts +1263 -0
  745. package/src/runtime/execution/thin-harness/fast-receipt.ts +235 -0
  746. package/src/runtime/execution/thin-harness/fingerprint-worker.ts +191 -0
  747. package/src/runtime/execution/thin-harness/index.ts +13 -0
  748. package/src/runtime/execution/thin-harness/latency-trace.ts +107 -0
  749. package/src/runtime/execution/thin-harness/lightweight-lanes.ts +745 -0
  750. package/src/runtime/execution/thin-harness/mutation-gate.ts +578 -0
  751. package/src/runtime/execution/thin-harness/proposal-store.ts +409 -0
  752. package/src/runtime/execution/thin-harness/request-ledger.ts +595 -0
  753. package/src/runtime/execution/thin-harness/search-worker.ts +114 -0
  754. package/src/runtime/execution/thin-harness/types.ts +313 -0
  755. package/src/runtime/execution/thin-harness/workspace-savepoint.ts +333 -0
  756. package/src/runtime/execution/workers/executor.ts +687 -0
  757. package/src/runtime/execution/workers/ownership.ts +94 -0
  758. package/src/runtime/execution/workers/worker-entry.ts +277 -0
  759. package/src/runtime/gateway/mcp/execution-tools.ts +2009 -0
  760. package/src/runtime/gateway/mcp/persisted-check-process.ts +255 -0
  761. package/src/runtime/gateway/mcp/process-tools.ts +230 -0
  762. package/src/runtime/gateway/mcp/resource-policy.ts +195 -0
  763. package/src/runtime/gateway/mcp/router.ts +1088 -0
  764. package/src/runtime/gateway/mcp/runtime-tools.ts +5994 -0
  765. package/src/runtime/gateway/mcp/work-validation-reconciler.ts +219 -0
  766. package/src/runtime/health/evaluator.ts +474 -0
  767. package/src/runtime/health/index.ts +2 -0
  768. package/src/runtime/health/operational-view.ts +198 -0
  769. package/src/runtime/maintenance/cleanup.ts +419 -0
  770. package/src/runtime/model-clients/deepseek-adapter.ts +312 -0
  771. package/src/runtime/model-clients/index.ts +2 -0
  772. package/src/runtime/model-clients/types.ts +101 -0
  773. package/src/runtime/observability/gateway-contention-metrics.ts +129 -0
  774. package/src/runtime/observability/route-session-benchmark.ts +110 -0
  775. package/src/runtime/personal-assistant/gmail-triage-manager.ts +231 -0
  776. package/src/runtime/personal-assistant/reporting-runtime.ts +344 -0
  777. package/src/runtime/personal-assistant/triage-runtime.ts +748 -0
  778. package/src/runtime/plugins/app-store-connect-adapter.ts +868 -0
  779. package/src/runtime/plugins/browser-adapter.ts +2812 -0
  780. package/src/runtime/plugins/browser-automation-service.ts +46 -0
  781. package/src/runtime/plugins/browser-handoff-host.ts +304 -0
  782. package/src/runtime/plugins/browser-handoff.ts +371 -0
  783. package/src/runtime/plugins/browser-macos-bridge.ts +865 -0
  784. package/src/runtime/plugins/browser-node-bridge-host.ts +64 -0
  785. package/src/runtime/plugins/browser-node-bridge.ts +249 -0
  786. package/src/runtime/plugins/desktop-adapter.ts +374 -0
  787. package/src/runtime/plugins/desktop-operator-registration.ts +418 -0
  788. package/src/runtime/plugins/errors.ts +39 -0
  789. package/src/runtime/plugins/execution-origin.ts +26 -0
  790. package/src/runtime/plugins/external-adapter.ts +322 -0
  791. package/src/runtime/plugins/external-registration.ts +308 -0
  792. package/src/runtime/plugins/external-unix-socket-probe.cjs +79 -0
  793. package/src/runtime/plugins/external-unix-socket.ts +238 -0
  794. package/src/runtime/plugins/first-party-registry.ts +37 -0
  795. package/src/runtime/plugins/github-adapter.ts +266 -0
  796. package/src/runtime/plugins/gmail-adapter.ts +729 -0
  797. package/src/runtime/plugins/google-calendar-adapter.ts +468 -0
  798. package/src/runtime/plugins/google-shared.ts +578 -0
  799. package/src/runtime/plugins/google-tasks-adapter.ts +535 -0
  800. package/src/runtime/plugins/interaction-session.ts +252 -0
  801. package/src/runtime/plugins/ios/agent-device-capabilities.ts +258 -0
  802. package/src/runtime/plugins/ios/agent-device-failures.ts +95 -0
  803. package/src/runtime/plugins/ios/agent-device-provider.ts +63 -0
  804. package/src/runtime/plugins/ios/agent-device-typed-provider.ts +268 -0
  805. package/src/runtime/plugins/ios/app-adapters.ts +122 -0
  806. package/src/runtime/plugins/ios-adapter.ts +509 -0
  807. package/src/runtime/plugins/ios-agent-device.ts +2311 -0
  808. package/src/runtime/plugins/ios-physical-device.ts +955 -0
  809. package/src/runtime/plugins/local-system-adapter.ts +1016 -0
  810. package/src/runtime/plugins/macos-capability-broker.ts +73 -0
  811. package/src/runtime/plugins/managed-process-adapter.ts +366 -0
  812. package/src/runtime/plugins/resend-adapter.ts +506 -0
  813. package/src/runtime/plugins/store.ts +1025 -0
  814. package/src/runtime/plugins/types.ts +138 -0
  815. package/src/runtime/projections/controller-context.ts +596 -0
  816. package/src/runtime/projections/git-status-sampler.ts +84 -0
  817. package/src/runtime/projections/invalidation.ts +250 -0
  818. package/src/runtime/projections/materialized-view.ts +694 -0
  819. package/src/runtime/recovery/actions.ts +158 -0
  820. package/src/runtime/recovery/audit.ts +38 -0
  821. package/src/runtime/recovery/classifier.ts +163 -0
  822. package/src/runtime/recovery/index.ts +9 -0
  823. package/src/runtime/recovery/local-jobs-repair.ts +393 -0
  824. package/src/runtime/recovery/maintenance-executor.ts +822 -0
  825. package/src/runtime/recovery/patch-handoff.ts +50 -0
  826. package/src/runtime/recovery/probe.ts +308 -0
  827. package/src/runtime/recovery/store.ts +35 -0
  828. package/src/runtime/recovery/types.ts +175 -0
  829. package/src/runtime/release/identity.ts +101 -0
  830. package/src/runtime/release/release-gate.ts +137 -0
  831. package/src/runtime/resources/claims/conflicts.ts +205 -0
  832. package/src/runtime/resources/index.ts +1 -0
  833. package/src/runtime/resources/leases/store.ts +539 -0
  834. package/src/runtime/resources/leases/types.ts +50 -0
  835. package/src/runtime/resources/ownership.ts +72 -0
  836. package/src/runtime/root/controller-services.ts +42 -0
  837. package/src/runtime/root/entry.ts +70 -0
  838. package/src/runtime/root/gateway-adapter.ts +46 -0
  839. package/src/runtime/root/local-bridge.ts +72 -0
  840. package/src/runtime/root/mcp-transport.ts +198 -0
  841. package/src/runtime/root/ownership.ts +94 -0
  842. package/src/runtime/root/readiness.ts +69 -0
  843. package/src/runtime/root/release-manifest.ts +147 -0
  844. package/src/runtime/root/release-materialize.ts +509 -0
  845. package/src/runtime/root/release-store.ts +236 -0
  846. package/src/runtime/root/runtime.ts +318 -0
  847. package/src/runtime/root/scheduler.ts +58 -0
  848. package/src/runtime/root/service-runner-entry.ts +20 -0
  849. package/src/runtime/root/service-runner.ts +69 -0
  850. package/src/runtime/root/service.ts +291 -0
  851. package/src/runtime/root/status.ts +127 -0
  852. package/src/runtime/root/types.ts +112 -0
  853. package/src/runtime/root/write-fence.ts +309 -0
  854. package/src/runtime/safe-tooling/browser-safe.ts +133 -0
  855. package/src/runtime/safe-tooling/external-filesystem.ts +304 -0
  856. package/src/runtime/safe-tooling/google-credential-store.ts +95 -0
  857. package/src/runtime/safe-tooling/google-oauth-broker.ts +286 -0
  858. package/src/runtime/safe-tooling/index.ts +11 -0
  859. package/src/runtime/safe-tooling/ios-development.ts +1039 -0
  860. package/src/runtime/safe-tooling/job-summary.ts +361 -0
  861. package/src/runtime/safe-tooling/plugin-summary.ts +53 -0
  862. package/src/runtime/safe-tooling/resend-oauth.ts +387 -0
  863. package/src/runtime/safe-tooling/review-artifacts.ts +133 -0
  864. package/src/runtime/safe-tooling/types.ts +103 -0
  865. package/src/runtime/safe-tooling/workspace-auth.ts +116 -0
  866. package/src/runtime/shared/bounded-child-supervisor.ts +235 -0
  867. package/src/runtime/shared/idempotent-retry.ts +128 -0
  868. package/src/runtime/shared/json-files.ts +43 -0
  869. package/src/runtime/shared/local-bridge-surface.ts +231 -0
  870. package/src/runtime/shared/managed-env.ts +154 -0
  871. package/src/runtime/shared/node-ts-loader.mjs +50 -0
  872. package/src/runtime/shared/process-environment.ts +97 -0
  873. package/src/runtime/shared/process-identity.ts +72 -0
  874. package/src/runtime/shared/process-tree.ts +227 -0
  875. package/src/runtime/shared/response-budget.ts +246 -0
  876. package/src/runtime/standalone-recovery/core.ts +2275 -0
  877. package/src/runtime/standalone-recovery/entry.ts +686 -0
  878. package/src/runtime/standalone-recovery/http-transport.ts +317 -0
  879. package/src/runtime/standalone-recovery/installer.ts +747 -0
  880. package/src/runtime/standalone-recovery/release.ts +241 -0
  881. package/src/runtime/watchdog/workflow-watchdog.ts +118 -0
  882. package/src/runtime/workflow/campaigns/cleanup.ts +263 -0
  883. package/src/runtime/workflow/campaigns/engine.ts +112 -0
  884. package/src/runtime/workflow/campaigns/normalize.ts +100 -0
  885. package/src/runtime/workflow/campaigns/review.ts +315 -0
  886. package/src/runtime/workflow/campaigns/store.ts +526 -0
  887. package/src/runtime/workflow/campaigns/supervisor.ts +112 -0
  888. package/src/runtime/workflow/campaigns/types.ts +290 -0
  889. package/src/runtime/workflow/campaigns/workspace-agent.ts +155 -0
  890. package/src/runtime/workflow/campaigns/workspace.ts +200 -0
  891. package/src/runtime/workflow/findings/store.ts +165 -0
  892. package/src/runtime/workflow/findings/types.ts +36 -0
  893. package/src/runtime/workflow/portfolio/engine.ts +20 -0
  894. package/src/runtime/workflow/portfolio/store.ts +185 -0
  895. package/src/runtime/workflow/portfolio/types.ts +29 -0
  896. package/src/runtime/workflow/schedules/engine.ts +620 -0
  897. package/src/runtime/workflow/schedules/settlement.ts +144 -0
  898. package/src/runtime/workflow/schedules/store.ts +417 -0
  899. package/src/runtime/workflow/schedules/types.ts +124 -0
  900. package/src/runtime/workspace-targets/index.ts +24 -0
  901. package/src/runtime/workspace-targets/target-grant.ts +721 -0
  902. package/src/testing/test-governance.ts +626 -0
  903. package/src/version.ts +21 -0
@@ -0,0 +1,1816 @@
1
+ /**
2
+ * Unified Process Runtime.
3
+ *
4
+ * One spawn path serves Direct (wait briefly then return result) and Managed
5
+ * (return handle when still running). Commands are never re-executed after spawn.
6
+ *
7
+ * Architecture:
8
+ * Controller → Process Runner (independent) → Actual Command
9
+ *
10
+ * Exit receipts are written by the Process Runner, so Controller crash/SIGKILL
11
+ * does not lose the true exit code. Controller only attaches / polls / reads
12
+ * receipt after restart — never re-spawns the command.
13
+ */
14
+
15
+ import { createHash, randomUUID } from 'crypto';
16
+ import { spawn, type ChildProcess } from 'child_process';
17
+ import {
18
+ closeSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ openSync,
22
+ readFileSync,
23
+ readSync,
24
+ realpathSync,
25
+ renameSync,
26
+ rmSync,
27
+ statSync,
28
+ writeFileSync,
29
+ } from 'fs';
30
+ import { dirname, join } from 'path';
31
+ import { fileURLToPath } from 'url';
32
+ import { capProcessOutput } from '../../../effects/process-runner';
33
+ import { redactSensitiveText, sanitizeSensitiveTextFileInPlace } from '../../evidence/sensitive-output';
34
+ import { isProcessAlive, terminateProcessTree } from '../../shared/process-tree';
35
+ import { repositoryChildProcessEnvironment, resolveBunExecutable } from '../../shared/process-environment';
36
+ import {
37
+ acquireExecutionLeases,
38
+ listActiveLeases,
39
+ releaseExecutionLeases,
40
+ releaseTerminalProcessLeases,
41
+ renewExecutionLeases,
42
+ } from '../../resources/leases/store';
43
+ import { assertRuntimeMayWrite, assertRuntimeMayWriteOrThrow, getRuntimeWriteClaim } from '../../root/write-fence';
44
+ import { defaultProcessIdentityProbe, executableFingerprint } from '../../shared/process-identity';
45
+ import {
46
+ claimProcessCheckExecution,
47
+ claimProcessRequest,
48
+ createProcessRecord,
49
+ getProcessRecord,
50
+ listProcessRecords,
51
+ listRecoverableProcessRecords,
52
+ processLogDir,
53
+ tryCompleteProcessRecord,
54
+ updateProcessRecord,
55
+ } from './store';
56
+ import { assertExecutionIdentity } from '../../control-plane/execution/execution-identity';
57
+ import {
58
+ DEFAULT_INTERACTIVE_WAIT_MS,
59
+ DEFAULT_MAX_OUTPUT_BYTES,
60
+ DEFAULT_PROCESS_TIMEOUT_MS,
61
+ PROCESS_LOG_TAIL_BYTES,
62
+ effectiveProcessStatus,
63
+ isManagedProcessActive,
64
+ isManagedProcessTerminal,
65
+ isTerminalProcessStatus,
66
+ type ManagedProcessRecord,
67
+ type ProcessHandle,
68
+ type ProcessLeaseRef,
69
+ type ProcessLogSlice,
70
+ type ProcessResourceClaim,
71
+ type SpawnManagedProcessInput,
72
+ type WaitProcessOptions,
73
+ processContractStatus,
74
+ } from './types';
75
+ import type {
76
+ ProcessCommandDescriptor,
77
+ ProcessRunnerExitReceipt,
78
+ } from './process-runner-entry';
79
+ import type { ResourceClaimSpec } from '../jobs/types';
80
+ import { scopeResourceClaims, toProcessClaims } from './resource-claims';
81
+
82
+ interface LiveMonitor {
83
+ processId: string;
84
+ repoId: string;
85
+ controllerHome: string;
86
+ /** Runner process (not the actual command). */
87
+ child: ChildProcess;
88
+ fenceToken: number;
89
+ stdoutBytes: number;
90
+ stderrBytes: number;
91
+ stdoutStoredBytes: number;
92
+ stderrStoredBytes: number;
93
+ stdoutTail: Buffer;
94
+ stderrTail: Buffer;
95
+ maxTailBytes: number;
96
+ maxDiskBytes: number;
97
+ logTruncated: boolean;
98
+ settled: boolean;
99
+ timeoutHandle?: NodeJS.Timeout;
100
+ waiters: Array<(record: ManagedProcessRecord) => void>;
101
+ stdoutPath: string;
102
+ stderrPath: string;
103
+ exitReceiptPath: string;
104
+ descriptorPath: string;
105
+ commandFingerprint: string;
106
+ }
107
+
108
+ const liveMonitors = new Map<string, LiveMonitor>();
109
+ const PROCESS_RECOVERY_RECEIPT_GRACE_MS = 500;
110
+ const PROCESS_RECOVERY_RECEIPT_POLL_MS = 25;
111
+
112
+ function nowIso(): string {
113
+ return new Date().toISOString();
114
+ }
115
+
116
+ function newProcessId(): string {
117
+ return `proc_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
118
+ }
119
+
120
+ function safeProcessText(value: string): string {
121
+ return redactSensitiveText(value).text;
122
+ }
123
+
124
+ function sanitizeTerminalProcessArtifacts(record: ManagedProcessRecord): ManagedProcessRecord {
125
+ if (!isManagedProcessTerminal(record)) return record;
126
+ let filesExamined = 0;
127
+ let filesChanged = 0;
128
+ let redactionCount = 0;
129
+ for (const path of [record.stdoutPath, record.stderrPath]) {
130
+ if (!path || !existsSync(path)) continue;
131
+ filesExamined += 1;
132
+ try {
133
+ const sanitized = sanitizeSensitiveTextFileInPlace(path);
134
+ if (sanitized.changed) filesChanged += 1;
135
+ redactionCount += sanitized.redactions.reduce((total, entry) => total + entry.count, 0);
136
+ } catch {
137
+ /* Keep the original artifact untouched; a later read/recovery may retry. */
138
+ }
139
+ }
140
+ const descriptorPath = record.commandDescriptorPath
141
+ ?? descriptorPathFor(record.controllerHome, record.repoId, record.processId);
142
+ let descriptorRemoved = false;
143
+ if (descriptorPath && existsSync(descriptorPath)) {
144
+ try {
145
+ rmSync(descriptorPath, { force: true });
146
+ descriptorRemoved = true;
147
+ } catch {
148
+ /* best-effort; never expose descriptor content in an error */
149
+ }
150
+ }
151
+ if (filesChanged === 0 && !descriptorRemoved && record.outputRedaction) return record;
152
+ const updated = updateProcessRecord(record.controllerHome, record.repoId, record.processId, {
153
+ outputRedaction: {
154
+ schemaVersion: 1,
155
+ sanitizedAt: nowIso(),
156
+ filesExamined,
157
+ filesChanged,
158
+ redactionCount,
159
+ descriptorRemoved: descriptorRemoved || record.outputRedaction?.descriptorRemoved === true,
160
+ },
161
+ }, { allowTerminal: true });
162
+ // A non-authoritative Controller can still have a trustworthy local terminal
163
+ // outcome from its live Runner waiter while the durable record remains
164
+ // writer-fenced in `running`. Persist redaction metadata best-effort, but never
165
+ // let that metadata update replace the terminal projection we are returning.
166
+ return updated ? { ...record, outputRedaction: updated.outputRedaction } : record;
167
+ }
168
+
169
+ function canonicalizeCommandCwd(cwd: string | undefined): string | null {
170
+ if (!cwd) return null;
171
+ try {
172
+ // Align request-id fingerprints with pre-spawn execution-identity realpath
173
+ // so callers that claim before spawn do not conflict after cwd normalization.
174
+ return realpathSync(cwd);
175
+ } catch {
176
+ return cwd;
177
+ }
178
+ }
179
+
180
+ function canonicalProcessCommand(command: SpawnManagedProcessInput['command']): Record<string, unknown> {
181
+ return {
182
+ kind: command.kind,
183
+ executable: command.executable ?? null,
184
+ args: command.args ?? [],
185
+ shellCommand: command.shellCommand ?? null,
186
+ cwd: canonicalizeCommandCwd(command.cwd),
187
+ env: command.env
188
+ ? Object.fromEntries(Object.entries(command.env).sort(([left], [right]) => left.localeCompare(right)))
189
+ : null,
190
+ };
191
+ }
192
+
193
+ /** Stable semantic command fingerprint used by request-id idempotency. */
194
+ export function fingerprintProcessCommand(command: SpawnManagedProcessInput['command']): string {
195
+ return createHash('sha256').update(JSON.stringify(canonicalProcessCommand(command))).digest('hex');
196
+ }
197
+
198
+ function tailText(buffer: Buffer, maxBytes: number): string {
199
+ if (buffer.length <= maxBytes) return safeProcessText(buffer.toString('utf8'));
200
+ // Drop incomplete leading UTF-8 sequence after offset cut.
201
+ let start = buffer.length - maxBytes;
202
+ while (start < buffer.length && (buffer[start]! & 0xc0) === 0x80) start += 1;
203
+ return safeProcessText(buffer.subarray(start).toString('utf8'));
204
+ }
205
+
206
+ function appendTail(current: Buffer, chunk: Buffer, maxBytes: number): Buffer {
207
+ if (chunk.length >= maxBytes) return Buffer.from(chunk.subarray(chunk.length - maxBytes));
208
+ const nextLen = current.length + chunk.length;
209
+ if (nextLen <= maxBytes) return Buffer.concat([current, chunk]);
210
+ const keep = maxBytes - chunk.length;
211
+ return Buffer.concat([current.subarray(current.length - keep), chunk]);
212
+ }
213
+
214
+ /**
215
+ * Read only the last maxBytes of a file without loading the whole file.
216
+ * Tolerates incomplete leading UTF-8 sequences.
217
+ */
218
+ export function readFileTailBytes(path: string, maxBytes: number): { text: string; fileBytes: number } {
219
+ if (!path || !existsSync(path)) return { text: '', fileBytes: 0 };
220
+ const size = statSync(path).size;
221
+ if (size <= 0) return { text: '', fileBytes: 0 };
222
+ const readSize = Math.min(size, Math.max(1, maxBytes));
223
+ const fd = openSync(path, 'r');
224
+ try {
225
+ const buf = Buffer.alloc(readSize);
226
+ const offset = size - readSize;
227
+ const bytesRead = readSync(fd, buf, 0, readSize, offset);
228
+ let start = 0;
229
+ // Skip incomplete leading UTF-8 continuation bytes when we started mid-sequence.
230
+ if (offset > 0) {
231
+ while (start < bytesRead && (buf[start]! & 0xc0) === 0x80) start += 1;
232
+ }
233
+ const text = safeProcessText(buf.subarray(start, bytesRead).toString('utf8'));
234
+ return { text, fileBytes: size };
235
+ } finally {
236
+ closeSync(fd);
237
+ }
238
+ }
239
+
240
+ function captureIdentity(pid: number | undefined): {
241
+ identity?: ManagedProcessRecord['identity'];
242
+ identityUntrusted?: boolean;
243
+ } {
244
+ if (!pid || pid <= 0) return {};
245
+ const probe = defaultProcessIdentityProbe;
246
+ if (!probe.isAlive(pid)) return {};
247
+ const inspected = probe.inspect?.(pid);
248
+ const command = inspected?.command ?? probe.command(pid);
249
+ const startTime = inspected?.startTime ?? probe.startTime(pid);
250
+ if (!command || !startTime) {
251
+ return {
252
+ identity: {
253
+ pid,
254
+ processStartTime: `untrusted:${Date.now()}`,
255
+ executableFingerprint: createHash('sha256').update(`pid:${pid}`).digest('hex').slice(0, 24),
256
+ processGroupId: process.platform !== 'win32' ? pid : undefined,
257
+ },
258
+ identityUntrusted: true,
259
+ };
260
+ }
261
+ return {
262
+ identity: {
263
+ pid,
264
+ processStartTime: startTime,
265
+ executableFingerprint: executableFingerprint(command),
266
+ processGroupId: process.platform !== 'win32' ? pid : undefined,
267
+ },
268
+ identityUntrusted: false,
269
+ };
270
+ }
271
+
272
+ function identityStillMatches(identity: ManagedProcessRecord['identity'] | undefined, untrusted?: boolean): boolean {
273
+ if (!identity) return false;
274
+ if (untrusted || identity.processStartTime.startsWith('untrusted:') || identity.processStartTime.startsWith('fallback:')) {
275
+ return false;
276
+ }
277
+ if (!isProcessAlive(identity.pid)) return false;
278
+ const probe = defaultProcessIdentityProbe;
279
+ const startTime = probe.startTime(identity.pid);
280
+ const command = probe.command(identity.pid);
281
+ if (!startTime || !command) return false;
282
+ if (startTime !== identity.processStartTime) return false;
283
+ if (executableFingerprint(command) !== identity.executableFingerprint) return false;
284
+ return true;
285
+ }
286
+
287
+ export interface ProcessExitReceipt {
288
+ schemaVersion: 1;
289
+ processId: string;
290
+ exitCode: number | null;
291
+ signal?: string;
292
+ finishedAt: string;
293
+ startedAt?: string;
294
+ timedOut?: boolean;
295
+ cancelled?: boolean;
296
+ stdoutBytes?: number;
297
+ stderrBytes?: number;
298
+ stdoutStoredBytes?: number;
299
+ stderrStoredBytes?: number;
300
+ logTruncated?: boolean;
301
+ runnerPid?: number;
302
+ commandExecutedOnce?: boolean;
303
+ }
304
+
305
+ function receiptPathFor(controllerHome: string, repoId: string, processId: string): string {
306
+ return join(processLogDir(controllerHome, repoId), `${processId}.exit.json`);
307
+ }
308
+
309
+ function descriptorPathFor(controllerHome: string, repoId: string, processId: string): string {
310
+ return join(processLogDir(controllerHome, repoId), `${processId}.command.json`);
311
+ }
312
+
313
+ function writeExitReceipt(path: string, receipt: ProcessExitReceipt): void {
314
+ try {
315
+ const temporary = `${path}.${process.pid}.tmp`;
316
+ writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
317
+ renameSync(temporary, path);
318
+ } catch {
319
+ /* best-effort receipt */
320
+ }
321
+ }
322
+
323
+ function readExitReceipt(path: string | undefined): ProcessExitReceipt | undefined {
324
+ if (!path || !existsSync(path)) return undefined;
325
+ try {
326
+ const value = JSON.parse(readFileSync(path, 'utf8')) as ProcessExitReceipt;
327
+ if (value?.schemaVersion !== 1) return undefined;
328
+ return value;
329
+ } catch {
330
+ return undefined;
331
+ }
332
+ }
333
+
334
+ function statusFromReceipt(receipt: ProcessExitReceipt): ManagedProcessRecord['status'] {
335
+ if (receipt.cancelled) return 'cancelled';
336
+ if (receipt.timedOut) return 'timed_out';
337
+ const code = receipt.exitCode ?? 1;
338
+ return code === 0 ? 'succeeded' : 'failed';
339
+ }
340
+
341
+ function processOwnerJobId(processId: string): string {
342
+ return `process:${processId}`;
343
+ }
344
+
345
+ function toResourceClaimSpecs(claims: ProcessResourceClaim[]): ResourceClaimSpec[] {
346
+ return claims.map((claim) => ({
347
+ resourceKey: claim.resourceKey,
348
+ mode: claim.mode,
349
+ ...(claim.repoId ? { repoId: claim.repoId } : {}),
350
+ ...(claim.checkoutId ? { checkoutId: claim.checkoutId } : {}),
351
+ ...(claim.workId ? { workId: claim.workId } : {}),
352
+ }));
353
+ }
354
+
355
+ function processRuntimeIdentityMatches(record: ManagedProcessRecord): boolean {
356
+ const expected = {
357
+ runtimeInstanceId: record.runtimeInstanceId,
358
+ releaseAuthorityRevision: record.releaseAuthorityRevision,
359
+ releaseId: record.releaseId,
360
+ artifactIdentity: record.artifactIdentity,
361
+ workerProtocolVersion: record.workerProtocolVersion,
362
+ };
363
+ if (Object.values(expected).every((value) => value === undefined)) return true;
364
+ const claim = getRuntimeWriteClaim();
365
+ if (!claim) return false;
366
+ if (expected.runtimeInstanceId && expected.runtimeInstanceId !== claim.runtimeInstanceId) return false;
367
+ if (expected.releaseAuthorityRevision !== undefined && expected.releaseAuthorityRevision !== claim.releaseAuthorityRevision) return false;
368
+ if (expected.releaseId && expected.releaseId !== claim.releaseId) return false;
369
+ if (expected.artifactIdentity && expected.artifactIdentity !== claim.artifactIdentity) return false;
370
+ if (expected.workerProtocolVersion !== undefined && expected.workerProtocolVersion !== claim.workerProtocolVersion) return false;
371
+ return true;
372
+ }
373
+
374
+ function expectedLeaseRefsForProcess(record: ManagedProcessRecord, repoId: string): Array<{
375
+ leaseId: string;
376
+ fencingToken: number;
377
+ repoId: string;
378
+ checkoutId?: string;
379
+ workId?: string;
380
+ resourceKey: string;
381
+ }> {
382
+ return (record.leaseRefs ?? []).flatMap((ref) => {
383
+ // A malformed or cross-repository reference is never eligible for cleanup.
384
+ if (!ref.leaseId || !ref.resourceKey || (ref.repoId && ref.repoId !== repoId)) return [];
385
+ if (ref.checkoutId && record.checkoutId && ref.checkoutId !== record.checkoutId) return [];
386
+ if (ref.workId && record.workId && ref.workId !== record.workId) return [];
387
+ return [{
388
+ leaseId: ref.leaseId,
389
+ fencingToken: ref.fencingToken,
390
+ repoId,
391
+ ...(ref.checkoutId ? { checkoutId: ref.checkoutId } : {}),
392
+ ...(ref.workId ? { workId: ref.workId } : {}),
393
+ resourceKey: ref.resourceKey,
394
+ }];
395
+ });
396
+ }
397
+
398
+ function recordLeaseReleaseFailure(
399
+ controllerHome: string,
400
+ repoId: string,
401
+ record: ManagedProcessRecord,
402
+ error: unknown,
403
+ ): ManagedProcessRecord {
404
+ const rawMessage = error instanceof Error ? error.message : String(error);
405
+ const message = redactSensitiveText(rawMessage).text;
406
+ const separator = message.indexOf(':');
407
+ const code = separator > 0 ? message.slice(0, separator) : 'TERMINAL_PROCESS_LEASE_RELEASE_FAILED';
408
+ return updateProcessRecord(controllerHome, repoId, record.processId, {
409
+ leasesReleased: false,
410
+ leaseReleaseState: 'pending',
411
+ leaseReleaseFailure: {
412
+ code,
413
+ message,
414
+ attemptedAt: nowIso(),
415
+ attempts: (record.leaseReleaseFailure?.attempts ?? 0) + 1,
416
+ },
417
+ }, { allowTerminal: true }) ?? record;
418
+ }
419
+
420
+ /**
421
+ * Release terminal Process leases exactly once. Safe under recovery/cancel races.
422
+ * Authorization is the durable terminal record plus exact owner/scope/token refs,
423
+ * not the writer generation that happened to spawn the Process.
424
+ */
425
+ export function releaseProcessLeasesOnce(
426
+ controllerHome: string,
427
+ repoId: string,
428
+ processId: string,
429
+ ): ManagedProcessRecord | undefined {
430
+ const record = getProcessRecord(controllerHome, repoId, processId);
431
+ if (!record) return undefined;
432
+ if (record.leasesReleased === true || record.leaseReleaseState === 'released') return record;
433
+ if (!isManagedProcessTerminal(record)) return record;
434
+ const refs = record.leaseRefs ?? [];
435
+ if (refs.length === 0) {
436
+ return updateProcessRecord(
437
+ controllerHome,
438
+ repoId,
439
+ processId,
440
+ { leasesReleased: true, leaseReleaseState: 'released', leaseReleaseFailure: undefined },
441
+ { allowTerminal: true },
442
+ );
443
+ }
444
+ const expectedRefs = expectedLeaseRefsForProcess(record, repoId);
445
+ if (expectedRefs.length !== refs.length) {
446
+ return recordLeaseReleaseFailure(
447
+ controllerHome,
448
+ repoId,
449
+ record,
450
+ new Error(`TERMINAL_PROCESS_LEASE_REF_INVALID: ${processId} contains malformed or cross-scope lease references`),
451
+ );
452
+ }
453
+ try {
454
+ releaseTerminalProcessLeases(
455
+ controllerHome,
456
+ repoId,
457
+ processId,
458
+ expectedRefs,
459
+ { visibility: 'ephemeral', notifyScheduler: false, invalidateProjection: false, emitRuntimeEvent: false },
460
+ );
461
+ } catch (error) {
462
+ // Exact-set or token mismatch remains pending and retryable. Failed cleanup
463
+ // must never become a durable released claim.
464
+ return recordLeaseReleaseFailure(controllerHome, repoId, record, error);
465
+ }
466
+ const remaining = listActiveLeases(controllerHome, repoId)
467
+ .some((lease) => lease.ownerJobId === processOwnerJobId(processId));
468
+ if (remaining) {
469
+ return recordLeaseReleaseFailure(
470
+ controllerHome,
471
+ repoId,
472
+ record,
473
+ new Error(`TERMINAL_PROCESS_LEASES_REMAIN_ACTIVE: ${processId} still owns active leases after cleanup`),
474
+ );
475
+ }
476
+ return updateProcessRecord(
477
+ controllerHome,
478
+ repoId,
479
+ processId,
480
+ { leasesReleased: true, leaseReleaseState: 'released', leaseReleaseFailure: undefined },
481
+ { allowTerminal: true },
482
+ ) ?? getProcessRecord(controllerHome, repoId, processId);
483
+ }
484
+
485
+ /**
486
+ * Central terminal completion path used by finalizeMonitor, getProcessHandle,
487
+ * waitForProcess, recoverManagedProcesses, and cancelProcess.
488
+ * Durable terminal record writes require active writer fencing.
489
+ * Receipt itself is independent evidence and is never blocked by fencing.
490
+ * Terminal persistence records a retryable pending-release phase. Exact lease
491
+ * cleanup then converges independently of the spawning runtime generation.
492
+ */
493
+ export function completeProcessFromEvidence(
494
+ controllerHome: string,
495
+ repoId: string,
496
+ processId: string,
497
+ fenceToken: number,
498
+ evidence: {
499
+ status: ManagedProcessRecord['status'];
500
+ exitCode?: number | null;
501
+ timedOut?: boolean;
502
+ cancelled?: boolean;
503
+ finishedAt?: string;
504
+ errorMessage?: string;
505
+ stdoutBytes?: number;
506
+ stderrBytes?: number;
507
+ stdoutStoredBytes?: number;
508
+ stderrStoredBytes?: number;
509
+ logTruncated?: boolean;
510
+ exitReceiptPath?: string;
511
+ stdoutTail?: string;
512
+ stderrTail?: string;
513
+ },
514
+ options: { authority?: 'runtime_writer' | 'durable_exit_receipt' | 'durable_pre_spawn_abandonment' } = {},
515
+ ): ManagedProcessRecord | undefined {
516
+ // Validated independent evidence may only perform the monotonic,
517
+ // fence-token-bound terminal CAS below. It does not grant general writer
518
+ // authority, queue access, process signalling, or arbitrary record updates.
519
+ let mayWriteTerminal = options.authority === 'durable_exit_receipt'
520
+ || options.authority === 'durable_pre_spawn_abandonment';
521
+ if (!mayWriteTerminal) {
522
+ mayWriteTerminal = assertRuntimeMayWrite('write_process_terminal', controllerHome).allowed;
523
+ }
524
+
525
+ if (!mayWriteTerminal) {
526
+ return getProcessRecord(controllerHome, repoId, processId);
527
+ }
528
+
529
+ const completion = tryCompleteProcessRecord(
530
+ controllerHome,
531
+ repoId,
532
+ processId,
533
+ fenceToken,
534
+ {
535
+ status: evidence.status,
536
+ exitCode: evidence.exitCode ?? undefined,
537
+ timedOut: evidence.timedOut,
538
+ cancelled: evidence.cancelled,
539
+ finishedAt: evidence.finishedAt ?? nowIso(),
540
+ exitReceiptPath: evidence.exitReceiptPath,
541
+ stdoutBytes: evidence.stdoutBytes,
542
+ stderrBytes: evidence.stderrBytes,
543
+ stdoutStoredBytes: evidence.stdoutStoredBytes,
544
+ stderrStoredBytes: evidence.stderrStoredBytes,
545
+ logTruncated: evidence.logTruncated,
546
+ stdoutTail: evidence.stdoutTail,
547
+ stderrTail: evidence.stderrTail,
548
+ ...(evidence.errorMessage
549
+ ? { error: { code: evidence.status.toUpperCase(), message: evidence.errorMessage } }
550
+ : {}),
551
+ },
552
+ );
553
+
554
+ // Always attempt exactly-once lease release after terminal evidence, including
555
+ // already-terminal races (second caller still needs to clear leases once).
556
+ const afterTerminal = completion.record ?? getProcessRecord(controllerHome, repoId, processId);
557
+ if (afterTerminal && (completion.ok || completion.reason === 'already_terminal' || afterTerminal.terminalWritten === true)) {
558
+ return releaseProcessLeasesOnce(controllerHome, repoId, processId) ?? afterTerminal;
559
+ }
560
+ return afterTerminal;
561
+ }
562
+
563
+ const DEFAULT_PRE_SPAWN_ABANDONMENT_MS = 5 * 60_000;
564
+
565
+ /**
566
+ * Reconcile a Process record that was durably created but provably never
567
+ * crossed the OS-spawn boundary. This is intentionally narrower than generic
568
+ * orphan recovery: a candidate must remain `starting`, have no captured
569
+ * identity or leases, have no descriptor and no exit receipt, and exceed a
570
+ * bounded startup grace period. Fresh or ambiguous records remain active.
571
+ */
572
+ export function reconcileAbandonedPreSpawnProcess(
573
+ controllerHome: string,
574
+ repoId: string,
575
+ processId: string,
576
+ options: { nowMs?: number; minAgeMs?: number } = {},
577
+ ): ManagedProcessRecord | undefined {
578
+ const record = getProcessRecord(controllerHome, repoId, processId);
579
+ if (!record || isManagedProcessTerminal(record) || record.status !== 'starting') return record;
580
+ if (record.identity || (record.leaseRefs?.length ?? 0) > 0) return record;
581
+ const updatedAtMs = Date.parse(record.updatedAt || record.startedAt);
582
+ const nowMs = options.nowMs ?? Date.now();
583
+ const minAgeMs = Math.max(1_000, options.minAgeMs ?? DEFAULT_PRE_SPAWN_ABANDONMENT_MS);
584
+ if (!Number.isFinite(updatedAtMs) || nowMs - updatedAtMs < minAgeMs) return record;
585
+ const descriptorPath = record.commandDescriptorPath
586
+ ?? descriptorPathFor(controllerHome, repoId, processId);
587
+ const exitReceiptPath = record.exitReceiptPath
588
+ ?? receiptPathFor(controllerHome, repoId, processId);
589
+ if (existsSync(descriptorPath) || existsSync(exitReceiptPath)) return record;
590
+ return completeProcessFromEvidence(controllerHome, repoId, processId, record.terminalFenceToken, {
591
+ status: 'failed',
592
+ exitCode: 1,
593
+ finishedAt: new Date(nowMs).toISOString(),
594
+ errorMessage: 'PROCESS_PRESPAWN_ABANDONED: no lease, descriptor, receipt, or PID after startup grace period',
595
+ }, { authority: 'durable_pre_spawn_abandonment' });
596
+ }
597
+
598
+ async function killTree(child: ChildProcess): Promise<void> {
599
+ const pid = child.pid;
600
+ if (!pid) return;
601
+ try {
602
+ if (process.platform !== 'win32') {
603
+ try {
604
+ process.kill(-pid, 'SIGTERM');
605
+ } catch {
606
+ child.kill('SIGTERM');
607
+ }
608
+ } else {
609
+ child.kill();
610
+ }
611
+ } catch {
612
+ /* already exited */
613
+ }
614
+ await terminateProcessTree(pid, { gracePeriodMs: 200, killAfterMs: 1_500, pollIntervalMs: 25 });
615
+ }
616
+
617
+ export function resolveProcessRunnerEntryPath(
618
+ execPath: string = process.execPath,
619
+ env: NodeJS.ProcessEnv = process.env,
620
+ cwd: string = process.cwd(),
621
+ ): string {
622
+ const configured = env.FORGE_PROCESS_RUNNER_ENTRY?.trim();
623
+ if (configured && existsSync(configured)) return configured;
624
+
625
+ // Compiled daemon/Gateway entrypoints and process-runner.js are siblings in
626
+ // every immutable release. Prefer that closed release surface before looking
627
+ // at source paths recorded only for diagnostics and development fallback.
628
+ const releaseSibling = join(dirname(execPath), 'process-runner.js');
629
+ if (existsSync(releaseSibling)) return releaseSibling;
630
+
631
+ try {
632
+ const here = typeof __dirname !== 'undefined'
633
+ ? __dirname
634
+ : dirname(fileURLToPath(import.meta.url));
635
+ const installed = join(here, 'process-runner.js');
636
+ if (existsSync(installed)) return installed;
637
+ const sourceSibling = join(here, 'process-runner-entry.ts');
638
+ if (existsSync(sourceSibling)) return sourceSibling;
639
+ } catch {
640
+ /* continue to source-root fallback */
641
+ }
642
+ const sourceRoot = env.FORGE_CONTROLLER_RUNTIME_SOURCE_ROOT?.trim() || cwd;
643
+ const sourceEntry = join(sourceRoot, 'src/runtime/execution/process-runtime/process-runner-entry.ts');
644
+ if (existsSync(sourceEntry)) return sourceEntry;
645
+ throw new Error('PROCESS_RUNNER_ENTRY_NOT_FOUND: immutable release is missing process-runner.js');
646
+ }
647
+ /**
648
+ * A compiled Bun executable reports the bundled forge binary through
649
+ * process.execPath. TypeScript runner entries must instead be launched by Bun.
650
+ */
651
+ export function resolveProcessRunnerRuntime(
652
+ execPath: string = process.execPath,
653
+ env: NodeJS.ProcessEnv = process.env,
654
+ ): string {
655
+ return resolveBunExecutable(execPath, env);
656
+ }
657
+
658
+ function runnerInvocation(entry: string, descriptorPath: string): { command: string; args: string[] } {
659
+ const scriptEntry = /\.(?:[cm]?js|tsx?)$/i.test(entry);
660
+ let standalone = !scriptEntry && process.env.FORGE_RUNTIME_EXECUTION === 'standalone-binary';
661
+ if (!scriptEntry && !standalone) {
662
+ try {
663
+ const manifest = JSON.parse(readFileSync(join(dirname(entry), 'manifest.json'), 'utf8')) as { executionMode?: unknown };
664
+ standalone = manifest.executionMode === 'standalone-binary';
665
+ } catch {
666
+ /* source and legacy script releases have no execution manifest */
667
+ }
668
+ }
669
+ const args = ['--descriptor', descriptorPath];
670
+ return standalone
671
+ ? { command: entry, args }
672
+ : { command: resolveProcessRunnerRuntime(), args: [entry, ...args] };
673
+ }
674
+
675
+ function commandFingerprint(command: ManagedProcessRecord['command']): string {
676
+ return createHash('sha256')
677
+ .update(JSON.stringify({
678
+ kind: command.kind,
679
+ executable: command.executable,
680
+ args: command.args,
681
+ shellCommand: command.shellCommand,
682
+ cwd: command.cwd,
683
+ }))
684
+ .digest('hex')
685
+ .slice(0, 24);
686
+ }
687
+
688
+ /**
689
+ * finalizeMonitor order (required):
690
+ * 1. close log fds (runner owns disk logs; controller may only have tails)
691
+ * 2. write/read independent exit receipt
692
+ * 3. update in-memory waiters
693
+ * 4. clear live monitor
694
+ * 5. attempt durable terminal record (writer-fenced)
695
+ */
696
+ function finalizeMonitor(
697
+ monitor: LiveMonitor,
698
+ status: ManagedProcessRecord['status'],
699
+ exitCode: number,
700
+ timedOut: boolean,
701
+ cancelled: boolean,
702
+ errorMessage?: string,
703
+ ): ManagedProcessRecord | undefined {
704
+ if (monitor.settled) {
705
+ return getProcessRecord(monitor.controllerHome, monitor.repoId, monitor.processId);
706
+ }
707
+ monitor.settled = true;
708
+ if (monitor.timeoutHandle) clearTimeout(monitor.timeoutHandle);
709
+
710
+ // Prefer runner-written receipt; fall back to controller-written receipt only
711
+ // when runner died without writing one (legacy / crash of both).
712
+ let receipt = readExitReceipt(monitor.exitReceiptPath);
713
+ if (!receipt) {
714
+ // Controller-side receipt only when runner did not produce one.
715
+ writeExitReceipt(monitor.exitReceiptPath, {
716
+ schemaVersion: 1,
717
+ processId: monitor.processId,
718
+ exitCode,
719
+ finishedAt: nowIso(),
720
+ timedOut,
721
+ cancelled,
722
+ stdoutBytes: monitor.stdoutBytes,
723
+ stderrBytes: monitor.stderrBytes,
724
+ stdoutStoredBytes: monitor.stdoutStoredBytes,
725
+ stderrStoredBytes: monitor.stderrStoredBytes,
726
+ logTruncated: monitor.logTruncated,
727
+ });
728
+ receipt = readExitReceipt(monitor.exitReceiptPath);
729
+ }
730
+
731
+ const finalStatus = receipt ? statusFromReceipt(receipt) : status;
732
+ const finalExit = receipt?.exitCode ?? exitCode;
733
+ const finalTimedOut = receipt?.timedOut ?? timedOut;
734
+ const finalCancelled = receipt?.cancelled ?? cancelled;
735
+
736
+ const stdoutBuf = monitor.stdoutTail;
737
+ const stderrBuf = monitor.stderrTail;
738
+ const stdout = capProcessOutput(safeProcessText(stdoutBuf.toString('utf8')), DEFAULT_MAX_OUTPUT_BYTES);
739
+ const stderrParts = [
740
+ safeProcessText(stderrBuf.toString('utf8')),
741
+ finalTimedOut ? 'process timed out' : '',
742
+ finalCancelled ? 'process cancelled' : '',
743
+ errorMessage ? safeProcessText(errorMessage) : '',
744
+ ].filter(Boolean);
745
+ const stderr = capProcessOutput(stderrParts.join('\n'), DEFAULT_MAX_OUTPUT_BYTES);
746
+
747
+ const enrichedPatch = {
748
+ status: finalStatus,
749
+ exitCode: finalExit ?? undefined,
750
+ timedOut: finalTimedOut,
751
+ cancelled: finalCancelled,
752
+ finishedAt: receipt?.finishedAt ?? nowIso(),
753
+ exitReceiptPath: monitor.exitReceiptPath,
754
+ stdoutBytes: receipt?.stdoutBytes ?? monitor.stdoutBytes,
755
+ stderrBytes: receipt?.stderrBytes ?? monitor.stderrBytes,
756
+ stdoutStoredBytes: receipt?.stdoutStoredBytes ?? monitor.stdoutStoredBytes,
757
+ stderrStoredBytes: receipt?.stderrStoredBytes ?? monitor.stderrStoredBytes,
758
+ logTruncated: receipt?.logTruncated ?? monitor.logTruncated,
759
+ stdoutTail: tailText(stdoutBuf, PROCESS_LOG_TAIL_BYTES),
760
+ stderrTail: tailText(Buffer.from(stderr, 'utf8'), PROCESS_LOG_TAIL_BYTES),
761
+ errorMessage,
762
+ };
763
+
764
+ // Durable terminal write is fenced; waiters still get local outcome.
765
+ const record = completeProcessFromEvidence(
766
+ monitor.controllerHome,
767
+ monitor.repoId,
768
+ monitor.processId,
769
+ monitor.fenceToken,
770
+ enrichedPatch,
771
+ );
772
+
773
+ const forWaiters: ManagedProcessRecord = {
774
+ ...(record ?? getProcessRecord(monitor.controllerHome, monitor.repoId, monitor.processId)!),
775
+ status: finalStatus,
776
+ exitCode: finalExit ?? undefined,
777
+ timedOut: finalTimedOut,
778
+ cancelled: finalCancelled,
779
+ stdoutTail: stdout.slice(-PROCESS_LOG_TAIL_BYTES),
780
+ stderrTail: stderr.slice(-PROCESS_LOG_TAIL_BYTES),
781
+ terminalWritten: record?.terminalWritten === true,
782
+ };
783
+ for (const waiter of monitor.waiters.splice(0)) waiter(forWaiters);
784
+ liveMonitors.delete(monitor.processId);
785
+ return record ?? forWaiters;
786
+ }
787
+
788
+ function attachRunnerMonitor(
789
+ record: ManagedProcessRecord,
790
+ runner: ChildProcess,
791
+ options: {
792
+ timeoutMs: number;
793
+ maxOutputBytes: number;
794
+ signal?: AbortSignal;
795
+ stdoutPath: string;
796
+ stderrPath: string;
797
+ exitReceiptPath: string;
798
+ descriptorPath: string;
799
+ },
800
+ ): LiveMonitor {
801
+ const maxDisk = options.maxOutputBytes;
802
+ const monitor: LiveMonitor = {
803
+ processId: record.processId,
804
+ repoId: record.repoId,
805
+ controllerHome: record.controllerHome,
806
+ child: runner,
807
+ fenceToken: record.terminalFenceToken,
808
+ stdoutBytes: 0,
809
+ stderrBytes: 0,
810
+ stdoutStoredBytes: 0,
811
+ stderrStoredBytes: 0,
812
+ stdoutTail: Buffer.alloc(0),
813
+ stderrTail: Buffer.alloc(0),
814
+ maxTailBytes: Math.min(options.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES),
815
+ maxDiskBytes: maxDisk,
816
+ logTruncated: false,
817
+ settled: false,
818
+ waiters: [],
819
+ stdoutPath: options.stdoutPath,
820
+ stderrPath: options.stderrPath,
821
+ exitReceiptPath: options.exitReceiptPath,
822
+ descriptorPath: options.descriptorPath,
823
+ commandFingerprint: commandFingerprint(record.command),
824
+ };
825
+
826
+ const appendStreamChunk = (stream: 'stdout' | 'stderr', chunk: Buffer): void => {
827
+ const tailKey = stream === 'stdout' ? 'stdoutTail' : 'stderrTail';
828
+ const bytesKey = stream === 'stdout' ? 'stdoutBytes' : 'stderrBytes';
829
+ const storedBytesKey = stream === 'stdout' ? 'stdoutStoredBytes' : 'stderrStoredBytes';
830
+ const currentTail = monitor[tailKey];
831
+ monitor[bytesKey] += chunk.length;
832
+ monitor[storedBytesKey] = Math.min(maxDisk, monitor[bytesKey]);
833
+ if (monitor[bytesKey] > maxDisk) monitor.logTruncated = true;
834
+ monitor[tailKey] = Buffer.concat([currentTail, chunk]).subarray(-monitor.maxTailBytes);
835
+ };
836
+
837
+ // Attached monitors receive redacted chunks directly from the independent
838
+ // Runner. After a Controller restart there is no pipe, so the receipt/file
839
+ // fallback remains authoritative.
840
+ const streaming = Boolean(runner.stdout || runner.stderr);
841
+ runner.stdout?.on('data', (chunk: Buffer) => appendStreamChunk('stdout', Buffer.from(chunk)));
842
+ runner.stderr?.on('data', (chunk: Buffer) => appendStreamChunk('stderr', Buffer.from(chunk)));
843
+
844
+ // Legacy runners without the optional stream transport retain a coarse file
845
+ // fallback. New runners never enter this timer while attached.
846
+ const pollLogs = () => {
847
+ if (monitor.settled) return;
848
+ try {
849
+ if (existsSync(options.stdoutPath)) {
850
+ const st = statSync(options.stdoutPath);
851
+ monitor.stdoutStoredBytes = st.size;
852
+ monitor.stdoutBytes = Math.max(monitor.stdoutBytes, st.size);
853
+ if (st.size >= maxDisk) monitor.logTruncated = true;
854
+ const tail = readFileTailBytes(options.stdoutPath, monitor.maxTailBytes);
855
+ monitor.stdoutTail = Buffer.from(tail.text, 'utf8');
856
+ }
857
+ if (existsSync(options.stderrPath)) {
858
+ const st = statSync(options.stderrPath);
859
+ monitor.stderrStoredBytes = st.size;
860
+ monitor.stderrBytes = Math.max(monitor.stderrBytes, st.size);
861
+ if (st.size >= maxDisk) monitor.logTruncated = true;
862
+ const tail = readFileTailBytes(options.stderrPath, monitor.maxTailBytes);
863
+ monitor.stderrTail = Buffer.from(tail.text, 'utf8');
864
+ }
865
+ } catch {
866
+ /* best-effort */
867
+ }
868
+ };
869
+ const pollTimer = streaming ? undefined : setInterval(pollLogs, 1_000);
870
+ pollTimer?.unref?.();
871
+
872
+ const onAbort = () => {
873
+ void killTree(runner).finally(() => {
874
+ if (pollTimer) clearInterval(pollTimer);
875
+ finalizeMonitor(monitor, 'cancelled', 1, false, true, 'cancelled by signal');
876
+ });
877
+ };
878
+ options.signal?.addEventListener('abort', onAbort, { once: true });
879
+
880
+ // Controller-side timeout is a safety net; runner also enforces timeout.
881
+ monitor.timeoutHandle = setTimeout(() => {
882
+ void killTree(runner).finally(() => {
883
+ if (pollTimer) clearInterval(pollTimer);
884
+ finalizeMonitor(monitor, 'timed_out', 1, true, false, `process timed out after ${options.timeoutMs}ms`);
885
+ });
886
+ }, Math.max(1, options.timeoutMs + 2_000));
887
+ monitor.timeoutHandle.unref?.();
888
+
889
+ runner.on('error', (error) => {
890
+ if (pollTimer) clearInterval(pollTimer);
891
+ finalizeMonitor(monitor, 'failed', 1, false, false, error.message);
892
+ options.signal?.removeEventListener('abort', onAbort);
893
+ });
894
+ runner.on('close', () => {
895
+ options.signal?.removeEventListener('abort', onAbort);
896
+ if (pollTimer) clearInterval(pollTimer);
897
+ pollLogs();
898
+ const receipt = readExitReceipt(options.exitReceiptPath);
899
+ if (receipt) {
900
+ finalizeMonitor(
901
+ monitor,
902
+ statusFromReceipt(receipt),
903
+ receipt.exitCode ?? 1,
904
+ receipt.timedOut === true,
905
+ receipt.cancelled === true,
906
+ );
907
+ return;
908
+ }
909
+ // Runner exited without receipt. The actual command may have succeeded or
910
+ // failed, so do not fabricate a deterministic failure.
911
+ finalizeMonitor(monitor, 'completed_unknown', 1, false, false, 'process runner exited without receipt; outcome unknown');
912
+ });
913
+
914
+ liveMonitors.set(record.processId, monitor);
915
+ return monitor;
916
+ }
917
+
918
+ function recordToHandle(
919
+ record: ManagedProcessRecord,
920
+ extras?: { stdout?: string; stderr?: string; completed?: boolean; deduplicated?: boolean; semanticDeduplicated?: boolean },
921
+ ): ProcessHandle {
922
+ const hintedStatus = effectiveProcessStatus(record, extras?.completed === true);
923
+ const completed = isTerminalProcessStatus(hintedStatus) || record.terminalWritten === true;
924
+ const coherentRecord = hintedStatus === record.status ? record : { ...record, status: hintedStatus };
925
+ const safeRecord = completed ? sanitizeTerminalProcessArtifacts(coherentRecord) : coherentRecord;
926
+ // Short-lived runner output can be fully buffered by the streaming redactor,
927
+ // leaving the Controller-side pipe tail empty even though the authoritative
928
+ // bounded log file was persisted before the exit receipt. A terminal handle
929
+ // must therefore fall back to the durable log, including after Controller
930
+ // recreation, instead of projecting an empty string as final output.
931
+ const persistedStdout = completed && !(extras?.stdout || safeRecord.stdoutTail) && safeRecord.stdoutPath
932
+ ? readFileTailBytes(safeRecord.stdoutPath, PROCESS_LOG_TAIL_BYTES).text
933
+ : undefined;
934
+ const persistedStderr = completed && !(extras?.stderr || safeRecord.stderrTail) && safeRecord.stderrPath
935
+ ? readFileTailBytes(safeRecord.stderrPath, PROCESS_LOG_TAIL_BYTES).text
936
+ : undefined;
937
+ return {
938
+ processId: safeRecord.processId,
939
+ workId: safeRecord.workId,
940
+ commandId: safeRecord.commandId ?? safeRecord.processId,
941
+ status: safeRecord.status,
942
+ contractStatus: processContractStatus(safeRecord.status),
943
+ route: safeRecord.route,
944
+ pid: safeRecord.identity?.pid,
945
+ startedAt: safeRecord.startedAt,
946
+ interactiveWaitMs: safeRecord.interactiveWaitMs,
947
+ timeoutMs: safeRecord.timeoutMs,
948
+ completed,
949
+ deduplicated: extras?.deduplicated,
950
+ semanticDeduplicated: extras?.semanticDeduplicated,
951
+ requestId: safeRecord.origin?.requestId,
952
+ ok: completed ? safeRecord.status === 'succeeded' : undefined,
953
+ exitCode: safeRecord.exitCode,
954
+ timedOut: safeRecord.timedOut,
955
+ cancelled: safeRecord.cancelled,
956
+ stdout: extras?.stdout
957
+ ? safeProcessText(extras.stdout)
958
+ : completed ? safeProcessText(safeRecord.stdoutTail || persistedStdout || '') : undefined,
959
+ stderr: extras?.stderr
960
+ ? safeProcessText(extras.stderr)
961
+ : completed ? safeProcessText(safeRecord.stderrTail || persistedStderr || '') : undefined,
962
+ stdoutTail: safeRecord.stdoutTail ? safeProcessText(safeRecord.stdoutTail) : undefined,
963
+ stderrTail: safeRecord.stderrTail ? safeProcessText(safeRecord.stderrTail) : undefined,
964
+ durableSideEffects: {
965
+ executionJobCount: 0,
966
+ localJobCount: 0,
967
+ workerSpawnCount: 0,
968
+ projectionUpdateCount: 0,
969
+ },
970
+ };
971
+ }
972
+
973
+ export function processRunnerEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
974
+ return repositoryChildProcessEnvironment(env);
975
+ }
976
+
977
+ function spawnProcessRunner(descriptor: ProcessCommandDescriptor, descriptorPath: string): ChildProcess {
978
+ mkdirSync(dirname(descriptorPath), { recursive: true });
979
+ writeFileSync(descriptorPath, `${JSON.stringify(descriptor, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
980
+ const entry = resolveProcessRunnerEntryPath();
981
+ const useProcessGroup = process.platform !== 'win32';
982
+ const invocation = runnerInvocation(entry, descriptorPath);
983
+ // Detached so Controller crash does not kill the runner.
984
+ return spawn(invocation.command, invocation.args, {
985
+ cwd: descriptor.command.cwd,
986
+ env: {
987
+ ...processRunnerEnvironment(),
988
+ FORGE_PROCESS_RUNNER: '1',
989
+ },
990
+ // The Runner always keeps bounded durable files. While Controller is
991
+ // attached, also stream redacted chunks over pipes so it does not stat and
992
+ // reread both log files every 100ms.
993
+ stdio: ['ignore', descriptor.streamLogs ? 'pipe' : 'ignore', descriptor.streamLogs ? 'pipe' : 'ignore'],
994
+ detached: useProcessGroup,
995
+ });
996
+ }
997
+
998
+ /**
999
+ * Spawn once via independent Process Runner.
1000
+ * If the process finishes within interactiveWaitMs, return a completed Direct handle.
1001
+ * Otherwise return a Managed handle for the same process (no re-exec).
1002
+ *
1003
+ * Ordering:
1004
+ * classify claims → acquire execution leases → spawn Runner
1005
+ * Terminal:
1006
+ * receipt / cancel / timeout / failure → release leases exactly once
1007
+ */
1008
+ export async function spawnManagedProcess(input: SpawnManagedProcessInput): Promise<ProcessHandle> {
1009
+ if (!input.executionIdentity) {
1010
+ throw new Error('EXECUTION_IDENTITY_REQUIRED: spawnManagedProcess requires an immutable executionIdentity');
1011
+ }
1012
+ if (input.executionIdentity.repositoryId !== input.repoId) {
1013
+ throw new Error(
1014
+ `EXECUTION_IDENTITY_MISMATCH: process repo ${input.repoId} differs from identity ${input.executionIdentity.repositoryId}`,
1015
+ );
1016
+ }
1017
+ if (input.checkoutId && input.executionIdentity.checkoutId !== input.checkoutId) {
1018
+ throw new Error(
1019
+ `CHECKOUT_ROUTE_MISMATCH: process checkout ${input.checkoutId} differs from identity ${input.executionIdentity.checkoutId}`,
1020
+ );
1021
+ }
1022
+ const guardedIdentity = assertExecutionIdentity({
1023
+ controllerHome: input.controllerHome,
1024
+ identity: input.executionIdentity,
1025
+ cwd: input.command.cwd,
1026
+ requestedRepoId: input.repoId,
1027
+ requestedCheckoutId: input.checkoutId,
1028
+ });
1029
+ const command = { ...input.command, cwd: guardedIdentity.resolvedCwd };
1030
+ const interactiveWaitMs = Math.max(
1031
+ 0,
1032
+ Math.min(input.interactiveWaitMs ?? DEFAULT_INTERACTIVE_WAIT_MS, 120_000),
1033
+ );
1034
+ const timeoutMs = Math.max(
1035
+ interactiveWaitMs + 1,
1036
+ Math.min(input.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS, 24 * 60 * 60_000),
1037
+ );
1038
+ const maxOutputBytes = Math.max(4_096, Math.min(input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, 8 * 1024 * 1024));
1039
+ const candidateProcessId = newProcessId();
1040
+ const requestId = input.origin?.requestId?.trim() || undefined;
1041
+ const commandFingerprint = fingerprintProcessCommand(command);
1042
+ let processId = candidateProcessId;
1043
+ let semanticExisting = false;
1044
+ if (input.checkExecution) {
1045
+ const semantic = claimProcessCheckExecution({
1046
+ controllerHome: input.controllerHome,
1047
+ repoId: input.repoId,
1048
+ sourceCheckoutId: input.checkoutId,
1049
+ scopeKey: input.checkExecution.scopeKey,
1050
+ cacheKey: input.checkExecution.cacheKey,
1051
+ processId: candidateProcessId,
1052
+ });
1053
+ processId = semantic.binding.processId;
1054
+ semanticExisting = semantic.status === 'existing';
1055
+ }
1056
+ if (requestId) {
1057
+ const claim = claimProcessRequest({
1058
+ controllerHome: input.controllerHome,
1059
+ repoId: input.repoId,
1060
+ checkoutId: input.checkoutId,
1061
+ requestId,
1062
+ commandFingerprint,
1063
+ processId,
1064
+ });
1065
+ processId = claim.binding.processId;
1066
+ if (claim.status === 'existing') {
1067
+ const existing = getProcessHandle(input.controllerHome, input.repoId, processId);
1068
+ if (!existing) {
1069
+ throw new Error(`PROCESS_REQUEST_INCOMPLETE: request ${requestId} is bound to missing process ${processId}; refusing re-execution`);
1070
+ }
1071
+ return { ...existing, deduplicated: true, semanticDeduplicated: semanticExisting || existing.semanticDeduplicated, requestId };
1072
+ }
1073
+ }
1074
+ if (semanticExisting) {
1075
+ let existing = getProcessHandle(input.controllerHome, input.repoId, processId);
1076
+ for (let attempt = 0; !existing && attempt < 20; attempt += 1) {
1077
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
1078
+ existing = getProcessHandle(input.controllerHome, input.repoId, processId);
1079
+ }
1080
+ if (!existing) {
1081
+ throw new Error(`PROCESS_CHECK_EXECUTION_INCOMPLETE: semantic Check binding points to missing process ${processId}; refusing duplicate execution`);
1082
+ }
1083
+ return { ...existing, deduplicated: true, semanticDeduplicated: true, requestId };
1084
+ }
1085
+ const fenceToken = 1;
1086
+ const startedAt = nowIso();
1087
+ const logDir = processLogDir(input.controllerHome, input.repoId);
1088
+ mkdirSync(logDir, { recursive: true });
1089
+ const stdoutPath = join(logDir, `${processId}.stdout.log`);
1090
+ const stderrPath = join(logDir, `${processId}.stderr.log`);
1091
+ const exitReceiptPath = receiptPathFor(input.controllerHome, input.repoId, processId);
1092
+ const descriptorPath = descriptorPathFor(input.controllerHome, input.repoId, processId);
1093
+ const resourceClaims = toProcessClaims(scopeResourceClaims(
1094
+ toResourceClaimSpecs(input.resourceClaims ?? []),
1095
+ input.repoId,
1096
+ input.checkoutId,
1097
+ input.workId,
1098
+ ));
1099
+
1100
+ const runtimeClaim = getRuntimeWriteClaim();
1101
+ const record: ManagedProcessRecord = {
1102
+ schemaVersion: 1,
1103
+ processId,
1104
+ repoId: input.repoId,
1105
+ checkoutId: input.checkoutId,
1106
+ workId: input.workId,
1107
+ commandId: input.commandId?.trim() || processId,
1108
+ requestFingerprint: requestId ? commandFingerprint : undefined,
1109
+ checkExecution: input.checkExecution,
1110
+ controllerHome: input.controllerHome,
1111
+ status: 'starting',
1112
+ route: input.returnHandleImmediately || interactiveWaitMs === 0 ? 'managed' : 'direct',
1113
+ command,
1114
+ resourceClaims,
1115
+ interactiveWaitMs,
1116
+ timeoutMs,
1117
+ maxOutputBytes,
1118
+ startedAt,
1119
+ updatedAt: startedAt,
1120
+ terminalFenceToken: fenceToken,
1121
+ runtimeInstanceId: input.runtimeInstanceId ?? runtimeClaim?.runtimeInstanceId,
1122
+ releaseAuthorityRevision: input.releaseAuthorityRevision ?? runtimeClaim?.releaseAuthorityRevision,
1123
+ releaseId: input.releaseId ?? runtimeClaim?.releaseId,
1124
+ artifactIdentity: input.artifactIdentity ?? runtimeClaim?.artifactIdentity,
1125
+ workerProtocolVersion: input.workerProtocolVersion ?? runtimeClaim?.workerProtocolVersion,
1126
+ origin: input.origin,
1127
+ exitReceiptPath,
1128
+ stdoutPath,
1129
+ stderrPath,
1130
+ commandDescriptorPath: descriptorPath,
1131
+ logPath: stdoutPath,
1132
+ };
1133
+ createProcessRecord(record);
1134
+
1135
+ if (input.signal?.aborted) {
1136
+ completeProcessFromEvidence(input.controllerHome, input.repoId, processId, fenceToken, {
1137
+ status: 'cancelled',
1138
+ exitCode: 1,
1139
+ cancelled: true,
1140
+ errorMessage: 'cancelled before spawn',
1141
+ exitReceiptPath,
1142
+ });
1143
+ const cancelled = getProcessRecord(input.controllerHome, input.repoId, processId)!;
1144
+ return recordToHandle(cancelled, { completed: true, stdout: '', stderr: 'cancelled before spawn' });
1145
+ }
1146
+
1147
+ // Refuse to re-exec if receipt already exists (exactly-once).
1148
+ if (existsSync(exitReceiptPath)) {
1149
+ const receipt = readExitReceipt(exitReceiptPath);
1150
+ if (receipt) {
1151
+ const completed = completeProcessFromEvidence(input.controllerHome, input.repoId, processId, fenceToken, {
1152
+ status: statusFromReceipt(receipt),
1153
+ exitCode: receipt.exitCode,
1154
+ timedOut: receipt.timedOut,
1155
+ cancelled: receipt.cancelled,
1156
+ finishedAt: receipt.finishedAt,
1157
+ exitReceiptPath,
1158
+ stdoutBytes: receipt.stdoutBytes,
1159
+ stderrBytes: receipt.stderrBytes,
1160
+ logTruncated: receipt.logTruncated,
1161
+ });
1162
+ return recordToHandle(completed!, { completed: true });
1163
+ }
1164
+ // Corrupt receipt present: do not re-exec; surface completed_unknown.
1165
+ const completed = completeProcessFromEvidence(input.controllerHome, input.repoId, processId, fenceToken, {
1166
+ status: 'completed_unknown',
1167
+ exitCode: 1,
1168
+ exitReceiptPath,
1169
+ errorMessage: 'exit receipt exists but is corrupt; refusing re-exec',
1170
+ });
1171
+ return recordToHandle(completed!, { completed: true });
1172
+ }
1173
+
1174
+ // Acquire real execution leases BEFORE spawning the runner. Fail closed on conflict.
1175
+ let leaseRefs: ProcessLeaseRef[] = [];
1176
+ if (resourceClaims.length > 0) {
1177
+ let acquisition: ReturnType<typeof acquireExecutionLeases>;
1178
+ try {
1179
+ acquisition = acquireExecutionLeases(
1180
+ input.controllerHome,
1181
+ input.repoId,
1182
+ processOwnerJobId(processId),
1183
+ toResourceClaimSpecs(resourceClaims),
1184
+ {
1185
+ ttlMs: Math.max(30_000, timeoutMs + 30_000),
1186
+ visibility: 'ephemeral',
1187
+ notifyScheduler: false,
1188
+ invalidateProjection: false,
1189
+ emitRuntimeEvent: false,
1190
+ },
1191
+ );
1192
+ } catch (error) {
1193
+ const message = error instanceof Error ? error.message : String(error);
1194
+ const failed = completeProcessFromEvidence(input.controllerHome, input.repoId, processId, fenceToken, {
1195
+ status: 'failed',
1196
+ exitCode: 1,
1197
+ errorMessage: `PROCESS_LEASE_ACQUISITION_FAILED_BEFORE_SPAWN: ${message}`,
1198
+ exitReceiptPath,
1199
+ }, { authority: 'durable_pre_spawn_abandonment' });
1200
+ return recordToHandle(failed ?? record, { completed: true, stdout: '', stderr: message });
1201
+ }
1202
+ if (!acquisition.acquired) {
1203
+ // A Runtime cutover can outlive the Controller monitor for the command
1204
+ // that requested the cutover: the independent Process Runner writes its
1205
+ // durable receipt after the new Runtime has already completed startup
1206
+ // recovery. Reconcile only the exact Process owners that block this
1207
+ // acquisition, then retry once. Live/identity-matching processes remain
1208
+ // active and keep their leases; no fence is bypassed.
1209
+ const blockingProcessIds = [...new Set(acquisition.blockers
1210
+ .map((blocker) => blocker.ownerJobId.startsWith('process:') ? blocker.ownerJobId.slice('process:'.length) : '')
1211
+ .filter(Boolean))];
1212
+ if (blockingProcessIds.length > 0) {
1213
+ for (const blockingProcessId of blockingProcessIds) {
1214
+ getProcessHandle(input.controllerHome, input.repoId, blockingProcessId);
1215
+ }
1216
+ acquisition = acquireExecutionLeases(
1217
+ input.controllerHome,
1218
+ input.repoId,
1219
+ processOwnerJobId(processId),
1220
+ toResourceClaimSpecs(resourceClaims),
1221
+ {
1222
+ ttlMs: Math.max(30_000, timeoutMs + 30_000),
1223
+ visibility: 'ephemeral',
1224
+ notifyScheduler: false,
1225
+ invalidateProjection: false,
1226
+ emitRuntimeEvent: false,
1227
+ },
1228
+ );
1229
+ }
1230
+ }
1231
+ // Minimal bounded queue behavior: when the caller opts in, wait for
1232
+ // conflicting leases to release instead of failing immediately. The Lease
1233
+ // store remains the single conflict authority; this loop only polls it.
1234
+ const leaseWaitMs = Math.max(
1235
+ 0,
1236
+ Math.min(Math.trunc(input.leaseWaitMs ?? 0), 30_000),
1237
+ );
1238
+ if (!acquisition.acquired && leaseWaitMs > 0) {
1239
+ const leaseDeadline = Date.now() + leaseWaitMs;
1240
+ while (!acquisition.acquired && Date.now() < leaseDeadline) {
1241
+ if (input.signal?.aborted) break;
1242
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
1243
+ acquisition = acquireExecutionLeases(
1244
+ input.controllerHome,
1245
+ input.repoId,
1246
+ processOwnerJobId(processId),
1247
+ toResourceClaimSpecs(resourceClaims),
1248
+ {
1249
+ ttlMs: Math.max(30_000, timeoutMs + 30_000),
1250
+ visibility: 'ephemeral',
1251
+ notifyScheduler: false,
1252
+ invalidateProjection: false,
1253
+ emitRuntimeEvent: false,
1254
+ },
1255
+ );
1256
+ }
1257
+ }
1258
+ if (!acquisition.acquired) {
1259
+ const blockers = acquisition.blockers
1260
+ .map((b) => `${b.resourceKey}@${b.ownerJobId}`)
1261
+ .join(', ');
1262
+ completeProcessFromEvidence(input.controllerHome, input.repoId, processId, fenceToken, {
1263
+ status: 'failed',
1264
+ exitCode: 1,
1265
+ errorMessage: `PROCESS_LEASE_CONFLICT: ${blockers || 'resource busy'}`,
1266
+ exitReceiptPath,
1267
+ });
1268
+ const failed = getProcessRecord(input.controllerHome, input.repoId, processId)!;
1269
+ return recordToHandle(failed, {
1270
+ completed: true,
1271
+ stdout: '',
1272
+ stderr: failed.error?.message ?? `PROCESS_LEASE_CONFLICT: ${blockers}`,
1273
+ });
1274
+ }
1275
+ leaseRefs = acquisition.leases.map((lease) => ({
1276
+ leaseId: lease.leaseId,
1277
+ resourceKey: lease.resourceKey,
1278
+ fencingToken: lease.fencingToken,
1279
+ expiresAt: lease.expiresAt,
1280
+ repoId: lease.repoId,
1281
+ checkoutId: lease.checkoutId,
1282
+ workId: lease.workId,
1283
+ }));
1284
+ updateProcessRecord(input.controllerHome, input.repoId, processId, { leaseRefs });
1285
+ }
1286
+
1287
+ const descriptor: ProcessCommandDescriptor = {
1288
+ schemaVersion: 1,
1289
+ processId,
1290
+ repoId: input.repoId,
1291
+ controllerHome: input.controllerHome,
1292
+ command,
1293
+ timeoutMs,
1294
+ maxStdoutBytes: maxOutputBytes,
1295
+ maxStderrBytes: maxOutputBytes,
1296
+ stdoutPath,
1297
+ stderrPath,
1298
+ exitReceiptPath,
1299
+ startedAt,
1300
+ streamLogs: true,
1301
+ };
1302
+
1303
+ let runner: ChildProcess;
1304
+ try {
1305
+ runner = spawnProcessRunner(descriptor, descriptorPath);
1306
+ } catch (error) {
1307
+ // Spawn failed — release any leases acquired above.
1308
+ releaseProcessLeasesOnce(input.controllerHome, input.repoId, processId);
1309
+ const message = error instanceof Error ? error.message : String(error);
1310
+ completeProcessFromEvidence(input.controllerHome, input.repoId, processId, fenceToken, {
1311
+ status: 'failed',
1312
+ exitCode: 1,
1313
+ errorMessage: message,
1314
+ exitReceiptPath,
1315
+ });
1316
+ const failed = getProcessRecord(input.controllerHome, input.repoId, processId)!;
1317
+ return recordToHandle(failed, { completed: true, stdout: '', stderr: message });
1318
+ }
1319
+
1320
+ // Unref so controller event loop can exit independently of long-lived runners
1321
+ // only after we have identity — keep ref while monitoring.
1322
+ try {
1323
+ runner.unref?.();
1324
+ } catch {
1325
+ /* ignore */
1326
+ }
1327
+
1328
+ // Immediate spawn failure (no PID) → release leases.
1329
+ if (!runner.pid) {
1330
+ releaseProcessLeasesOnce(input.controllerHome, input.repoId, processId);
1331
+ completeProcessFromEvidence(input.controllerHome, input.repoId, processId, fenceToken, {
1332
+ status: 'failed',
1333
+ exitCode: 1,
1334
+ errorMessage: 'process runner failed to spawn (no pid)',
1335
+ exitReceiptPath,
1336
+ });
1337
+ const failed = getProcessRecord(input.controllerHome, input.repoId, processId)!;
1338
+ return recordToHandle(failed, { completed: true, stdout: '', stderr: 'process runner failed to spawn' });
1339
+ }
1340
+
1341
+ const captured = captureIdentity(runner.pid);
1342
+ const runningRecord = updateProcessRecord(input.controllerHome, input.repoId, processId, {
1343
+ status: 'running',
1344
+ identity: captured.identity,
1345
+ identityUntrusted: captured.identityUntrusted === true,
1346
+ exitReceiptPath,
1347
+ logPath: stdoutPath,
1348
+ stdoutPath,
1349
+ stderrPath,
1350
+ leaseRefs,
1351
+ })!;
1352
+
1353
+ const monitor = attachRunnerMonitor(
1354
+ { ...record, status: 'running', identity: captured.identity, leaseRefs },
1355
+ runner,
1356
+ {
1357
+ timeoutMs,
1358
+ maxOutputBytes,
1359
+ signal: input.signal,
1360
+ stdoutPath,
1361
+ stderrPath,
1362
+ exitReceiptPath,
1363
+ descriptorPath,
1364
+ },
1365
+ );
1366
+
1367
+ if (input.returnHandleImmediately || interactiveWaitMs === 0) {
1368
+ // The initial record already chose route=managed for this branch; reuse the
1369
+ // running-state write above instead of performing another durable write+read.
1370
+ return recordToHandle({ ...runningRecord, route: 'managed' }, { completed: false });
1371
+ }
1372
+
1373
+ const completed = await new Promise<ManagedProcessRecord | 'timeout'>((resolve) => {
1374
+ const timer = setTimeout(() => resolve('timeout'), interactiveWaitMs);
1375
+ timer.unref?.();
1376
+ monitor.waiters.push((done) => {
1377
+ clearTimeout(timer);
1378
+ resolve(done);
1379
+ });
1380
+ if (monitor.settled) {
1381
+ const current = getProcessRecord(input.controllerHome, input.repoId, processId);
1382
+ if (current?.terminalWritten) {
1383
+ clearTimeout(timer);
1384
+ resolve(current);
1385
+ }
1386
+ }
1387
+ });
1388
+
1389
+ if (completed === 'timeout') {
1390
+ updateProcessRecord(input.controllerHome, input.repoId, processId, { route: 'managed' });
1391
+ const current = getProcessRecord(input.controllerHome, input.repoId, processId)!;
1392
+ return recordToHandle({ ...current, route: 'managed' }, { completed: false });
1393
+ }
1394
+
1395
+ const stdout = monitor.stdoutTail.length
1396
+ ? capProcessOutput(safeProcessText(monitor.stdoutTail.toString('utf8')), maxOutputBytes)
1397
+ : completed.stdoutTail ?? '';
1398
+ const stderr = monitor.stderrTail.length
1399
+ ? capProcessOutput(safeProcessText(monitor.stderrTail.toString('utf8')), maxOutputBytes)
1400
+ : completed.stderrTail ?? '';
1401
+ return recordToHandle(completed, { completed: true, stdout, stderr });
1402
+ }
1403
+
1404
+ function applyReceiptIfPresent(
1405
+ controllerHome: string,
1406
+ repoId: string,
1407
+ processId: string,
1408
+ record: ManagedProcessRecord,
1409
+ ): ManagedProcessRecord | undefined {
1410
+ const receipt = readExitReceipt(record.exitReceiptPath) ?? readExitReceipt(receiptPathFor(controllerHome, repoId, processId));
1411
+ if (!receipt) return undefined;
1412
+ if (receipt.processId !== processId) return undefined;
1413
+ if (!receipt.finishedAt || !Number.isFinite(Date.parse(receipt.finishedAt))) return undefined;
1414
+ if (receipt.commandExecutedOnce === false) return undefined;
1415
+ if (receipt.exitCode !== null && !Number.isInteger(receipt.exitCode)) return undefined;
1416
+ return completeProcessFromEvidence(controllerHome, repoId, processId, record.terminalFenceToken, {
1417
+ status: statusFromReceipt(receipt),
1418
+ exitCode: receipt.exitCode,
1419
+ timedOut: receipt.timedOut,
1420
+ cancelled: receipt.cancelled,
1421
+ finishedAt: receipt.finishedAt,
1422
+ exitReceiptPath: record.exitReceiptPath ?? receiptPathFor(controllerHome, repoId, processId),
1423
+ stdoutBytes: receipt.stdoutBytes,
1424
+ stderrBytes: receipt.stderrBytes,
1425
+ stdoutStoredBytes: receipt.stdoutStoredBytes,
1426
+ stderrStoredBytes: receipt.stderrStoredBytes,
1427
+ logTruncated: receipt.logTruncated,
1428
+ }, { authority: 'durable_exit_receipt' });
1429
+ }
1430
+
1431
+ export function getProcessHandle(
1432
+ controllerHome: string,
1433
+ repoId: string,
1434
+ processId: string,
1435
+ ): ProcessHandle | undefined {
1436
+ const record = getProcessRecord(controllerHome, repoId, processId);
1437
+ if (!record) return undefined;
1438
+ if (isManagedProcessActive(record) && !liveMonitors.has(processId)) {
1439
+ const fromReceipt = applyReceiptIfPresent(controllerHome, repoId, processId, record);
1440
+ if (fromReceipt) return recordToHandle(fromReceipt, { completed: true });
1441
+ if (!identityStillMatches(record.identity, record.identityUntrusted)) {
1442
+ const completed = completeProcessFromEvidence(controllerHome, repoId, processId, record.terminalFenceToken, {
1443
+ status: 'completed_unknown',
1444
+ errorMessage: 'process no longer matches stored identity; exit code unknown',
1445
+ });
1446
+ return completed ? recordToHandle(completed, { completed: true }) : undefined;
1447
+ }
1448
+ }
1449
+ return recordToHandle(getProcessRecord(controllerHome, repoId, processId)!);
1450
+ }
1451
+
1452
+ export async function waitForProcess(
1453
+ controllerHome: string,
1454
+ repoId: string,
1455
+ processId: string,
1456
+ options: WaitProcessOptions = {},
1457
+ ): Promise<ProcessHandle> {
1458
+ const existing = getProcessRecord(controllerHome, repoId, processId);
1459
+ if (!existing) throw new Error(`PROCESS_NOT_FOUND: ${processId}`);
1460
+ if (existing.terminalWritten) return recordToHandle(existing, { completed: true });
1461
+
1462
+ const monitor = liveMonitors.get(processId);
1463
+ if (monitor) {
1464
+ const waitMs = options.timeoutMs ?? existing.timeoutMs;
1465
+ const done = await new Promise<ManagedProcessRecord | 'timeout'>((resolve) => {
1466
+ const timer = setTimeout(() => resolve('timeout'), Math.max(1, waitMs));
1467
+ timer.unref?.();
1468
+ const onAbort = () => {
1469
+ clearTimeout(timer);
1470
+ void cancelProcess(controllerHome, repoId, processId).then(() => {
1471
+ const current = getProcessRecord(controllerHome, repoId, processId);
1472
+ if (current) resolve(current);
1473
+ else resolve('timeout');
1474
+ });
1475
+ };
1476
+ options.signal?.addEventListener('abort', onAbort, { once: true });
1477
+ monitor.waiters.push((record) => {
1478
+ clearTimeout(timer);
1479
+ options.signal?.removeEventListener('abort', onAbort);
1480
+ resolve(record);
1481
+ });
1482
+ if (monitor.settled) {
1483
+ const current = getProcessRecord(controllerHome, repoId, processId);
1484
+ if (current?.terminalWritten) {
1485
+ clearTimeout(timer);
1486
+ resolve(current);
1487
+ }
1488
+ }
1489
+ });
1490
+ if (done === 'timeout') {
1491
+ return recordToHandle(getProcessRecord(controllerHome, repoId, processId)!, { completed: false });
1492
+ }
1493
+ return recordToHandle(done, { completed: true });
1494
+ }
1495
+
1496
+ // No live monitor — poll receipt / identity (Controller restart attach path).
1497
+ const deadline = Date.now() + Math.max(1, options.timeoutMs ?? 15_000);
1498
+ let pollIntervalMs = 100;
1499
+ while (Date.now() < deadline) {
1500
+ const current = getProcessRecord(controllerHome, repoId, processId);
1501
+ if (!current) throw new Error(`PROCESS_NOT_FOUND: ${processId}`);
1502
+ if (current.terminalWritten) return recordToHandle(current, { completed: true });
1503
+ const fromReceipt = applyReceiptIfPresent(controllerHome, repoId, processId, current);
1504
+ if (fromReceipt) return recordToHandle(fromReceipt, { completed: true });
1505
+ if (!identityStillMatches(current.identity, current.identityUntrusted)) {
1506
+ // The independent Runner atomically writes its durable receipt before it
1507
+ // exits, but process identity observation and filesystem visibility can
1508
+ // cross during restart attach. Give the receipt a short bounded grace
1509
+ // window before committing the irreversible completed_unknown fallback.
1510
+ const receiptGraceDeadline = Math.min(deadline, Date.now() + PROCESS_RECOVERY_RECEIPT_GRACE_MS);
1511
+ let identityRecovered = false;
1512
+ while (Date.now() < receiptGraceDeadline) {
1513
+ await new Promise((r) => setTimeout(r, PROCESS_RECOVERY_RECEIPT_POLL_MS));
1514
+ const latest = getProcessRecord(controllerHome, repoId, processId);
1515
+ if (!latest) throw new Error(`PROCESS_NOT_FOUND: ${processId}`);
1516
+ if (latest.terminalWritten) return recordToHandle(latest, { completed: true });
1517
+ const delayedReceipt = applyReceiptIfPresent(controllerHome, repoId, processId, latest);
1518
+ if (delayedReceipt) return recordToHandle(delayedReceipt, { completed: true });
1519
+ if (identityStillMatches(latest.identity, latest.identityUntrusted)) {
1520
+ identityRecovered = true;
1521
+ break;
1522
+ }
1523
+ }
1524
+ if (identityRecovered) continue;
1525
+ const latest = getProcessRecord(controllerHome, repoId, processId)!;
1526
+ const delayedReceipt = applyReceiptIfPresent(controllerHome, repoId, processId, latest);
1527
+ if (delayedReceipt) return recordToHandle(delayedReceipt, { completed: true });
1528
+ if (identityStillMatches(latest.identity, latest.identityUntrusted)) continue;
1529
+ const completed = completeProcessFromEvidence(controllerHome, repoId, processId, latest.terminalFenceToken, {
1530
+ status: 'completed_unknown',
1531
+ errorMessage: 'process exited while controller was offline and no exit receipt was found after receipt grace period',
1532
+ });
1533
+ return recordToHandle(completed!, { completed: true });
1534
+ }
1535
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
1536
+ pollIntervalMs = Math.min(1_000, pollIntervalMs * 2);
1537
+ }
1538
+ return recordToHandle(getProcessRecord(controllerHome, repoId, processId)!, { completed: false });
1539
+ }
1540
+
1541
+ export async function cancelProcess(
1542
+ controllerHome: string,
1543
+ repoId: string,
1544
+ processId: string,
1545
+ ): Promise<ProcessHandle> {
1546
+ const record = getProcessRecord(controllerHome, repoId, processId);
1547
+ if (!record) throw new Error(`PROCESS_NOT_FOUND: ${processId}`);
1548
+ if (record.terminalWritten) return recordToHandle(record, { completed: true });
1549
+
1550
+ // Process control is itself a writer mutation. Fence BEFORE sending any
1551
+ // signal so a passive/stale runtime cannot kill the active runtime's work.
1552
+ assertRuntimeMayWriteOrThrow('cancel_process', controllerHome);
1553
+
1554
+ const monitor = liveMonitors.get(processId);
1555
+ if (monitor) {
1556
+ await killTree(monitor.child);
1557
+ await new Promise((r) => setTimeout(r, 100));
1558
+ const after = getProcessRecord(controllerHome, repoId, processId);
1559
+ if (after && !after.terminalWritten) {
1560
+ // Write controller-side cancel receipt if runner did not.
1561
+ const path = after.exitReceiptPath ?? receiptPathFor(controllerHome, repoId, processId);
1562
+ if (!existsSync(path)) {
1563
+ writeExitReceipt(path, {
1564
+ schemaVersion: 1,
1565
+ processId,
1566
+ exitCode: 1,
1567
+ finishedAt: nowIso(),
1568
+ cancelled: true,
1569
+ });
1570
+ }
1571
+ completeProcessFromEvidence(controllerHome, repoId, processId, after.terminalFenceToken, {
1572
+ status: 'cancelled',
1573
+ exitCode: 1,
1574
+ cancelled: true,
1575
+ exitReceiptPath: path,
1576
+ });
1577
+ }
1578
+ return recordToHandle(getProcessRecord(controllerHome, repoId, processId)!, { completed: true });
1579
+ }
1580
+
1581
+ if (record.identity && !record.identityUntrusted && identityStillMatches(record.identity, false)) {
1582
+ await terminateProcessTree(record.identity.pid, {
1583
+ gracePeriodMs: 200,
1584
+ killAfterMs: 1_500,
1585
+ pollIntervalMs: 25,
1586
+ });
1587
+ } else if (record.identityUntrusted || record.identity?.processStartTime?.startsWith('untrusted:')) {
1588
+ completeProcessFromEvidence(controllerHome, repoId, processId, record.terminalFenceToken, {
1589
+ status: 'completed_unknown',
1590
+ cancelled: true,
1591
+ errorMessage: 'refusing to signal PID without verified start time',
1592
+ });
1593
+ return recordToHandle(getProcessRecord(controllerHome, repoId, processId)!, { completed: true });
1594
+ }
1595
+
1596
+ const path = record.exitReceiptPath ?? receiptPathFor(controllerHome, repoId, processId);
1597
+ if (!existsSync(path)) {
1598
+ writeExitReceipt(path, {
1599
+ schemaVersion: 1,
1600
+ processId,
1601
+ exitCode: 1,
1602
+ finishedAt: nowIso(),
1603
+ cancelled: true,
1604
+ });
1605
+ }
1606
+ completeProcessFromEvidence(controllerHome, repoId, processId, record.terminalFenceToken, {
1607
+ status: 'cancelled',
1608
+ exitCode: 1,
1609
+ cancelled: true,
1610
+ exitReceiptPath: path,
1611
+ });
1612
+ return recordToHandle(getProcessRecord(controllerHome, repoId, processId)!, { completed: true });
1613
+ }
1614
+
1615
+ export function readProcessLogs(
1616
+ controllerHome: string,
1617
+ repoId: string,
1618
+ processId: string,
1619
+ maxBytes = PROCESS_LOG_TAIL_BYTES,
1620
+ ): ProcessLogSlice | undefined {
1621
+ let record = getProcessRecord(controllerHome, repoId, processId);
1622
+ if (!record) return undefined;
1623
+ record = sanitizeTerminalProcessArtifacts(record);
1624
+ const stdout = readFileTailBytes(record.stdoutPath ?? '', maxBytes);
1625
+ const stderr = readFileTailBytes(record.stderrPath ?? '', maxBytes);
1626
+ return {
1627
+ processId,
1628
+ stdout: stdout.text || safeProcessText(record.stdoutTail ?? ''),
1629
+ stderr: stderr.text || safeProcessText(record.stderrTail ?? ''),
1630
+ stdoutBytes: stdout.fileBytes || record.stdoutBytes || 0,
1631
+ stderrBytes: stderr.fileBytes || record.stderrBytes || 0,
1632
+ truncated: stdout.fileBytes > maxBytes || stderr.fileBytes > maxBytes || record.logTruncated === true,
1633
+ };
1634
+ }
1635
+
1636
+ /**
1637
+ * Sanitize bounded historical Process Runtime artifacts without returning any
1638
+ * command output. Active processes are left alone because their runner may
1639
+ * still be appending; new runners redact before persistence.
1640
+ */
1641
+ export function sanitizeHistoricalProcessArtifacts(
1642
+ controllerHome: string,
1643
+ repoId: string,
1644
+ limit = 10_000,
1645
+ ): { scanned: number; eligible: number; changed: number; failed: number; processIds: string[] } {
1646
+ const records = listProcessRecords(controllerHome, repoId, Math.max(1, limit));
1647
+ let eligible = 0;
1648
+ let changed = 0;
1649
+ let failed = 0;
1650
+ const processIds: string[] = [];
1651
+ for (const record of records) {
1652
+ if (!isManagedProcessTerminal(record)) continue;
1653
+ eligible += 1;
1654
+ try {
1655
+ const before = record.outputRedaction?.sanitizedAt;
1656
+ const sanitized = sanitizeTerminalProcessArtifacts(record);
1657
+ if (sanitized.outputRedaction?.sanitizedAt && sanitized.outputRedaction.sanitizedAt !== before) {
1658
+ changed += 1;
1659
+ processIds.push(record.processId);
1660
+ }
1661
+ } catch {
1662
+ failed += 1;
1663
+ }
1664
+ }
1665
+ return { scanned: records.length, eligible, changed, failed, processIds };
1666
+ }
1667
+
1668
+ /**
1669
+ * Re-discover running processes after Controller restart.
1670
+ * Does not re-spawn; only re-validates identity and applies runner receipts.
1671
+ * Releases leases for terminal processes (exactly once) when this runtime may write.
1672
+ */
1673
+ export function recoverManagedProcesses(
1674
+ controllerHome: string,
1675
+ repoId: string,
1676
+ ): { recovered: string[]; orphaned: string[]; completedUnknown: string[]; completedFromReceipt: string[]; leasesReleased: string[] } {
1677
+ const recovered: string[] = [];
1678
+ const orphaned: string[] = [];
1679
+ const completedUnknown: string[] = [];
1680
+ const completedFromReceipt: string[] = [];
1681
+ const leasesReleased: string[] = [];
1682
+ // The v2 recovery index contains active records plus the narrow terminal
1683
+ // subset whose leases still need exactly-once release. The first migration
1684
+ // performs one full reconciliation; steady-state restarts no longer scan
1685
+ // thousands of historical terminal Process records.
1686
+ for (const record of listRecoverableProcessRecords(controllerHome, repoId)) {
1687
+ const processId = record.processId;
1688
+ if (liveMonitors.has(processId)) {
1689
+ recovered.push(processId);
1690
+ continue;
1691
+ }
1692
+ if (isManagedProcessTerminal(record)) {
1693
+ sanitizeTerminalProcessArtifacts(record);
1694
+ // Cleanup leftover leases after crash between terminal write and release.
1695
+ if (record.leasesReleased !== true && (record.leaseRefs?.length ?? 0) > 0) {
1696
+ const after = releaseProcessLeasesOnce(controllerHome, repoId, processId);
1697
+ if (after?.leasesReleased) leasesReleased.push(processId);
1698
+ }
1699
+ continue;
1700
+ }
1701
+
1702
+ const fromReceipt = applyReceiptIfPresent(controllerHome, repoId, processId, record);
1703
+ if (fromReceipt) {
1704
+ sanitizeTerminalProcessArtifacts(fromReceipt);
1705
+ completedFromReceipt.push(processId);
1706
+ if (fromReceipt.leasesReleased) leasesReleased.push(processId);
1707
+ continue;
1708
+ }
1709
+
1710
+ if (identityStillMatches(record.identity, record.identityUntrusted) && !processRuntimeIdentityMatches(record)) {
1711
+ // The PID may still be alive, but it belongs to a different runtime
1712
+ // generation/instance. Do not renew or release its leases from here.
1713
+ continue;
1714
+ }
1715
+
1716
+ if (identityStillMatches(record.identity, record.identityUntrusted)) {
1717
+ updateProcessRecord(controllerHome, repoId, processId, {
1718
+ status: 'running_recovered',
1719
+ route: 'managed',
1720
+ }, { allowTerminal: false });
1721
+ // Renew leases for recovered running processes when possible.
1722
+ if ((record.leaseRefs?.length ?? 0) > 0) {
1723
+ try {
1724
+ renewExecutionLeases(
1725
+ controllerHome,
1726
+ repoId,
1727
+ processOwnerJobId(processId),
1728
+ Math.max(30_000, record.timeoutMs),
1729
+ record.leaseRefs!.map((ref) => ({
1730
+ leaseId: ref.leaseId,
1731
+ fencingToken: ref.fencingToken,
1732
+ resourceKey: ref.resourceKey,
1733
+ })),
1734
+ );
1735
+ } catch {
1736
+ /* fenced or missing — leave for active writer */
1737
+ }
1738
+ }
1739
+ recovered.push(processId);
1740
+ continue;
1741
+ }
1742
+
1743
+ if (record.identityUntrusted || record.identity?.processStartTime?.startsWith('untrusted:') || record.identity?.processStartTime?.startsWith('fallback:')) {
1744
+ const completed = completeProcessFromEvidence(controllerHome, repoId, processId, record.terminalFenceToken, {
1745
+ status: 'completed_unknown',
1746
+ errorMessage: 'process identity was untrusted; exit code cannot be recovered after restart',
1747
+ });
1748
+ if (completed) sanitizeTerminalProcessArtifacts(completed);
1749
+ completedUnknown.push(processId);
1750
+ continue;
1751
+ }
1752
+
1753
+ // PID gone, no receipt → completed_unknown (releases leases).
1754
+ const completed = completeProcessFromEvidence(controllerHome, repoId, processId, record.terminalFenceToken, {
1755
+ status: 'completed_unknown',
1756
+ errorMessage: 'process no longer running and no exit receipt after controller restart',
1757
+ });
1758
+ if (completed) sanitizeTerminalProcessArtifacts(completed);
1759
+ completedUnknown.push(processId);
1760
+ }
1761
+ return { recovered, orphaned, completedUnknown, completedFromReceipt, leasesReleased };
1762
+ }
1763
+
1764
+ export function listLiveMonitorIds(): string[] {
1765
+ return [...liveMonitors.keys()];
1766
+ }
1767
+
1768
+ export function processRuntimeResourceDiagnostics(): {
1769
+ monitorCount: number;
1770
+ logPollerCount: number;
1771
+ timeoutCount: number;
1772
+ waiterCount: number;
1773
+ activeProcessIds: string[];
1774
+ } {
1775
+ const monitors = [...liveMonitors.values()];
1776
+ return {
1777
+ monitorCount: monitors.length,
1778
+ // Attached monitors stream redacted chunks over pipes; the disk-log poller
1779
+ // is only a legacy fallback and is never active for new runners.
1780
+ logPollerCount: 0,
1781
+ timeoutCount: monitors.filter((monitor) => Boolean(monitor.timeoutHandle)).length,
1782
+ waiterCount: monitors.reduce((total, monitor) => total + monitor.waiters.length, 0),
1783
+ activeProcessIds: monitors.map((monitor) => monitor.processId),
1784
+ };
1785
+ }
1786
+
1787
+ /** Test helper: drop in-memory monitors without killing OS processes / runners. */
1788
+ export function __resetLiveMonitorsForTests(): void {
1789
+ for (const monitor of liveMonitors.values()) {
1790
+ if (monitor.timeoutHandle) clearTimeout(monitor.timeoutHandle);
1791
+ }
1792
+ liveMonitors.clear();
1793
+ }
1794
+
1795
+ /** Test helper: simulate controller crash by dropping monitors while runners keep running. */
1796
+ export function __detachMonitorsKeepRunnersForTests(): string[] {
1797
+ const ids = [...liveMonitors.keys()];
1798
+ for (const monitor of liveMonitors.values()) {
1799
+ if (monitor.timeoutHandle) clearTimeout(monitor.timeoutHandle);
1800
+ // Detach close listeners so this controller process no longer finalizes.
1801
+ try {
1802
+ monitor.child.removeAllListeners('close');
1803
+ monitor.child.removeAllListeners('error');
1804
+ monitor.child.unref?.();
1805
+ } catch {
1806
+ /* ignore */
1807
+ }
1808
+ }
1809
+ liveMonitors.clear();
1810
+ return ids;
1811
+ }
1812
+
1813
+ // silence unused helpers retained for local tail buffering paths
1814
+ void openSync;
1815
+ void appendTail;
1816
+ type _RunnerReceipt = ProcessRunnerExitReceipt;