@moretea-labs/forge 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (434) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +46 -0
  3. package/CLAUDE.md +2 -0
  4. package/DESIGN.md +7 -2
  5. package/README.md +4 -4
  6. package/README.zh-CN.md +2 -2
  7. package/SKILL.md +2 -2
  8. package/THIRD_PARTY_NOTICES.md +1 -1
  9. package/agents/openai.yaml +1 -1
  10. package/assets/hooks/hook-input.sh +11 -8
  11. package/assets/hooks/lib/session-state.sh +10 -7
  12. package/assets/hooks/lib/workflow-state.sh +13 -177
  13. package/assets/hooks/post-tool-observer.sh +8 -2
  14. package/assets/hooks/pre-edit-guard.sh +8 -39
  15. package/assets/hooks/prompt-guard.sh +7 -83
  16. package/assets/hooks/session-start-context.sh +56 -1
  17. package/assets/partials/05-workflow.partial.md +1 -1
  18. package/assets/partials-agents/03-orchestration.partial.md +1 -1
  19. package/assets/plugin-registry.v1.json +54 -3
  20. package/assets/reference-configs/document-generation.md +1 -1
  21. package/assets/reference-configs/harness-overview.md +1 -1
  22. package/assets/reference-configs/hook-operations.md +1 -1
  23. package/assets/reference-configs/sprint-contracts.md +3 -3
  24. package/assets/skill-version.json +2 -2
  25. package/assets/templates/contract.template.md +2 -7
  26. package/assets/templates/helpers/capture-plan.sh +2 -2
  27. package/assets/templates/helpers/check-task-workflow.sh +4 -4
  28. package/assets/templates/helpers/contract-worktree.sh +1 -21
  29. package/assets/templates/helpers/ensure-task-workflow.sh +13 -38
  30. package/assets/templates/helpers/migrate-project-template.sh +22 -45
  31. package/assets/templates/helpers/new-plan.sh +1 -1
  32. package/assets/templates/helpers/plan-to-todo.sh +12 -37
  33. package/assets/templates/helpers/ship-worktrees.sh +2 -29
  34. package/assets/templates/helpers/sprint-backlog.sh +6 -6
  35. package/assets/templates/helpers/verify-contract.sh +1 -111
  36. package/assets/templates/helpers/verify-sprint.sh +3 -180
  37. package/assets/templates/plan.template.md +1 -1
  38. package/assets/templates/review.template.md +9 -34
  39. package/assets/workflow-contract.v1.json +10 -19
  40. package/docs/README.md +10 -10
  41. package/docs/architecture/CURRENT.md +170 -0
  42. package/docs/architecture/EVOLUTION.md +42 -0
  43. package/docs/architecture/current/README.md +4 -156
  44. package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +2 -2
  45. package/docs/architecture/decisions/20260823-immediate-controller-relay.md +53 -0
  46. package/docs/architecture/decisions/20260826-browser-runtime-v2.md +73 -0
  47. package/docs/architecture/history/README.md +7 -0
  48. package/docs/architecture/{chatgpt-handoff-facade.md → history/chatgpt-handoff-facade.md} +4 -0
  49. package/docs/architecture/{global-hook-runtime.md → history/global-hook-runtime.md} +7 -3
  50. package/docs/architecture/{ios-semantic-automation-provider-v2.md → history/ios-semantic-automation-provider-v2.md} +6 -2
  51. package/docs/architecture/history.md +3 -34
  52. package/docs/architecture/index.md +15 -98
  53. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +1 -1
  54. package/docs/architecture/reviews/2026-08-22-semantic-harness-convergence-acceptance.md +39 -0
  55. package/docs/architecture/versions/1.6.md +38 -0
  56. package/docs/architecture/versions/1.7.md +41 -0
  57. package/docs/forge-chatgpt-browser-engine.md +2 -2
  58. package/docs/forge-chatgpt-mcp-setup.md +4 -28
  59. package/docs/forge-chatgpt-supervised-automation.md +3 -3
  60. package/docs/forge-github-issue-launcher.md +1 -1
  61. package/docs/forge-runtime-self-healing-loop.md +1 -1
  62. package/docs/operations/controller-browser-plugin.md +38 -22
  63. package/docs/operations/controller-performance-and-502.md +124 -0
  64. package/docs/operations/controller-reliability-runbook.md +1 -1
  65. package/docs/operations/features.md +3 -3
  66. package/docs/operations/github-repository.md +1 -1
  67. package/docs/operations/homebrew.md +2 -2
  68. package/docs/operations/mcp-tool-exposure.md +5 -3
  69. package/docs/tutorials/01-install-and-start.md +1 -1
  70. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  71. package/docs/wiki/Architecture.md +6 -6
  72. package/docs/wiki/Implementation.md +2 -2
  73. package/docs/wiki/Installation.md +1 -1
  74. package/docs/wiki/Integrations.md +2 -2
  75. package/docs/wiki/Quick-Start.md +1 -1
  76. package/docs/wiki/Work-Lifecycle.md +1 -1
  77. package/package.json +29 -10
  78. package/scripts/activate-source-baseline.ts +17 -13
  79. package/scripts/benchmark-background-check-overlap.ts +228 -0
  80. package/scripts/benchmark-mcp-host.sh +117 -0
  81. package/scripts/benchmark-mcp-transport.ts +170 -0
  82. package/scripts/benchmark-quality-harness.ts +146 -0
  83. package/scripts/benchmark-route-session-concurrency.ts +207 -6
  84. package/scripts/benchmark-thin-harness-mcp-e2e.ts +7 -1
  85. package/scripts/benchmark-typescript-navigation.ts +186 -0
  86. package/scripts/benchmark-work-efficiency.ts +334 -0
  87. package/scripts/capture-plan.sh +3 -3
  88. package/scripts/check-agent-tooling.sh +8 -35
  89. package/scripts/check-architecture-sync.sh +17 -86
  90. package/scripts/check-mcp-compatibility.ts +14 -0
  91. package/scripts/check-public-docs.sh +14 -1
  92. package/scripts/check-runtime-architecture.mjs +107 -64
  93. package/scripts/check-stable-baseline.ts +92 -0
  94. package/scripts/check-task-workflow.sh +4 -4
  95. package/scripts/contract-worktree.sh +1 -21
  96. package/scripts/ensure-task-workflow.sh +22 -55
  97. package/scripts/host-temporary-cloud-mcp-e2e.sh +265 -0
  98. package/scripts/install-standalone-recovery.ts +24 -0
  99. package/scripts/lib/project-init-lib.sh +15 -44
  100. package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
  101. package/scripts/migrate-project-template.sh +23 -1179
  102. package/scripts/new-plan.sh +1 -1
  103. package/scripts/plan-to-todo.sh +11 -36
  104. package/scripts/run-governed-gate.ts +2 -1
  105. package/scripts/ship-worktrees.sh +2 -29
  106. package/scripts/smoke-ios-physical-device.ts +4 -2
  107. package/scripts/smoke-runtime-recovery.ts +1 -35
  108. package/scripts/smoke-schedule-engine.ts +1 -20
  109. package/scripts/sprint-backlog.sh +1 -1
  110. package/scripts/verify-contract.sh +1 -111
  111. package/scripts/verify-forge-runtime.sh +0 -1
  112. package/scripts/verify-sprint.sh +3 -180
  113. package/scripts/workflow-contract.ts +1 -5
  114. package/src/cli/commands/chatgpt.ts +31 -2
  115. package/src/cli/commands/init-hook.ts +3 -3
  116. package/src/cli/commands/mcp.ts +9 -5
  117. package/src/cli/commands/plugin.ts +37 -10
  118. package/src/cli/commands/recovery.ts +25 -3
  119. package/src/cli/commands/runtime.ts +55 -3
  120. package/src/cli/commands/security.ts +12 -1
  121. package/src/cli/commands/setup-profile.ts +31 -21
  122. package/src/cli/controller/check-runner.ts +106 -3
  123. package/src/cli/controller/composite-result.ts +1 -1
  124. package/src/cli/controller/context/focus.ts +51 -0
  125. package/src/cli/controller/context/instruction-context.ts +114 -0
  126. package/src/cli/controller/context/known-paths.ts +76 -0
  127. package/src/cli/controller/context/query-planning.ts +110 -0
  128. package/src/cli/controller/context/rule-resolution.ts +532 -0
  129. package/src/cli/controller/context/source-materializer.ts +278 -0
  130. package/src/cli/controller/context/structural.ts +16 -0
  131. package/src/cli/controller/context/types.ts +286 -0
  132. package/src/cli/controller/context-pack.ts +898 -430
  133. package/src/cli/controller/launch-agents.ts +77 -2
  134. package/src/cli/controller/operational-plan.ts +2 -2
  135. package/src/cli/controller/work-mode.ts +6 -4
  136. package/src/cli/editing/edit-session.ts +60 -7
  137. package/src/cli/github/github.ts +133 -0
  138. package/src/cli/hook/prompt-guard-decision.ts +17 -18
  139. package/src/cli/hook/prompt-intents.ts +1 -1
  140. package/src/cli/installer/managed-entries.ts +15 -1
  141. package/src/cli/local-bridge/console-automations.ts +225 -67
  142. package/src/cli/local-bridge/console-view-models.ts +3 -35
  143. package/src/cli/local-bridge/facade-api.ts +2 -217
  144. package/src/cli/local-bridge/frontend/src/App.tsx +3 -3
  145. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +153 -3
  146. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +37 -4
  147. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +183 -3
  148. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
  149. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
  150. package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -11
  151. package/src/cli/local-bridge/frontend/src/lib/routes.ts +1 -1
  152. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
  153. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +4 -3
  154. package/src/cli/local-bridge/frontend/src/styles.css +27 -11
  155. package/src/cli/local-bridge/frontend/src/types.ts +8 -7
  156. package/src/cli/local-bridge/job-store.ts +1 -490
  157. package/src/cli/local-bridge/server.ts +29 -532
  158. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  159. package/src/cli/local-bridge/ui-dist/app.js +5101 -4967
  160. package/src/cli/mcp/auth.ts +5 -5
  161. package/src/cli/mcp/instructions.ts +3 -2
  162. package/src/cli/mcp/legacy-tool-service.ts +0 -189
  163. package/src/cli/mcp/multi-repository.ts +16 -5
  164. package/src/cli/mcp/oauth.ts +175 -26
  165. package/src/cli/mcp/paths.ts +8 -2
  166. package/src/cli/mcp/repository-tools.ts +356 -101
  167. package/src/cli/mcp/server.ts +625 -54
  168. package/src/cli/mcp/tool-capability-cache.ts +1 -1
  169. package/src/cli/mcp/toolset-selection.ts +1 -1
  170. package/src/cli/mcp/toolset.ts +42 -5
  171. package/src/cli/mcp/transports/http.ts +125 -15
  172. package/src/cli/mcp/transports/session-registry.ts +67 -5
  173. package/src/cli/mcp/types.ts +3 -3
  174. package/src/cli/repositories/command-classifier.ts +209 -14
  175. package/src/cli/repositories/command-executor.ts +55 -521
  176. package/src/cli/repositories/command-process.ts +108 -0
  177. package/src/cli/repositories/command-scope.ts +99 -41
  178. package/src/cli/repositories/controller-home.ts +7 -8
  179. package/src/cli/repositories/git-command-executor.ts +2 -0
  180. package/src/cli/repositories/locks.ts +53 -16
  181. package/src/cli/repositories/repository-snapshot.ts +121 -0
  182. package/src/cli/repositories/safe-patch.ts +6 -4
  183. package/src/cli/repositories/selected-path-actions.ts +17 -9
  184. package/src/cli/repositories/structured-git.ts +110 -10
  185. package/src/cli/repository/inspector.ts +565 -62
  186. package/src/cli/repository/session-cache.ts +58 -17
  187. package/src/cli/v81-entry.ts +1 -4
  188. package/src/effects/fs-transaction.ts +85 -17
  189. package/src/runtime/context/codegraph-read-provider.ts +233 -2
  190. package/src/runtime/context/codegraph-sidecar.cjs +74 -40
  191. package/src/runtime/context/generic-lsp-provider.ts +355 -0
  192. package/src/runtime/context/lsp-client.ts +283 -0
  193. package/src/runtime/context/semantic-navigation.ts +275 -0
  194. package/src/runtime/context/swift-navigation.ts +565 -0
  195. package/src/runtime/context/typescript-navigation.ts +226 -0
  196. package/src/runtime/control-plane/dispatch-priority.ts +28 -0
  197. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +196 -2
  198. package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +86 -24
  199. package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
  200. package/src/runtime/control-plane/execution/session-store.ts +18 -0
  201. package/src/runtime/control-plane/execution/validation.ts +15 -2
  202. package/src/runtime/control-plane/execution/verification-evidence.ts +58 -3
  203. package/src/runtime/control-plane/execution/work-handle-store.ts +27 -2
  204. package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +258 -0
  205. package/src/runtime/control-plane/execution/work-remote-delivery.ts +215 -0
  206. package/src/runtime/control-plane/execution/work-task-receipt.ts +54 -11
  207. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +175 -8
  208. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +28 -21
  209. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +87 -9
  210. package/src/runtime/control-plane/facade/capability-registry.ts +129 -25
  211. package/src/runtime/control-plane/facade/controller-round-relay.ts +991 -0
  212. package/src/runtime/control-plane/facade/controller-session-store.ts +144 -14
  213. package/src/runtime/control-plane/facade/goal-workloop-access.ts +9 -2
  214. package/src/runtime/control-plane/facade/goal-workloop.ts +885 -105
  215. package/src/runtime/control-plane/facade/index.ts +1 -0
  216. package/src/runtime/control-plane/facade/plan-contract-store.ts +211 -26
  217. package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
  218. package/src/runtime/control-plane/facade/semantic-admission.ts +205 -0
  219. package/src/runtime/control-plane/facade/suggested-actions.ts +5 -1
  220. package/src/runtime/control-plane/facade/types.ts +55 -11
  221. package/src/runtime/control-plane/facade/work-contract-store.ts +204 -73
  222. package/src/runtime/control-plane/generated-cache-retention.ts +271 -0
  223. package/src/runtime/control-plane/global-scheduler/config.ts +44 -0
  224. package/src/runtime/control-plane/global-scheduler/dispatch-capacity.ts +80 -0
  225. package/src/runtime/control-plane/global-scheduler/durable-admission.ts +46 -0
  226. package/src/runtime/control-plane/global-scheduler/maintenance.ts +145 -0
  227. package/src/runtime/control-plane/global-scheduler/projection-refresh.ts +84 -0
  228. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +3 -3
  229. package/src/runtime/control-plane/global-scheduler/scheduler.ts +273 -542
  230. package/src/runtime/control-plane/global-scheduler/source-scan.ts +59 -0
  231. package/src/runtime/control-plane/global-scheduler/state.ts +88 -0
  232. package/src/runtime/control-plane/global-scheduler/worker-attachment.ts +57 -0
  233. package/src/runtime/control-plane/global-scheduler/worker-exit-decision.ts +38 -0
  234. package/src/runtime/control-plane/global-scheduler/worker-exit-reconciler.ts +175 -0
  235. package/src/runtime/control-plane/global-scheduler/worker-launch.ts +111 -0
  236. package/src/runtime/control-plane/global-scheduler/worker-lifecycle-store.ts +64 -0
  237. package/src/runtime/control-plane/global-scheduler/worker-lifecycle.ts +150 -0
  238. package/src/runtime/control-plane/global-scheduler/worker-process.ts +119 -0
  239. package/src/runtime/control-plane/global-scheduler/worker-stderr.ts +60 -0
  240. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +50 -0
  241. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +642 -51
  242. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +40 -0
  243. package/src/runtime/control-plane/launcher/thin-launcher.ts +194 -10
  244. package/src/runtime/control-plane/persistence/controller-home-migration.ts +487 -0
  245. package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
  246. package/src/runtime/control-plane/persistence/sqlite-store.ts +33 -2
  247. package/src/runtime/control-plane/release-retention.ts +62 -2
  248. package/src/runtime/control-plane/routing/route-policy.ts +7 -16
  249. package/src/runtime/control-plane/runtime-status-client.ts +0 -2
  250. package/src/runtime/diagnostics/mcp-timing.ts +19 -0
  251. package/src/runtime/diagnostics/mcp-transport-benchmark.ts +129 -0
  252. package/src/runtime/diagnostics/performance.ts +24 -1
  253. package/src/runtime/evidence/event-ledger.ts +1 -1
  254. package/src/runtime/execution/jobs/store.ts +58 -4
  255. package/src/runtime/execution/jobs/types.ts +1 -1
  256. package/src/runtime/execution/managed-workspace.ts +203 -1
  257. package/src/runtime/execution/process-runtime/check-facade.ts +52 -14
  258. package/src/runtime/execution/process-runtime/check-result.ts +63 -2
  259. package/src/runtime/execution/process-runtime/check-scheduling.ts +114 -0
  260. package/src/runtime/execution/process-runtime/command-facade.ts +346 -40
  261. package/src/runtime/execution/process-runtime/gc.ts +98 -12
  262. package/src/runtime/execution/process-runtime/lightweight-managed.ts +1184 -0
  263. package/src/runtime/execution/process-runtime/persisted-check.ts +57 -6
  264. package/src/runtime/execution/process-runtime/resource-claims.ts +97 -8
  265. package/src/runtime/execution/process-runtime/runtime.ts +59 -4
  266. package/src/runtime/execution/process-runtime/store.ts +7 -0
  267. package/src/runtime/execution/process-runtime/types.ts +2 -0
  268. package/src/runtime/execution/thin-harness/async-process.ts +12 -2
  269. package/src/runtime/execution/thin-harness/search-worker.ts +54 -5
  270. package/src/runtime/execution/workers/executor.ts +0 -48
  271. package/src/runtime/gateway/mcp/execution-tools.ts +1219 -126
  272. package/src/runtime/gateway/mcp/process-tools.ts +83 -37
  273. package/src/runtime/gateway/mcp/router.ts +185 -83
  274. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +546 -0
  275. package/src/runtime/gateway/mcp/runtime-tools.ts +2555 -1616
  276. package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
  277. package/src/runtime/plugins/app-store-connect-adapter.ts +123 -19
  278. package/src/runtime/plugins/browser-adapter.ts +2925 -318
  279. package/src/runtime/plugins/browser-automation-service.ts +10 -0
  280. package/src/runtime/plugins/browser-handoff-host.ts +6 -31
  281. package/src/runtime/plugins/browser-handoff.ts +92 -5
  282. package/src/runtime/plugins/browser-macos-bridge.ts +586 -45
  283. package/src/runtime/plugins/browser-node-bridge.ts +6 -34
  284. package/src/runtime/plugins/browser-provider-registry.ts +109 -0
  285. package/src/runtime/plugins/browser-runtime-contract.ts +152 -0
  286. package/src/runtime/plugins/browser-runtime.ts +183 -0
  287. package/src/runtime/plugins/browser-session-authority.ts +332 -0
  288. package/src/runtime/plugins/capability-authorization-grants.ts +354 -0
  289. package/src/runtime/plugins/desktop-operator-registration.ts +15 -12
  290. package/src/runtime/plugins/external-adapter.ts +324 -3
  291. package/src/runtime/plugins/external-registration.ts +84 -5
  292. package/src/runtime/plugins/external-unix-socket.ts +7 -1
  293. package/src/runtime/plugins/first-party-registry.ts +4 -2
  294. package/src/runtime/plugins/github-adapter.ts +125 -0
  295. package/src/runtime/plugins/gmail-adapter.ts +28 -2
  296. package/src/runtime/plugins/ios/agent-device-provider.ts +1 -1
  297. package/src/runtime/plugins/ios/agent-device-typed-provider.ts +52 -9
  298. package/src/runtime/plugins/ios/remote-xpc-hid.ts +1171 -0
  299. package/src/runtime/plugins/ios-adapter.ts +106 -16
  300. package/src/runtime/plugins/ios-agent-device.ts +234 -64
  301. package/src/runtime/plugins/ios-physical-device.ts +1143 -392
  302. package/src/runtime/plugins/lightweight-action.ts +153 -0
  303. package/src/runtime/plugins/local-system-adapter.ts +125 -8
  304. package/src/runtime/plugins/macos-capability-broker.ts +55 -9
  305. package/src/runtime/plugins/plugin-action-sidecar.ts +59 -0
  306. package/src/runtime/plugins/plugin-management-adapter.ts +181 -0
  307. package/src/runtime/plugins/resend-adapter.ts +1 -1
  308. package/src/runtime/plugins/store.ts +366 -24
  309. package/src/runtime/plugins/types.ts +39 -2
  310. package/src/runtime/plugins/xiaohongshu-publish.ts +515 -0
  311. package/src/runtime/projections/git-status-sampler.ts +16 -16
  312. package/src/runtime/projections/materialized-view.ts +28 -10
  313. package/src/runtime/recovery/actions.ts +0 -10
  314. package/src/runtime/recovery/classifier.ts +0 -9
  315. package/src/runtime/recovery/maintenance-executor.ts +415 -18
  316. package/src/runtime/recovery/probe.ts +0 -1
  317. package/src/runtime/recovery/types.ts +0 -1
  318. package/src/runtime/root/mcp-transport.ts +180 -10
  319. package/src/runtime/root/package-connector-service.ts +186 -8
  320. package/src/runtime/root/package-runtime-release.ts +143 -17
  321. package/src/runtime/root/package-runtime-service.ts +430 -22
  322. package/src/runtime/root/release-manifest.ts +7 -2
  323. package/src/runtime/root/release-materialize.ts +162 -19
  324. package/src/runtime/root/release-store.ts +48 -11
  325. package/src/runtime/root/runtime.ts +23 -5
  326. package/src/runtime/root/service.ts +100 -27
  327. package/src/runtime/root/types.ts +2 -0
  328. package/src/runtime/safe-tooling/index.ts +0 -1
  329. package/src/runtime/safe-tooling/ios-development.ts +321 -63
  330. package/src/runtime/safe-tooling/types.ts +0 -30
  331. package/src/runtime/shared/trusted-executable.ts +62 -0
  332. package/src/runtime/shared/trusted-node-executable.ts +48 -0
  333. package/src/runtime/standalone-recovery/core.ts +702 -94
  334. package/src/runtime/standalone-recovery/entry.ts +99 -23
  335. package/src/runtime/standalone-recovery/installer.ts +19 -3
  336. package/src/runtime/standalone-recovery/service-labels.ts +2 -0
  337. package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +160 -0
  338. package/src/runtime/workflow/schedules/browser-probe.ts +258 -0
  339. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
  340. package/src/runtime/workflow/schedules/engine.ts +509 -80
  341. package/src/runtime/workflow/schedules/github-issue-watch.ts +158 -0
  342. package/src/runtime/workflow/schedules/settlement.ts +48 -4
  343. package/src/runtime/workflow/schedules/store.ts +131 -10
  344. package/src/runtime/workflow/schedules/types.ts +6 -3
  345. package/src/runtime/workflow/schedules/work-continuation.ts +120 -34
  346. package/src/testing/test-governance.ts +8 -40
  347. package/assets/reference-configs/heartbeat-triage.md +0 -57
  348. package/assets/templates/helpers/harness-trace-grade.sh +0 -150
  349. package/assets/templates/helpers/heartbeat-triage.sh +0 -361
  350. package/assets/templates/helpers/maintenance-triage.sh +0 -167
  351. package/bin/forge-desktop-helper.mjs +0 -204
  352. package/docs/architecture/current/approved-target-architecture.zh-CN.md +0 -1501
  353. package/docs/architecture/current/architecture-invariants.md +0 -331
  354. package/docs/architecture/current/automation-and-schedule-engine.md +0 -453
  355. package/docs/architecture/current/codegraph-context-provider.md +0 -226
  356. package/docs/architecture/current/control-plane-authority-inventory.md +0 -94
  357. package/docs/architecture/current/controller-repository-decoupling.md +0 -148
  358. package/docs/architecture/current/dispatch-and-agent-strategy.md +0 -354
  359. package/docs/architecture/current/entity-model.md +0 -592
  360. package/docs/architecture/current/external-plugin-governance.md +0 -396
  361. package/docs/architecture/current/failure-recovery.md +0 -447
  362. package/docs/architecture/current/governance.md +0 -213
  363. package/docs/architecture/current/human-interaction-plane.md +0 -87
  364. package/docs/architecture/current/implementation-status.md +0 -128
  365. package/docs/architecture/current/job-and-run-lifecycle.md +0 -413
  366. package/docs/architecture/current/legacy-governance.md +0 -58
  367. package/docs/architecture/current/local-workspace-target-grants.md +0 -70
  368. package/docs/architecture/current/migration-roadmap.md +0 -105
  369. package/docs/architecture/current/multi-repository-execution.md +0 -371
  370. package/docs/architecture/current/operations-runbook.md +0 -45
  371. package/docs/architecture/current/peer-supercontroller-runtime.md +0 -32
  372. package/docs/architecture/current/personal-assistant-plugin-baseline.md +0 -206
  373. package/docs/architecture/current/repository-access-modes.md +0 -102
  374. package/docs/architecture/current/route-policy.md +0 -15
  375. package/docs/architecture/current/route-session-concurrency.md +0 -85
  376. package/docs/architecture/current/runtime-architecture-simplification.md +0 -214
  377. package/docs/architecture/current/runtime-directory-map.md +0 -49
  378. package/docs/architecture/current/runtime-health-and-resource-lifecycle.md +0 -85
  379. package/docs/architecture/current/scheduler-and-resource-claims.md +0 -422
  380. package/docs/architecture/current/session-aware-execution-and-authorization.md +0 -77
  381. package/docs/architecture/current/system-overview.md +0 -144
  382. package/docs/architecture/current/target-requirements-traceability.md +0 -202
  383. package/docs/architecture/current/thin-harness-governance-policy.md +0 -32
  384. package/docs/architecture/current/thin-harness-v1.md +0 -311
  385. package/docs/architecture/current/verification-and-release-gates.md +0 -391
  386. package/docs/forge-autonomous-goal-loop.md +0 -9
  387. package/docs/forge-provider-configuration.md +0 -7
  388. package/docs/operations/chatgpt-personal-assistant-runtime.md +0 -83
  389. package/docs/operations/controller-desktop-plugin.md +0 -112
  390. package/docs/operations/gmail-assistant-routines.md +0 -186
  391. package/docs/operations/personal-assistant-operator-guide.md +0 -61
  392. package/docs/operations/provider-configuration.md +0 -31
  393. package/scripts/harness-trace-grade.sh +0 -150
  394. package/scripts/heartbeat-triage.sh +0 -361
  395. package/scripts/maintenance-triage.sh +0 -167
  396. package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +0 -2
  397. package/src/cli/repositories/goal-registry.ts +0 -239
  398. package/src/runtime/assistant/action-proposals.ts +0 -279
  399. package/src/runtime/assistant/intent.ts +0 -395
  400. package/src/runtime/assistant/model-provider.ts +0 -460
  401. package/src/runtime/assistant/openapi.ts +0 -223
  402. package/src/runtime/assistant/policy.ts +0 -89
  403. package/src/runtime/assistant/readiness.ts +0 -258
  404. package/src/runtime/assistant/routine-runtime.ts +0 -477
  405. package/src/runtime/assistant/schedule-binding.ts +0 -171
  406. package/src/runtime/assistant/standing-grants.ts +0 -375
  407. package/src/runtime/assistant/store.ts +0 -243
  408. package/src/runtime/assistant/types.ts +0 -135
  409. package/src/runtime/control-plane/goal-loop/config-facade.ts +0 -700
  410. package/src/runtime/control-plane/goal-loop/config-store.ts +0 -570
  411. package/src/runtime/control-plane/goal-loop/config-types.ts +0 -213
  412. package/src/runtime/control-plane/goal-loop/executor-router.ts +0 -109
  413. package/src/runtime/control-plane/goal-loop/failure-taxonomy.ts +0 -149
  414. package/src/runtime/control-plane/goal-loop/goal-contract-store.ts +0 -304
  415. package/src/runtime/control-plane/goal-loop/goal-loop-engine.ts +0 -1001
  416. package/src/runtime/control-plane/goal-loop/handoff-packet.ts +0 -160
  417. package/src/runtime/control-plane/goal-loop/index.ts +0 -13
  418. package/src/runtime/control-plane/goal-loop/local-tools.ts +0 -290
  419. package/src/runtime/control-plane/goal-loop/policy-gates.ts +0 -203
  420. package/src/runtime/control-plane/goal-loop/provider-dispatch.ts +0 -93
  421. package/src/runtime/control-plane/goal-loop/provider-registry.ts +0 -545
  422. package/src/runtime/control-plane/goal-loop/types.ts +0 -378
  423. package/src/runtime/control-plane/startup-recovery.ts +0 -198
  424. package/src/runtime/gateway/mcp/resource-policy.ts +0 -186
  425. package/src/runtime/personal-assistant/gmail-triage-manager.ts +0 -231
  426. package/src/runtime/personal-assistant/reporting-runtime.ts +0 -344
  427. package/src/runtime/personal-assistant/triage-runtime.ts +0 -748
  428. package/src/runtime/plugins/desktop-adapter.ts +0 -374
  429. package/src/runtime/safe-tooling/browser-safe.ts +0 -133
  430. package/src/runtime/workflow/findings/store.ts +0 -204
  431. package/src/runtime/workflow/findings/types.ts +0 -58
  432. package/src/runtime/workflow/portfolio/engine.ts +0 -20
  433. package/src/runtime/workflow/portfolio/store.ts +0 -185
  434. package/src/runtime/workflow/portfolio/types.ts +0 -29
@@ -4,7 +4,14 @@ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, re
4
4
  import { homedir } from 'os';
5
5
  import { basename, dirname, isAbsolute, join, resolve } from 'path';
6
6
  import { observeRuntimeStatus } from '../root/status';
7
- import { ensureForgeRuntimeLaunchAgentContract, forgeRuntimeServicePaths, readForgeRuntimeServiceConfig } from '../root/service';
7
+ import {
8
+ activeRuntimeEntrypoint,
9
+ activeRuntimeLaunchSpec,
10
+ ensureForgeRuntimeLaunchAgentContract,
11
+ forgeRuntimeServicePaths,
12
+ inspectForgeRuntimeLaunchAgentContract,
13
+ readForgeRuntimeServiceConfig,
14
+ } from '../root/service';
8
15
  import { loadRuntimeReleaseManifest } from '../root/release-manifest';
9
16
  import { assertRuntimeReleaseFiles, stageRuntimeReleaseFromCandidateSource, type StagedRuntimeRelease } from '../root/release-materialize';
10
17
  import {
@@ -15,7 +22,10 @@ import {
15
22
  type RuntimeReleaseAuthority,
16
23
  } from '../root/release-store';
17
24
  import type { RuntimeReleaseManifest } from '../root/types';
25
+ import { ensurePackageConnectorService, packageConnectorServicePaths, type PackageConnectorReleaseBinding } from '../root/package-connector-service';
18
26
  import { createRecoveryHttpTransport, type RecoveryHttpTransport } from './http-transport';
27
+ import { observeRecoveryWatchdogHealth } from './watchdog-heartbeat';
28
+ import { RECOVERY_GATEWAY_LABEL, RECOVERY_WATCHDOG_LABEL } from './service-labels';
19
29
  import {
20
30
  migrateStoppedRepoLocalControllerHomeStorage,
21
31
  repoLocalControllerHomeStorageNeedsMigration,
@@ -50,6 +60,12 @@ export interface PrimaryConnectorServiceConfig {
50
60
  platform: 'launchd';
51
61
  label: string;
52
62
  plistPath?: string;
63
+ /** Local OAuth MCP endpoint used to distinguish Connector failure from tunnel failure. */
64
+ localMcpUrl?: string;
65
+ minimumFailures?: number;
66
+ minimumFailureDurationMs?: number;
67
+ restartCooldownMs?: number;
68
+ maximumRestartAttempts?: number;
53
69
  postRestartVerifyTimeoutMs?: number;
54
70
  }
55
71
 
@@ -59,6 +75,8 @@ export interface RecoveryConfig {
59
75
  publicMcpUrl?: string;
60
76
  recoveryPublicUrl?: string;
61
77
  recoveryTunnelService?: PublicTunnelServiceConfig;
78
+ /** Optional public tunnel serving publicMcpUrl; independent from the OAuth Connector process. */
79
+ primaryPublicTunnelService?: PublicTunnelServiceConfig;
62
80
  primaryRuntimeService?: PrimaryRuntimeServiceConfig;
63
81
  primaryRuntimeSourceRoot?: string;
64
82
  primaryConnectorService?: PrimaryConnectorServiceConfig;
@@ -95,6 +113,7 @@ export type RecoveryMutationAction =
95
113
  | 'stage_and_activate_runtime_release'
96
114
  | 'restart_primary_connector'
97
115
  | 'restart_recovery_gateway'
116
+ | 'restart_recovery_watchdog'
98
117
  | 'repair_public_tunnel';
99
118
 
100
119
  interface RecoveryLock {
@@ -132,8 +151,8 @@ export interface VerifyStableRuntimeOptions {
132
151
  export interface RecoveryWatchdogDiagnosticEvidence {
133
152
  fingerprint: string;
134
153
  releaseIdentity: string;
135
- components: Array<'runtime' | 'gateway' | 'public_mcp' | 'recovery_gateway' | 'recovery_tunnel'>;
136
- failedProbes: Array<{ name: string; component: 'runtime' | 'gateway' | 'public_mcp' | 'recovery_gateway' | 'recovery_tunnel'; detail: string; status?: number }>;
154
+ components: Array<'runtime' | 'gateway' | 'public_mcp' | 'recovery_gateway' | 'recovery_watchdog' | 'recovery_tunnel'>;
155
+ failedProbes: Array<{ name: string; component: 'runtime' | 'gateway' | 'public_mcp' | 'recovery_gateway' | 'recovery_watchdog' | 'recovery_tunnel'; detail: string; status?: number }>;
137
156
  firstObservedAt: string;
138
157
  lastObservedAt: string;
139
158
  occurrences: number;
@@ -153,7 +172,7 @@ export interface RollbackResult {
153
172
  }
154
173
 
155
174
  export interface WatchdogDecision {
156
- action: 'healthy' | 'degraded' | 'repair_public_tunnel' | 'restart_recovery_gateway' | 'restart_primary_runtime' | 'rollback' | 'recovery_exhausted';
175
+ action: 'healthy' | 'degraded' | 'repair_public_tunnel' | 'restart_recovery_gateway' | 'restart_primary_connector' | 'restart_primary_runtime' | 'rollback' | 'recovery_exhausted';
157
176
  reason: string;
158
177
  }
159
178
 
@@ -175,10 +194,16 @@ export interface WatchdogState {
175
194
  publicTunnelFailures?: number;
176
195
  publicTunnelFirstFailureAt?: number;
177
196
  publicTunnelRepairFailures?: number;
197
+ primaryConnectorFailures?: number;
198
+ primaryConnectorFirstFailureAt?: number;
199
+ primaryConnectorRestartAttempts?: number;
200
+ primaryConnectorRestartFailures?: number;
201
+ primaryConnectorRestartLastAttemptAt?: number;
178
202
  recoveryGatewayRestartUsed?: boolean;
179
203
  recoveryReleaseRevision?: string;
180
204
  lastFullVerifyAt?: number;
181
205
  lastDecision?: WatchdogDecision['action'];
206
+ lastReason?: string;
182
207
  updatedAt?: string;
183
208
  }
184
209
 
@@ -300,6 +325,10 @@ function configuredRecoveryTunnel(config: RecoveryConfig): PublicTunnelServiceCo
300
325
  return config.recoveryTunnelService;
301
326
  }
302
327
 
328
+ function configuredPrimaryPublicTunnel(config: RecoveryConfig): PublicTunnelServiceConfig | undefined {
329
+ return config.primaryPublicTunnelService;
330
+ }
331
+
303
332
  function configuredRecoveryPublicUrl(config: RecoveryConfig): string | undefined {
304
333
  return config.recoveryPublicUrl;
305
334
  }
@@ -324,6 +353,7 @@ export function loadRecoveryConfig(controllerHome: string, explicit?: string): R
324
353
  ...(typeof loaded.publicMcpUrl === 'string' ? { publicMcpUrl: loaded.publicMcpUrl } : {}),
325
354
  ...(typeof loaded.recoveryPublicUrl === 'string' ? { recoveryPublicUrl: loaded.recoveryPublicUrl } : {}),
326
355
  ...(loaded.recoveryTunnelService ? { recoveryTunnelService: loaded.recoveryTunnelService } : {}),
356
+ ...(loaded.primaryPublicTunnelService ? { primaryPublicTunnelService: loaded.primaryPublicTunnelService } : {}),
327
357
  ...(loaded.primaryRuntimeService ? { primaryRuntimeService: loaded.primaryRuntimeService } : {}),
328
358
  ...(typeof loaded.primaryRuntimeSourceRoot === 'string' ? { primaryRuntimeSourceRoot: resolve(loaded.primaryRuntimeSourceRoot) } : {}),
329
359
  ...(loaded.primaryConnectorService ? { primaryConnectorService: loaded.primaryConnectorService } : {}),
@@ -423,6 +453,7 @@ function watchdogProbeComponent(name: string): RecoveryWatchdogDiagnosticEvidenc
423
453
  if (name === 'runtime_status') return 'runtime';
424
454
  if (name === 'active_gateway') return 'gateway';
425
455
  if (name === 'recovery_gateway') return 'recovery_gateway';
456
+ if (name === 'recovery_watchdog') return 'recovery_watchdog';
426
457
  if (name === 'recovery_external_http') return 'recovery_tunnel';
427
458
  return 'public_mcp';
428
459
  }
@@ -541,7 +572,23 @@ async function withLock<T>(
541
572
  }
542
573
 
543
574
  export async function runtimeStatus(config: RecoveryConfig) {
544
- return observeRuntimeStatus(config.controllerHome);
575
+ const observation = observeRuntimeStatus(config.controllerHome);
576
+ const watchdog = loadWatchdogState(config);
577
+ return {
578
+ ...observation,
579
+ recoveryWatchdog: {
580
+ lastDecision: watchdog.lastDecision,
581
+ lastReason: watchdog.lastReason,
582
+ updatedAt: watchdog.updatedAt,
583
+ failures: watchdog.failures,
584
+ rollbackUsed: watchdog.rollbackUsed,
585
+ runtimeRestartAttempts: watchdog.runtimeRestartAttempts ?? 0,
586
+ runtimeRestartFailures: watchdog.runtimeRestartFailures ?? 0,
587
+ primaryConnectorFailures: watchdog.primaryConnectorFailures ?? 0,
588
+ primaryConnectorRestartAttempts: watchdog.primaryConnectorRestartAttempts ?? 0,
589
+ primaryConnectorRestartFailures: watchdog.primaryConnectorRestartFailures ?? 0,
590
+ },
591
+ };
545
592
  }
546
593
 
547
594
  async function probe(transport: RecoveryHttpTransport, url: string, timeoutMs = 4_000): Promise<{ ok: boolean; detail: string; status?: number }> {
@@ -583,6 +630,59 @@ async function probeExternalMcp(transport: RecoveryHttpTransport, url: string):
583
630
  } finally { clearTimeout(timer); }
584
631
  }
585
632
 
633
+ function publicGatewayReadinessEndpoint(endpoint: string): string {
634
+ const url = new URL(endpoint);
635
+ url.pathname = '/ready';
636
+ url.search = '';
637
+ url.hash = '';
638
+ return url.toString();
639
+ }
640
+
641
+ async function probePublicGatewayReadiness(
642
+ transport: RecoveryHttpTransport,
643
+ endpoint: string,
644
+ ): Promise<{ ok: boolean; detail: string; status?: number; value?: unknown }> {
645
+ const controller = new AbortController();
646
+ const timer = setTimeout(() => controller.abort('RECOVERY_HTTP_TIMEOUT'), 4_000);
647
+ try {
648
+ const response = await transport.request({
649
+ url: publicGatewayReadinessEndpoint(endpoint),
650
+ headers: { accept: 'application/json' },
651
+ timeoutMs: 4_000,
652
+ signal: controller.signal,
653
+ });
654
+ let sessionCapacity: unknown;
655
+ try {
656
+ const payload = JSON.parse(response.body) as { sessionCapacity?: unknown };
657
+ sessionCapacity = payload.sessionCapacity;
658
+ } catch { /* malformed readiness payload remains a failed/opaque probe */ }
659
+ const recoveryRecommended = Boolean(
660
+ sessionCapacity
661
+ && typeof sessionCapacity === 'object'
662
+ && (sessionCapacity as { recoveryRecommended?: unknown }).recoveryRecommended === true,
663
+ );
664
+ return {
665
+ ok: response.ok && !recoveryRecommended,
666
+ detail: `HTTP ${response.status}${recoveryRecommended ? '; session capacity recommends Connector recovery' : ''}`,
667
+ status: response.status,
668
+ ...(sessionCapacity === undefined ? {} : { value: sessionCapacity }),
669
+ };
670
+ } catch (error) {
671
+ return { ok: false, detail: error instanceof Error ? error.message.slice(0, 180) : 'request failed' };
672
+ } finally {
673
+ clearTimeout(timer);
674
+ }
675
+ }
676
+
677
+ function connectorCapacityRecoveryRecommended(verified: VerifyResult): boolean {
678
+ const value = verified.probes.primary_connector_ready?.value;
679
+ return Boolean(
680
+ value
681
+ && typeof value === 'object'
682
+ && (value as { recoveryRecommended?: unknown }).recoveryRecommended === true,
683
+ );
684
+ }
685
+
586
686
  function mainToken(config: RecoveryConfig): string | undefined {
587
687
  const candidate = config.mainMcpTokenFile ?? join(config.controllerHome, 'mcp', 'mcp.tokens.json');
588
688
  const parsed = json<{ bearerToken?: unknown }>(candidate);
@@ -753,8 +853,23 @@ export async function verifyStableRuntime(
753
853
  probes.active_gateway = endpoint
754
854
  ? await probe(transport, runtimeHealthEndpoint(endpoint))
755
855
  : { ok: false, detail: 'canonical Runtime endpoint is unavailable' };
756
- if (config.publicMcpUrl) probes.external_mcp_http = await probeExternalMcp(transport, config.publicMcpUrl);
856
+ if (config.publicMcpUrl) {
857
+ probes.external_mcp_http = await probeExternalMcp(transport, config.publicMcpUrl);
858
+ probes.primary_connector_ready = await probePublicGatewayReadiness(transport, config.publicMcpUrl);
859
+ }
757
860
  if (config.gateway) probes.recovery_gateway = await probe(transport, `http://${config.gateway.host}:${config.gateway.port}/health`);
861
+ const watchdogHealth = observeRecoveryWatchdogHealth(config.controllerHome);
862
+ probes.recovery_watchdog = {
863
+ ok: watchdogHealth.ok,
864
+ detail: watchdogHealth.detail,
865
+ value: {
866
+ pulseAgeMs: watchdogHealth.pulseAgeMs,
867
+ tickAgeMs: watchdogHealth.tickAgeMs,
868
+ currentReleaseRevision: watchdogHealth.currentReleaseRevision,
869
+ watchdogReleaseRevision: watchdogHealth.runtimeIdentity?.releaseRevision,
870
+ watchdogPid: watchdogHealth.runtimeIdentity?.pid,
871
+ },
872
+ };
758
873
  const recoveryPublicUrl = configuredRecoveryPublicUrl(config);
759
874
  if (recoveryPublicUrl) probes.recovery_external_http = await probeExternalMcp(transport, recoveryPublicUrl);
760
875
  if (options.probeMcpProtocol !== false) Object.assign(probes, await probeMcp(config, transport));
@@ -801,10 +916,8 @@ export async function verifyStableRuntime(
801
916
  return result;
802
917
  }
803
918
  /** Explicitly records evidence only after the full independent verification passed. */
804
- export async function attestKnownGood(config: RecoveryConfig): Promise<ReleaseEvidence> {
805
- const locked = await withLock(config, { action: 'attest_known_good' }, async () => {
806
- const verified = await verifyStableRuntime(config);
807
- const authority = releaseAuthority(config);
919
+ function persistVerifiedKnownGood(config: RecoveryConfig, verified: VerifyResult): ReleaseEvidence {
920
+ const authority = releaseAuthority(config);
808
921
  const active = verified.releases.active;
809
922
  if (
810
923
  !verified.ok
@@ -825,7 +938,10 @@ export async function attestKnownGood(config: RecoveryConfig): Promise<ReleaseEv
825
938
  attestedAt: new Date().toISOString(),
826
939
  };
827
940
  const store = knownGood(config);
828
- const releases = [attested, ...store.releases.filter((entry) => entry.path !== active.path)].slice(0, 8);
941
+ const releases = [
942
+ attested,
943
+ ...store.releases.filter((entry) => entry.path !== active.path && existsSync(entry.path)),
944
+ ].slice(0, 8);
829
945
  writeJson(statePath(config), { schemaVersion: 1, releases, updatedAt: new Date().toISOString() } satisfies KnownGoodStore);
830
946
  audit(config, 'known_good_attested', {
831
947
  revision: active.revision,
@@ -833,8 +949,13 @@ export async function attestKnownGood(config: RecoveryConfig): Promise<ReleaseEv
833
949
  manifestSha256: active.manifestSha256,
834
950
  releaseAuthorityRevision: authority.revision,
835
951
  });
836
- return attested;
837
- });
952
+ return attested;
953
+ }
954
+
955
+ export async function attestKnownGood(config: RecoveryConfig): Promise<ReleaseEvidence> {
956
+ const locked = await withLock(config, { action: 'attest_known_good' }, async () => (
957
+ persistVerifiedKnownGood(config, await verifyStableRuntime(config))
958
+ ));
838
959
  if (!locked.acquired) throw new Error(recoveryBusyDetail(locked.owner));
839
960
  return locked.value;
840
961
  }
@@ -849,8 +970,8 @@ function quarantine(config: RecoveryConfig, release: ReleaseEvidence | undefined
849
970
  async function rollbackPreviousLocked(config: RecoveryConfig, reason: string): Promise<RollbackResult> {
850
971
  const before = await verifyStableRuntime(config);
851
972
  const active = before.releases.active;
852
- if (matchingKnownGood(config, active)) {
853
- return { ok: true, noOp: true, detail: 'active whole-Runtime release is already independently attested known-good', verify: before };
973
+ if (before.ok && matchingKnownGood(config, active)) {
974
+ return { ok: true, noOp: true, detail: 'active whole-Runtime release is currently healthy and independently attested known-good', verify: before };
854
975
  }
855
976
  if (before.runtime.running) {
856
977
  return {
@@ -911,7 +1032,9 @@ export async function reconnectMain(config: RecoveryConfig): Promise<{ ok: boole
911
1032
  // intentionally a bounded health/reconnect observation, never a rollout.
912
1033
  const verified = await verifyStableRuntime(config);
913
1034
  const publicProbe = verified.probes.external_mcp_http;
914
- const ok = verified.probes.active_gateway?.ok === true && (publicProbe?.ok === true || publicProbe?.status === 401);
1035
+ const ok = verified.probes.active_gateway?.ok === true
1036
+ && verified.probes.primary_connector_ready?.ok !== false
1037
+ && (publicProbe?.ok === true || publicProbe?.status === 401);
915
1038
  audit(config, 'reconnect_main', { ok, externalStatus: publicProbe?.status });
916
1039
  return { ok, detail: ok ? 'canonical Runtime Gateway and primary endpoint are reachable; client session may refresh' : 'primary endpoint remains unavailable; recovery channel remains independent', verify: verified };
917
1040
  }
@@ -1037,6 +1160,16 @@ export function decideWatchdog(input: {
1037
1160
  rollbackUsed: boolean;
1038
1161
  recoveryGatewayFailed?: boolean;
1039
1162
  recoveryGatewayRestartUsed?: boolean;
1163
+ primaryConnectorConfigured?: boolean;
1164
+ primaryConnectorFailed?: boolean;
1165
+ primaryConnectorFailures?: number;
1166
+ primaryConnectorFirstFailureAt?: number;
1167
+ primaryConnectorRestartAttempts?: number;
1168
+ primaryConnectorMaximumRestartAttempts?: number;
1169
+ primaryConnectorRestartLastAttemptAt?: number;
1170
+ primaryConnectorRestartCooldownMs?: number;
1171
+ primaryConnectorMinimumFailures?: number;
1172
+ primaryConnectorMinimumFailureDurationMs?: number;
1040
1173
  primaryRuntimeFailed?: boolean;
1041
1174
  runtimeRestartAttempts?: number;
1042
1175
  runtimeMaximumRestartAttempts?: number;
@@ -1064,6 +1197,22 @@ export function decideWatchdog(input: {
1064
1197
  if (failures >= minimumFailures && sustained) return { action: 'repair_public_tunnel', reason: 'local runtime and Recovery Gateway are healthy while the dedicated Recovery public endpoint is unavailable' };
1065
1198
  return { action: 'degraded', reason: 'Recovery tunnel failure has not yet met the bounded repair threshold' };
1066
1199
  }
1200
+ if (input.primaryConnectorConfigured && input.primaryConnectorFailed) {
1201
+ const failures = input.primaryConnectorFailures ?? 0;
1202
+ const minimumFailures = Math.max(1, input.primaryConnectorMinimumFailures ?? 2);
1203
+ const minimumDurationMs = Math.max(0, input.primaryConnectorMinimumFailureDurationMs ?? 5_000);
1204
+ const sustained = input.primaryConnectorFirstFailureAt !== undefined && now - input.primaryConnectorFirstFailureAt >= minimumDurationMs;
1205
+ const attempts = input.primaryConnectorRestartAttempts ?? 0;
1206
+ const maximumAttempts = Math.max(1, input.primaryConnectorMaximumRestartAttempts ?? 3);
1207
+ const cooldownMs = Math.max(0, input.primaryConnectorRestartCooldownMs ?? 30_000);
1208
+ const cooldownElapsed = input.primaryConnectorRestartLastAttemptAt === undefined || now - input.primaryConnectorRestartLastAttemptAt >= cooldownMs;
1209
+ if (failures >= minimumFailures && sustained && attempts < maximumAttempts && cooldownElapsed) {
1210
+ return { action: 'restart_primary_connector', reason: `local Runtime is healthy but the primary Connector endpoint is unavailable; restart attempt ${attempts + 1}/${maximumAttempts}` };
1211
+ }
1212
+ return { action: 'degraded', reason: attempts >= maximumAttempts
1213
+ ? `primary Connector restart budget exhausted (${attempts}/${maximumAttempts}); keeping the local Runtime available for independent Recovery`
1214
+ : 'primary Connector failure has not yet met its bounded restart threshold or cooldown' };
1215
+ }
1067
1216
  if (input.failures === 0) return { action: 'healthy', reason: 'all recovery probes healthy' };
1068
1217
  const recoveryRestartSustained = input.firstFailureAt !== undefined && now - input.firstFailureAt >= 5_000;
1069
1218
  if (input.failures >= 2 && recoveryRestartSustained && input.recoveryGatewayFailed && !input.recoveryGatewayRestartUsed) {
@@ -1258,6 +1407,9 @@ export interface PrimaryConnectorRecoveryDependencies {
1258
1407
  runCommand?: CommandRunner;
1259
1408
  verifyLocal?: (config: RecoveryConfig) => Promise<VerifyResult>;
1260
1409
  reconnect?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string; verify: VerifyResult }>;
1410
+ probeConnectorLocal?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string; status?: number } | undefined>;
1411
+ probeConnectorOwnership?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string }>;
1412
+ repairConnectorBinding?: (config: RecoveryConfig) => Promise<{ ok: boolean; attempted: boolean; noOp?: boolean; detail: string }>;
1261
1413
  now?: () => number;
1262
1414
  sleep?: (ms: number) => Promise<void>;
1263
1415
  }
@@ -1272,6 +1424,79 @@ function primaryConnectorLaunchdService(config: RecoveryConfig, uid: number): La
1272
1424
  return { uid, domain, target: `${domain}/${label}`, label, plistPath };
1273
1425
  }
1274
1426
 
1427
+ async function probePrimaryConnectorLocal(config: RecoveryConfig): Promise<{ ok: boolean; detail: string; status?: number } | undefined> {
1428
+ const localMcpUrl = config.primaryConnectorService?.localMcpUrl?.trim();
1429
+ if (!localMcpUrl) return undefined;
1430
+ return probeExternalMcp(createRecoveryHttpTransport(config.controllerHome), localMcpUrl);
1431
+ }
1432
+
1433
+ function activePackageConnectorReleaseBinding(config: RecoveryConfig): PackageConnectorReleaseBinding | undefined {
1434
+ const configured = config.primaryConnectorService;
1435
+ if (!configured || configured.platform !== 'launchd' || !configured.localMcpUrl?.trim()) return undefined;
1436
+ const authority = readRuntimeReleaseAuthority(config.controllerHome);
1437
+ if (!authority) return undefined;
1438
+ const releaseRoot = dirname(resolve(authority.active.manifestPath));
1439
+ const packageRoot = join(releaseRoot, 'package');
1440
+ const paths = packageConnectorServicePaths(config.controllerHome);
1441
+ const configuredPlist = resolve(configured.plistPath ?? paths.installedPlistPath);
1442
+ if (configured.label !== paths.label || configuredPlist !== resolve(paths.installedPlistPath)) return undefined;
1443
+ if (!existsSync(join(packageRoot, 'src', 'cli', 'index.ts')) || !existsSync(join(packageRoot, 'src', 'runtime', 'shared', 'node-ts-loader.mjs'))) return undefined;
1444
+ return { releaseId: authority.active.releaseId, releaseRoot, packageRoot };
1445
+ }
1446
+
1447
+ async function repairPrimaryConnectorBinding(config: RecoveryConfig): Promise<{ ok: boolean; attempted: boolean; noOp?: boolean; detail: string }> {
1448
+ const release = activePackageConnectorReleaseBinding(config);
1449
+ const endpoint = config.primaryConnectorService?.localMcpUrl?.trim();
1450
+ if (!release || !endpoint) return { ok: true, attempted: false, noOp: true, detail: 'primary Connector does not use the canonical package-release binding contract' };
1451
+ try {
1452
+ const result = await ensurePackageConnectorService({ release, controllerHome: config.controllerHome, endpoint, platform: 'darwin' });
1453
+ const attempted = result.reused !== true;
1454
+ return {
1455
+ ok: true,
1456
+ attempted,
1457
+ ...(attempted ? {} : { noOp: true }),
1458
+ detail: attempted
1459
+ ? `primary Connector binding repaired to active immutable Runtime release ${release.releaseId}`
1460
+ : `primary Connector binding already matches active immutable Runtime release ${release.releaseId}`,
1461
+ };
1462
+ } catch (error) {
1463
+ return { ok: false, attempted: true, detail: error instanceof Error ? error.message : 'primary Connector binding repair failed' };
1464
+ }
1465
+ }
1466
+
1467
+ async function probePrimaryConnectorOwnership(
1468
+ config: RecoveryConfig,
1469
+ service: LaunchdService,
1470
+ runCommand: CommandRunner = command,
1471
+ ): Promise<{ ok: boolean; detail: string }> {
1472
+ const localMcpUrl = config.primaryConnectorService?.localMcpUrl?.trim();
1473
+ if (!localMcpUrl) return { ok: false, detail: 'primary Connector local MCP endpoint is not configured' };
1474
+ let port: number;
1475
+ try {
1476
+ const parsed = new URL(localMcpUrl);
1477
+ const rawPort = parsed.port || (parsed.protocol === 'https:' ? '443' : parsed.protocol === 'http:' ? '80' : '');
1478
+ port = Number(rawPort);
1479
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error('invalid port');
1480
+ } catch {
1481
+ return { ok: false, detail: `primary Connector local MCP endpoint has no verifiable TCP port: ${localMcpUrl}` };
1482
+ }
1483
+ const printed = await runCommand('launchctl', ['print', service.target], 5_000);
1484
+ const pidMatch = printed.ok ? printed.stdout.match(/\bpid\s*=\s*(\d+)/) : null;
1485
+ const pid = pidMatch ? Number(pidMatch[1]) : undefined;
1486
+ if (!pid || !Number.isInteger(pid)) {
1487
+ return { ok: false, detail: `configured primary Connector launchd service has no live pid: ${service.target}` };
1488
+ }
1489
+ const listening = await runCommand('lsof', ['-nP', '-a', '-p', String(pid), `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], 5_000);
1490
+ const listenerPids = listening.stdout.split(/\s+/).map((value) => Number(value)).filter(Number.isInteger);
1491
+ const ok = listening.ok && listenerPids.includes(pid);
1492
+ return {
1493
+ ok,
1494
+ detail: ok
1495
+ ? `configured primary Connector pid ${pid} owns TCP ${port}`
1496
+ : `configured primary Connector pid ${pid} does not own TCP ${port}`,
1497
+ };
1498
+ }
1499
+
1275
1500
  export async function restartPrimaryConnector(
1276
1501
  config: RecoveryConfig,
1277
1502
  dependencies: PrimaryConnectorRecoveryDependencies = {},
@@ -1296,21 +1521,107 @@ export async function restartPrimaryConnector(
1296
1521
  return { ok: false, attempted: false, noOp: true, detail: 'primary Connector launchd service is not configured or installed', verify: initialLocal };
1297
1522
  }
1298
1523
  const reconnect = dependencies.reconnect ?? reconnectMain;
1524
+ const probeConnectorLocal = dependencies.probeConnectorLocal ?? probePrimaryConnectorLocal;
1525
+ const runCommand = dependencies.runCommand ?? command;
1526
+ const probeConnectorOwnership = dependencies.probeConnectorOwnership
1527
+ ?? ((candidateConfig: RecoveryConfig) => probePrimaryConnectorOwnership(candidateConfig, service, runCommand));
1299
1528
  const now = dependencies.now ?? Date.now;
1300
1529
  const wait = dependencies.sleep ?? sleep;
1301
1530
  const timeoutMs = config.primaryConnectorService?.postRestartVerifyTimeoutMs ?? 30_000;
1302
1531
  const locked = await withLock(config, { action: 'restart_primary_connector' }, async () => {
1303
- const restarted = await ensureLaunchdServiceStarted(service, dependencies.runCommand ?? command);
1304
- if (!restarted.ok) {
1305
- audit(config, 'primary_connector_restart_failed', { serviceTarget: service.target, detail: restarted.detail });
1306
- return { ok: false, attempted: true, detail: restarted.detail, serviceTarget: service.target, verify: initialLocal } satisfies PrimaryConnectorRestartResult;
1532
+ const tunnelConfigured = Boolean(configuredPrimaryPublicTunnel(config));
1533
+ const repairConnectorBinding = dependencies.repairConnectorBinding ?? repairPrimaryConnectorBinding;
1534
+ const bindingRepair = await repairConnectorBinding(config);
1535
+ if (bindingRepair.attempted) {
1536
+ audit(config, bindingRepair.ok ? 'primary_connector_binding_repaired' : 'primary_connector_binding_repair_failed', { detail: bindingRepair.detail });
1307
1537
  }
1308
- const deadline = now() + timeoutMs;
1309
- let observed = await reconnect(config);
1310
- while (!observed.ok && now() < deadline) {
1311
- await wait(1_000);
1312
- observed = await reconnect(config);
1538
+ if (!bindingRepair.ok) {
1539
+ return { ok: false, attempted: true, detail: `primary Connector immutable release binding repair failed: ${bindingRepair.detail}`, serviceTarget: service.target, verify: initialLocal } satisfies PrimaryConnectorRestartResult;
1540
+ }
1541
+ let localConnector = await probeConnectorLocal(config);
1542
+ let connectorOwnership = localConnector?.ok ? await probeConnectorOwnership(config) : undefined;
1543
+ let observed = localConnector?.ok && connectorOwnership?.ok ? await reconnect(config) : undefined;
1544
+ if (observed?.ok) {
1545
+ return {
1546
+ ok: true,
1547
+ attempted: bindingRepair.attempted,
1548
+ ...(bindingRepair.attempted ? {} : { noOp: true }),
1549
+ detail: bindingRepair.attempted
1550
+ ? 'primary Connector immutable release binding was repaired and the public MCP endpoint recovered'
1551
+ : 'primary Connector and public MCP endpoint recovered before restart',
1552
+ serviceTarget: service.target,
1553
+ verify: observed.verify,
1554
+ } satisfies PrimaryConnectorRestartResult;
1555
+ }
1556
+
1557
+ // A healthy local OAuth endpoint proves the Connector is not the broken hop.
1558
+ // If a distinct primary tunnel is configured, skip the pointless Gateway
1559
+ // restart and repair the public hop directly.
1560
+ const restartConnector = !localConnector?.ok || !connectorOwnership?.ok || !tunnelConfigured;
1561
+ if (restartConnector) {
1562
+ const restarted = await ensureLaunchdServiceStarted(service, runCommand);
1563
+ if (!restarted.ok) {
1564
+ audit(config, 'primary_connector_restart_failed', { serviceTarget: service.target, detail: restarted.detail });
1565
+ return { ok: false, attempted: true, detail: restarted.detail, serviceTarget: service.target, verify: initialLocal } satisfies PrimaryConnectorRestartResult;
1566
+ }
1567
+
1568
+ const localDeadline = now() + timeoutMs;
1569
+ localConnector = await probeConnectorLocal(config);
1570
+ while (localConnector && !localConnector.ok && now() < localDeadline) {
1571
+ await wait(1_000);
1572
+ localConnector = await probeConnectorLocal(config);
1573
+ }
1574
+ connectorOwnership = localConnector?.ok ? await probeConnectorOwnership(config) : undefined;
1575
+ if (localConnector && (!localConnector.ok || !connectorOwnership?.ok)) {
1576
+ const detail = localConnector.ok
1577
+ ? (connectorOwnership?.detail ?? 'configured primary Connector listener ownership could not be verified')
1578
+ : localConnector.detail;
1579
+ audit(config, 'primary_connector_restart_local_unverified', { serviceTarget: service.target, detail });
1580
+ return {
1581
+ ok: false,
1582
+ attempted: true,
1583
+ detail: localConnector.ok
1584
+ ? 'primary Connector restarted but the configured launchd service does not own its local MCP listener'
1585
+ : 'primary Connector restarted but its local MCP endpoint did not recover before timeout',
1586
+ serviceTarget: service.target,
1587
+ verify: initialLocal,
1588
+ } satisfies PrimaryConnectorRestartResult;
1589
+ }
1313
1590
  }
1591
+
1592
+ observed ??= await reconnect(config);
1593
+ if (!observed.ok && localConnector?.ok && connectorOwnership?.ok && configuredPrimaryPublicTunnel(config)) {
1594
+ const tunnel = primaryPublicTunnelService(config, service.uid);
1595
+ if (tunnel) {
1596
+ const tunnelRestarted = await ensureLaunchdServiceStarted(tunnel, dependencies.runCommand ?? command);
1597
+ if (!tunnelRestarted.ok) {
1598
+ audit(config, 'primary_public_tunnel_restart_failed', { serviceTarget: tunnel.target, detail: tunnelRestarted.detail });
1599
+ return {
1600
+ ok: false,
1601
+ attempted: true,
1602
+ detail: `primary Connector is locally healthy but public tunnel restart failed: ${tunnelRestarted.detail}`,
1603
+ serviceTarget: service.target,
1604
+ verify: observed.verify,
1605
+ } satisfies PrimaryConnectorRestartResult;
1606
+ }
1607
+ const tunnelDeadline = now() + (configuredPrimaryPublicTunnel(config)?.postRestartVerifyTimeoutMs ?? 20_000);
1608
+ while (!observed.ok && now() < tunnelDeadline) {
1609
+ await wait(1_000);
1610
+ observed = await reconnect(config);
1611
+ }
1612
+ audit(config, observed.ok ? 'primary_public_tunnel_restart_succeeded' : 'primary_public_tunnel_restart_unverified', {
1613
+ serviceTarget: tunnel.target,
1614
+ detail: observed.detail,
1615
+ });
1616
+ }
1617
+ } else {
1618
+ const deadline = now() + timeoutMs;
1619
+ while (!observed.ok && now() < deadline) {
1620
+ await wait(1_000);
1621
+ observed = await reconnect(config);
1622
+ }
1623
+ }
1624
+
1314
1625
  audit(config, observed.ok ? 'primary_connector_restart_succeeded' : 'primary_connector_restart_unverified', {
1315
1626
  serviceTarget: service.target,
1316
1627
  detail: observed.detail,
@@ -1318,7 +1629,9 @@ export async function restartPrimaryConnector(
1318
1629
  return {
1319
1630
  ok: observed.ok,
1320
1631
  attempted: true,
1321
- detail: observed.ok ? 'primary Connector restarted and the public MCP endpoint is reachable' : 'primary Connector restarted but the public MCP endpoint did not recover before timeout',
1632
+ detail: observed.ok
1633
+ ? 'primary Connector recovery completed and the public MCP endpoint is reachable'
1634
+ : 'primary Connector recovery completed but the public MCP endpoint did not recover before timeout',
1322
1635
  serviceTarget: service.target,
1323
1636
  verify: observed.verify,
1324
1637
  } satisfies PrimaryConnectorRestartResult;
@@ -1362,6 +1675,44 @@ async function waitForLaunchdServiceUnloaded(input: {
1362
1675
  return !printed.ok;
1363
1676
  }
1364
1677
 
1678
+ interface PrimaryRuntimePortObservation {
1679
+ port: number;
1680
+ pids: number[];
1681
+ uncertain?: string;
1682
+ }
1683
+
1684
+ interface PrimaryRuntimePortCleanupResult {
1685
+ released: boolean;
1686
+ cleaned: boolean;
1687
+ detail: string;
1688
+ pid?: number;
1689
+ signal?: 'SIGTERM' | 'SIGKILL';
1690
+ }
1691
+
1692
+ async function observePrimaryRuntimePort(config: RecoveryConfig, runCommand: CommandRunner): Promise<PrimaryRuntimePortObservation> {
1693
+ const paths = forgeRuntimeServicePaths(config.controllerHome);
1694
+ let port: number;
1695
+ try {
1696
+ port = readForgeRuntimeServiceConfig(paths.configPath).port;
1697
+ } catch {
1698
+ return { port: -1, pids: [], uncertain: 'primary Runtime service port could not be read' };
1699
+ }
1700
+ const result = await runCommand('lsof', ['-nP', '-Fp', `-iTCP:${port}`, '-sTCP:LISTEN'], 3_000);
1701
+ if (result.status === 1 && result.stderr.trim().length === 0) return { port, pids: [] };
1702
+ if (!result.ok) {
1703
+ return { port, pids: [], uncertain: `primary Runtime listener observation failed: ${result.stderr || result.stdout || result.status}` };
1704
+ }
1705
+ const pids = [...new Set(result.stdout.split(/\r?\n/)
1706
+ .map((line) => /^p(\d+)$/.exec(line.trim())?.[1])
1707
+ .filter((value): value is string => Boolean(value))
1708
+ .map(Number)
1709
+ .filter((value) => Number.isInteger(value) && value > 0))];
1710
+ if (result.stdout.trim().length > 0 && pids.length === 0) {
1711
+ return { port, pids: [], uncertain: 'primary Runtime listener identity was not machine-readable' };
1712
+ }
1713
+ return { port, pids };
1714
+ }
1715
+
1365
1716
  async function waitForPrimaryRuntimePortReleased(input: {
1366
1717
  config: RecoveryConfig;
1367
1718
  timeoutMs: number;
@@ -1369,25 +1720,102 @@ async function waitForPrimaryRuntimePortReleased(input: {
1369
1720
  wait: (ms: number) => Promise<void>;
1370
1721
  runCommand: CommandRunner;
1371
1722
  }): Promise<boolean> {
1372
- const paths = forgeRuntimeServicePaths(input.config.controllerHome);
1373
- let port: number;
1374
- try {
1375
- port = readForgeRuntimeServiceConfig(paths.configPath).port;
1376
- } catch {
1377
- return false;
1378
- }
1379
- const isListening = async (): Promise<boolean> => {
1380
- const result = await input.runCommand('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], 3_000);
1381
- if (result.ok) return result.stdout.trim().length > 0;
1382
- if (result.status === 1 && result.stderr.trim().length === 0) return false;
1383
- return true;
1384
- };
1385
1723
  const deadline = input.now() + input.timeoutMs;
1386
1724
  while (input.now() < deadline) {
1387
- if (!(await isListening())) return true;
1725
+ const observation = await observePrimaryRuntimePort(input.config, input.runCommand);
1726
+ if (!observation.uncertain && observation.pids.length === 0) return true;
1388
1727
  await input.wait(250);
1389
1728
  }
1390
- return !(await isListening());
1729
+ const observation = await observePrimaryRuntimePort(input.config, input.runCommand);
1730
+ return !observation.uncertain && observation.pids.length === 0;
1731
+ }
1732
+
1733
+ function commandLineHasArgument(commandLine: string, flag: string, value: string): boolean {
1734
+ const candidates = [
1735
+ `${flag} ${value}`,
1736
+ `${flag} "${value}"`,
1737
+ `${flag} '${value}'`,
1738
+ ];
1739
+ return candidates.some((candidate) => {
1740
+ const index = commandLine.indexOf(candidate);
1741
+ if (index < 0) return false;
1742
+ const before = index === 0 ? '' : commandLine[index - 1];
1743
+ const afterIndex = index + candidate.length;
1744
+ const after = afterIndex >= commandLine.length ? '' : commandLine[afterIndex];
1745
+ return (!before || /\s/.test(before)) && (!after || /\s/.test(after));
1746
+ });
1747
+ }
1748
+
1749
+ async function verifyPrimaryRuntimeListenerIdentity(input: {
1750
+ config: RecoveryConfig;
1751
+ pid: number;
1752
+ uid: number;
1753
+ port: number;
1754
+ runCommand: CommandRunner;
1755
+ }): Promise<{ ok: boolean; detail: string }> {
1756
+ const paths = forgeRuntimeServicePaths(input.config.controllerHome);
1757
+ const physicalEntrypoint = activeRuntimeEntrypoint(input.config.controllerHome);
1758
+ const launchSpec = activeRuntimeLaunchSpec(input.config.controllerHome);
1759
+ if (!physicalEntrypoint || !launchSpec) return { ok: false, detail: 'active Runtime release identity is unavailable' };
1760
+ const manifestIndex = launchSpec.args.indexOf('--release-manifest');
1761
+ const manifestPath = manifestIndex >= 0 ? launchSpec.args[manifestIndex + 1] : undefined;
1762
+ if (!manifestPath) return { ok: false, detail: 'active Runtime release manifest identity is unavailable' };
1763
+
1764
+ const result = await input.runCommand('ps', ['-ww', '-p', String(input.pid), '-o', 'uid=', '-o', 'command='], 3_000);
1765
+ if (!result.ok) return { ok: false, detail: `listener process identity could not be read: ${result.stderr || result.stdout || result.status}` };
1766
+ const line = result.stdout.trim().split(/\r?\n/).find(Boolean) ?? '';
1767
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line);
1768
+ if (!match) return { ok: false, detail: 'listener process identity was not machine-readable' };
1769
+ const observedUid = Number(match[1]);
1770
+ const commandLine = match[2]!.trim();
1771
+ if (observedUid !== input.uid) return { ok: false, detail: `listener uid ${observedUid} does not match Recovery uid ${input.uid}` };
1772
+ const executableMatches = [resolve(paths.activeEntrypointPath), resolve(physicalEntrypoint)]
1773
+ .some((candidate) => commandLine === candidate || commandLine.startsWith(`${candidate} `));
1774
+ if (!executableMatches) return { ok: false, detail: 'listener executable is not the active Forge Runtime entrypoint' };
1775
+ if (!commandLineHasArgument(commandLine, '--controller-home', resolve(input.config.controllerHome))) {
1776
+ return { ok: false, detail: 'listener controller-home identity does not match' };
1777
+ }
1778
+ if (!commandLineHasArgument(commandLine, '--port', String(input.port))) {
1779
+ return { ok: false, detail: 'listener port identity does not match' };
1780
+ }
1781
+ if (!commandLineHasArgument(commandLine, '--release-manifest', resolve(manifestPath))) {
1782
+ return { ok: false, detail: 'listener release-manifest identity does not match' };
1783
+ }
1784
+ return { ok: true, detail: 'listener matches the current Forge Runtime release and controller identity' };
1785
+ }
1786
+
1787
+ async function cleanupVerifiedStalePrimaryRuntimeListener(input: {
1788
+ config: RecoveryConfig;
1789
+ uid: number;
1790
+ now: () => number;
1791
+ wait: (ms: number) => Promise<void>;
1792
+ runCommand: CommandRunner;
1793
+ }): Promise<PrimaryRuntimePortCleanupResult> {
1794
+ const initial = await observePrimaryRuntimePort(input.config, input.runCommand);
1795
+ if (initial.uncertain) return { released: false, cleaned: false, detail: initial.uncertain };
1796
+ if (initial.pids.length === 0) return { released: true, cleaned: false, detail: 'primary Runtime port is already released' };
1797
+ if (initial.pids.length !== 1) return { released: false, cleaned: false, detail: `primary Runtime port has ${initial.pids.length} listeners; cleanup requires exactly one` };
1798
+ const pid = initial.pids[0]!;
1799
+ const identity = await verifyPrimaryRuntimeListenerIdentity({ config: input.config, pid, uid: input.uid, port: initial.port, runCommand: input.runCommand });
1800
+ if (!identity.ok) return { released: false, cleaned: false, detail: identity.detail, pid };
1801
+
1802
+ const term = await input.runCommand('kill', ['-TERM', String(pid)], 3_000);
1803
+ const releasedAfterTerm = await waitForPrimaryRuntimePortReleased({ ...input, timeoutMs: 5_000 });
1804
+ if (releasedAfterTerm) return { released: true, cleaned: true, detail: 'verified stale Forge Runtime listener released after SIGTERM', pid, signal: 'SIGTERM' };
1805
+ if (!term.ok) return { released: false, cleaned: false, detail: `verified listener SIGTERM failed: ${term.stderr || term.stdout || term.status}`, pid };
1806
+
1807
+ const beforeKill = await observePrimaryRuntimePort(input.config, input.runCommand);
1808
+ if (beforeKill.uncertain || beforeKill.pids.length !== 1 || beforeKill.pids[0] !== pid) {
1809
+ return { released: false, cleaned: false, detail: 'listener identity changed after SIGTERM; refusing SIGKILL', pid };
1810
+ }
1811
+ const identityBeforeKill = await verifyPrimaryRuntimeListenerIdentity({ config: input.config, pid, uid: input.uid, port: beforeKill.port, runCommand: input.runCommand });
1812
+ if (!identityBeforeKill.ok) return { released: false, cleaned: false, detail: `listener identity changed after SIGTERM: ${identityBeforeKill.detail}`, pid };
1813
+ const killed = await input.runCommand('kill', ['-KILL', String(pid)], 3_000);
1814
+ if (!killed.ok) return { released: false, cleaned: false, detail: `verified listener SIGKILL failed: ${killed.stderr || killed.stdout || killed.status}`, pid };
1815
+ const releasedAfterKill = await waitForPrimaryRuntimePortReleased({ ...input, timeoutMs: 5_000 });
1816
+ return releasedAfterKill
1817
+ ? { released: true, cleaned: true, detail: 'verified stale Forge Runtime listener released after SIGKILL', pid, signal: 'SIGKILL' }
1818
+ : { released: false, cleaned: false, detail: 'verified stale Forge Runtime listener remained on the port after SIGKILL', pid };
1391
1819
  }
1392
1820
 
1393
1821
  async function verifyPrimaryRuntimeAfterStart(input: {
@@ -1489,10 +1917,23 @@ export async function recoverPrimaryRuntime(
1489
1917
  return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1490
1918
  }
1491
1919
 
1492
- const portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
1920
+ let portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
1921
+ let staleListenerCleanup: PrimaryRuntimePortCleanupResult | undefined;
1493
1922
  if (!portReleased) {
1494
- const detail = 'primary Runtime TCP port remained occupied after bounded launchd bootout';
1495
- audit(config, 'primary_runtime_recovery_stop_unverified', { serviceTarget: service.target, detail });
1923
+ staleListenerCleanup = await cleanupVerifiedStalePrimaryRuntimeListener({ config, uid: service.uid, now, wait, runCommand });
1924
+ portReleased = staleListenerCleanup.released;
1925
+ if (staleListenerCleanup.cleaned) {
1926
+ audit(config, 'primary_runtime_recovery_stale_listener_cleaned', {
1927
+ serviceTarget: service.target,
1928
+ pid: staleListenerCleanup.pid,
1929
+ signal: staleListenerCleanup.signal,
1930
+ detail: staleListenerCleanup.detail,
1931
+ });
1932
+ }
1933
+ }
1934
+ if (!portReleased) {
1935
+ const detail = `primary Runtime TCP port remained occupied after bounded launchd bootout: ${staleListenerCleanup?.detail ?? 'listener did not release'}`;
1936
+ audit(config, 'primary_runtime_recovery_stop_unverified', { serviceTarget: service.target, detail, pid: staleListenerCleanup?.pid });
1496
1937
  return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
1497
1938
  }
1498
1939
 
@@ -1668,7 +2109,25 @@ export async function activateRuntimeRelease(
1668
2109
  } satisfies RuntimeReleaseActivationResult;
1669
2110
  }
1670
2111
  if (current) {
1671
- if (runtimeBehaviorEquivalent(current.active.manifestPath, candidate.manifestPath) && !storageMigrationRequired) {
2112
+ const behaviorEquivalent = runtimeBehaviorEquivalent(current.active.manifestPath, candidate.manifestPath);
2113
+ let serviceContractMatches = false;
2114
+ if (behaviorEquivalent && !storageMigrationRequired) {
2115
+ try {
2116
+ serviceContractMatches = inspectForgeRuntimeLaunchAgentContract({
2117
+ controllerHome: config.controllerHome,
2118
+ inspectUserLaunchAgent: true,
2119
+ }).matches;
2120
+ } catch (error) {
2121
+ audit(config, 'runtime_release_activation_service_contract_inspection_failed', {
2122
+ operationId,
2123
+ requestId: lockRequestId,
2124
+ activeRevision: current.active.releaseId,
2125
+ candidateRevision: candidate.manifest.releaseId,
2126
+ detail: error instanceof Error ? error.message : String(error),
2127
+ });
2128
+ }
2129
+ }
2130
+ if (behaviorEquivalent && !storageMigrationRequired && serviceContractMatches) {
1672
2131
  const verify = await verifyLocal(config);
1673
2132
  audit(config, 'runtime_release_activation_behavior_identical', {
1674
2133
  operationId,
@@ -1682,8 +2141,8 @@ export async function activateRuntimeRelease(
1682
2141
  attempted: false,
1683
2142
  noOp: true,
1684
2143
  detail: verify.ok
1685
- ? 'candidate Runtime behavior is identical to the active release; restart skipped'
1686
- : 'candidate Runtime behavior is identical to the active release, but the active Runtime is unhealthy',
2144
+ ? 'candidate Runtime behavior and launchd service contract are identical to the active release; restart skipped'
2145
+ : 'candidate Runtime behavior and launchd service contract are identical to the active release, but the active Runtime is unhealthy',
1687
2146
  operationId,
1688
2147
  verify,
1689
2148
  } satisfies RuntimeReleaseActivationResult;
@@ -1720,10 +2179,24 @@ export async function activateRuntimeRelease(
1720
2179
  audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId, detail });
1721
2180
  return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
1722
2181
  }
1723
- const portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
2182
+ let portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
2183
+ let staleListenerCleanup: PrimaryRuntimePortCleanupResult | undefined;
1724
2184
  if (!portReleased) {
1725
- const detail = 'primary Runtime TCP port remained occupied after bounded launchd bootout';
1726
- audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId, detail });
2185
+ staleListenerCleanup = await cleanupVerifiedStalePrimaryRuntimeListener({ config, uid: service.uid, now, wait, runCommand });
2186
+ portReleased = staleListenerCleanup.released;
2187
+ if (staleListenerCleanup.cleaned) {
2188
+ audit(config, 'runtime_release_activation_stale_listener_cleaned', {
2189
+ serviceTarget: service.target,
2190
+ operationId,
2191
+ pid: staleListenerCleanup.pid,
2192
+ signal: staleListenerCleanup.signal,
2193
+ detail: staleListenerCleanup.detail,
2194
+ });
2195
+ }
2196
+ }
2197
+ if (!portReleased) {
2198
+ const detail = `primary Runtime TCP port remained occupied after bounded launchd bootout: ${staleListenerCleanup?.detail ?? 'listener did not release'}`;
2199
+ audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId, detail, pid: staleListenerCleanup?.pid });
1727
2200
  return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
1728
2201
  }
1729
2202
  let committed: RuntimeReleaseAuthority;
@@ -1740,7 +2213,7 @@ export async function activateRuntimeRelease(
1740
2213
  return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
1741
2214
  }
1742
2215
  try {
1743
- ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome });
2216
+ ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome, installUserLaunchAgent: true });
1744
2217
  } catch (error) {
1745
2218
  const detail = `runtime launchd stable-release contract update failed: ${error instanceof Error ? error.message : String(error)}`;
1746
2219
  audit(config, 'runtime_release_activation_launchd_contract_failed', { serviceTarget: service.target, operationId, detail });
@@ -1821,7 +2294,7 @@ export async function activateRuntimeRelease(
1821
2294
  physicalHome: storageMigration.physicalHome,
1822
2295
  });
1823
2296
  }
1824
- ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome });
2297
+ ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome, installUserLaunchAgent: true });
1825
2298
  const previousRevision = previousActive?.releaseId ?? '';
1826
2299
  const previousIdentity = previousActive?.artifactIdentity;
1827
2300
  if (
@@ -1933,8 +2406,7 @@ export async function stageAndActivateConfiguredRuntimeRelease(
1933
2406
  };
1934
2407
  }
1935
2408
 
1936
- function recoveryTunnelService(config: RecoveryConfig, uid: number): LaunchdService | undefined {
1937
- const configured = configuredRecoveryTunnel(config);
2409
+ function tunnelLaunchdService(configured: PublicTunnelServiceConfig | undefined, uid: number): LaunchdService | undefined {
1938
2410
  if (!configured || configured.platform !== 'launchd') return undefined;
1939
2411
  if (!/^com\.[A-Za-z0-9._-]{1,180}$/.test(configured.label)) return undefined;
1940
2412
  const plistPath = configured.plistPath;
@@ -1948,6 +2420,14 @@ function recoveryTunnelService(config: RecoveryConfig, uid: number): LaunchdServ
1948
2420
  };
1949
2421
  }
1950
2422
 
2423
+ function recoveryTunnelService(config: RecoveryConfig, uid: number): LaunchdService | undefined {
2424
+ return tunnelLaunchdService(configuredRecoveryTunnel(config), uid);
2425
+ }
2426
+
2427
+ function primaryPublicTunnelService(config: RecoveryConfig, uid: number): LaunchdService | undefined {
2428
+ return tunnelLaunchdService(configuredPrimaryPublicTunnel(config), uid);
2429
+ }
2430
+
1951
2431
  function tunnelRepairAllowed(config: RecoveryConfig, now: number): boolean {
1952
2432
  const cooldownMs = configuredRecoveryTunnel(config)?.cooldownMs ?? 60_000;
1953
2433
  const prior = json<{ lastAttemptAt?: unknown }>(publicTunnelRepairStatePath(config));
@@ -2015,17 +2495,32 @@ export interface RecoveryGatewayRestartResult {
2015
2495
  serviceTarget?: string;
2016
2496
  }
2017
2497
 
2018
- export interface RecoveryGatewayRestartDependencies {
2498
+ export interface RecoveryWatchdogRestartResult {
2499
+ ok: boolean;
2500
+ attempted: boolean;
2501
+ noOp?: boolean;
2502
+ detail: string;
2503
+ serviceTarget?: string;
2504
+ }
2505
+
2506
+ interface RecoveryRoleRestartDependencies {
2019
2507
  platform?: NodeJS.Platform;
2020
2508
  currentUid?: () => Promise<number | undefined>;
2021
2509
  runCommand?: CommandRunner;
2022
- probeGateway?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string }>;
2023
2510
  now?: () => number;
2024
2511
  sleep?: (ms: number) => Promise<void>;
2025
2512
  }
2026
2513
 
2027
- function recoveryGatewayLaunchdService(config: RecoveryConfig, uid: number): LaunchdService {
2028
- const label = 'com.moretea.forge-recovery-gateway';
2514
+ export interface RecoveryGatewayRestartDependencies extends RecoveryRoleRestartDependencies {
2515
+ probeGateway?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string }>;
2516
+ }
2517
+
2518
+ export interface RecoveryWatchdogRestartDependencies extends RecoveryRoleRestartDependencies {
2519
+ probeWatchdog?: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string }>;
2520
+ }
2521
+
2522
+ function recoveryRoleLaunchdService(config: RecoveryConfig, uid: number, role: 'gateway' | 'watchdog'): LaunchdService {
2523
+ const label = role === 'gateway' ? RECOVERY_GATEWAY_LABEL : RECOVERY_WATCHDOG_LABEL;
2029
2524
  const generated = join(recoveryRoot(config), 'launchd', `${label}.plist`);
2030
2525
  const installed = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
2031
2526
  return {
@@ -2042,46 +2537,81 @@ async function probeRecoveryGateway(config: RecoveryConfig): Promise<{ ok: boole
2042
2537
  return probe(createRecoveryHttpTransport(config.controllerHome), `http://${config.gateway.host}:${config.gateway.port}/health`);
2043
2538
  }
2044
2539
 
2045
- export async function restartRecoveryGateway(
2046
- config: RecoveryConfig,
2047
- dependencies: RecoveryGatewayRestartDependencies = {},
2048
- ): Promise<RecoveryGatewayRestartResult> {
2049
- if (!config.gateway) return { ok: false, attempted: false, noOp: true, detail: 'Recovery Gateway is not configured' };
2050
- if ((dependencies.platform ?? process.platform) !== 'darwin') return { ok: false, attempted: false, noOp: true, detail: 'Recovery Gateway launchd restart is only supported on macOS' };
2051
- const check = dependencies.probeGateway ?? probeRecoveryGateway;
2052
- const initial = await check(config);
2053
- if (initial.ok) return { ok: true, attempted: false, noOp: true, detail: 'Recovery Gateway is already healthy' };
2054
- const uid = await (dependencies.currentUid ?? currentUid)();
2055
- if (uid === undefined) return { ok: false, attempted: false, noOp: true, detail: 'Recovery Gateway launchd UID is unavailable' };
2056
- const service = recoveryGatewayLaunchdService(config, uid);
2057
- if (!existsSync(service.plistPath)) return { ok: false, attempted: false, noOp: true, detail: `Recovery Gateway launchd plist is missing: ${service.plistPath}`, serviceTarget: service.target };
2058
- const locked = await withLock(config, { action: 'restart_recovery_gateway' }, async () => {
2059
- const before = await check(config);
2060
- if (before.ok) return { ok: true, attempted: false, noOp: true, detail: 'Recovery Gateway recovered before restart', serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
2061
- const started = await ensureLaunchdServiceStarted(service, dependencies.runCommand ?? command);
2540
+ async function probeRecoveryWatchdog(config: RecoveryConfig): Promise<{ ok: boolean; detail: string }> {
2541
+ const health = observeRecoveryWatchdogHealth(config.controllerHome);
2542
+ return { ok: health.ok, detail: health.detail };
2543
+ }
2544
+
2545
+ async function restartRecoveryRole(input: {
2546
+ config: RecoveryConfig;
2547
+ role: 'gateway' | 'watchdog';
2548
+ action: 'restart_recovery_gateway' | 'restart_recovery_watchdog';
2549
+ check: (config: RecoveryConfig) => Promise<{ ok: boolean; detail: string }>;
2550
+ dependencies: RecoveryRoleRestartDependencies;
2551
+ }): Promise<RecoveryGatewayRestartResult> {
2552
+ const display = input.role === 'gateway' ? 'Recovery Gateway' : 'Recovery Watchdog';
2553
+ if ((input.dependencies.platform ?? process.platform) !== 'darwin') return { ok: false, attempted: false, noOp: true, detail: `${display} launchd restart is only supported on macOS` };
2554
+ const initial = await input.check(input.config);
2555
+ if (initial.ok) return { ok: true, attempted: false, noOp: true, detail: `${display} is already healthy` };
2556
+ const uid = await (input.dependencies.currentUid ?? currentUid)();
2557
+ if (uid === undefined) return { ok: false, attempted: false, noOp: true, detail: `${display} launchd UID is unavailable` };
2558
+ const service = recoveryRoleLaunchdService(input.config, uid, input.role);
2559
+ if (!existsSync(service.plistPath)) return { ok: false, attempted: false, noOp: true, detail: `${display} launchd plist is missing: ${service.plistPath}`, serviceTarget: service.target };
2560
+ const eventPrefix = input.role === 'gateway' ? 'recovery_gateway' : 'recovery_watchdog';
2561
+ const locked = await withLock(input.config, { action: input.action }, async () => {
2562
+ const before = await input.check(input.config);
2563
+ if (before.ok) return { ok: true, attempted: false, noOp: true, detail: `${display} recovered before restart`, serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
2564
+ const started = await ensureLaunchdServiceStarted(service, input.dependencies.runCommand ?? command);
2062
2565
  if (!started.ok) {
2063
- audit(config, 'recovery_gateway_restart_failed', { serviceTarget: service.target, detail: started.detail });
2566
+ audit(input.config, `${eventPrefix}_restart_failed`, { serviceTarget: service.target, detail: started.detail });
2064
2567
  return { ok: false, attempted: true, detail: started.detail, serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
2065
2568
  }
2066
- const now = dependencies.now ?? Date.now;
2067
- const wait = dependencies.sleep ?? sleep;
2569
+ const now = input.dependencies.now ?? Date.now;
2570
+ const wait = input.dependencies.sleep ?? sleep;
2068
2571
  const deadline = now() + 20_000;
2069
2572
  let observed = before;
2070
2573
  while (now() < deadline) {
2071
2574
  await wait(1_000);
2072
- observed = await check(config);
2575
+ observed = await input.check(input.config);
2073
2576
  if (observed.ok) {
2074
- audit(config, 'recovery_gateway_restart_succeeded', { serviceTarget: service.target });
2075
- return { ok: true, attempted: true, detail: 'Recovery Gateway restarted and its local health endpoint recovered', serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
2577
+ audit(input.config, `${eventPrefix}_restart_succeeded`, { serviceTarget: service.target });
2578
+ return { ok: true, attempted: true, detail: `${display} restarted and passed local health verification`, serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
2076
2579
  }
2077
2580
  }
2078
- audit(config, 'recovery_gateway_restart_unverified', { serviceTarget: service.target, detail: observed.detail });
2079
- return { ok: false, attempted: true, detail: 'Recovery Gateway restarted but did not pass local health verification before timeout', serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
2581
+ audit(input.config, `${eventPrefix}_restart_unverified`, { serviceTarget: service.target, detail: observed.detail });
2582
+ return { ok: false, attempted: true, detail: `${display} restarted but did not pass local health verification before timeout`, serviceTarget: service.target } satisfies RecoveryGatewayRestartResult;
2080
2583
  });
2081
2584
  if (!locked.acquired) return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(locked.owner), serviceTarget: service.target };
2082
2585
  return locked.value;
2083
2586
  }
2084
2587
 
2588
+ export async function restartRecoveryGateway(
2589
+ config: RecoveryConfig,
2590
+ dependencies: RecoveryGatewayRestartDependencies = {},
2591
+ ): Promise<RecoveryGatewayRestartResult> {
2592
+ if (!config.gateway) return { ok: false, attempted: false, noOp: true, detail: 'Recovery Gateway is not configured' };
2593
+ return restartRecoveryRole({
2594
+ config,
2595
+ role: 'gateway',
2596
+ action: 'restart_recovery_gateway',
2597
+ check: dependencies.probeGateway ?? probeRecoveryGateway,
2598
+ dependencies,
2599
+ });
2600
+ }
2601
+
2602
+ export async function restartRecoveryWatchdog(
2603
+ config: RecoveryConfig,
2604
+ dependencies: RecoveryWatchdogRestartDependencies = {},
2605
+ ): Promise<RecoveryWatchdogRestartResult> {
2606
+ return restartRecoveryRole({
2607
+ config,
2608
+ role: 'watchdog',
2609
+ action: 'restart_recovery_watchdog',
2610
+ check: dependencies.probeWatchdog ?? probeRecoveryWatchdog,
2611
+ dependencies,
2612
+ });
2613
+ }
2614
+
2085
2615
  function pidAlive(pid: number | undefined): boolean {
2086
2616
  if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return false;
2087
2617
  try { process.kill(pid, 0); return true; } catch { return false; }
@@ -2101,7 +2631,7 @@ export function gatewayToken(config: RecoveryConfig): string | undefined {
2101
2631
  export function initializeStandaloneRecovery(
2102
2632
  controllerHome: string,
2103
2633
  port = 8787,
2104
- extensions: Partial<Pick<RecoveryConfig, 'publicMcpUrl' | 'recoveryPublicUrl' | 'recoveryTunnelService' | 'primaryRuntimeService' | 'primaryRuntimeSourceRoot' | 'primaryConnectorService' | 'readOnlyTool'>> = {},
2634
+ extensions: Partial<Pick<RecoveryConfig, 'publicMcpUrl' | 'recoveryPublicUrl' | 'recoveryTunnelService' | 'primaryPublicTunnelService' | 'primaryRuntimeService' | 'primaryRuntimeSourceRoot' | 'primaryConnectorService' | 'readOnlyTool'>> = {},
2105
2635
  ): RecoveryConfig {
2106
2636
  const root = resolve(controllerHome);
2107
2637
  const tokenPath = join(root, 'recovery', 'config', 'gateway-token.json');
@@ -2128,6 +2658,7 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2128
2658
  rollback?: RollbackResult;
2129
2659
  publicTunnelRepair?: PublicTunnelRepairResult;
2130
2660
  recoveryGatewayRestart?: RecoveryGatewayRestartResult;
2661
+ primaryConnectorRestart?: PrimaryConnectorRestartResult;
2131
2662
  primaryRuntimeRestart?: PrimaryRuntimeRestartResult;
2132
2663
  primaryRuntimeRecovery?: PrimaryRuntimeRecoveryResult;
2133
2664
  }> {
@@ -2168,6 +2699,7 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2168
2699
  runtimeHealthySince: undefined,
2169
2700
  lastFullVerifyAt,
2170
2701
  lastDecision: 'degraded',
2702
+ lastReason: 'canonical Runtime is within startup grace; restart escalation suppressed',
2171
2703
  };
2172
2704
  return {
2173
2705
  state,
@@ -2177,8 +2709,30 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2177
2709
  }
2178
2710
  const recoveryHealthy = verified.probes.recovery_gateway?.ok !== false
2179
2711
  && verified.probes.recovery_external_http?.ok !== false;
2180
- const primaryRuntimeHealthy = verified.ok && localVerify.ok;
2181
- if (primaryRuntimeHealthy && recoveryHealthy) {
2712
+ const primaryRuntimeHealthy = localVerify.ok;
2713
+ const primaryConnectorConfigured = Boolean(config.primaryConnectorService && config.publicMcpUrl);
2714
+ const primaryConnectorFailed = Boolean(
2715
+ primaryConnectorConfigured
2716
+ && primaryRuntimeHealthy
2717
+ && (
2718
+ verified.probes.external_mcp_http?.ok === false
2719
+ || verified.probes.primary_connector_ready?.ok === false
2720
+ || connectorCapacityRecoveryRecommended(verified)
2721
+ ),
2722
+ );
2723
+ if (primaryRuntimeHealthy && !primaryConnectorFailed && recoveryHealthy) {
2724
+ if (fullVerificationPerformed && verified.ok && !matchingKnownGood(config, verified.releases.active)) {
2725
+ try {
2726
+ const attestation = await withLock(config, { action: 'attest_known_good' }, async () => persistVerifiedKnownGood(config, verified));
2727
+ if (!attestation.acquired) {
2728
+ audit(config, 'known_good_attestation_deferred', { reason: recoveryBusyDetail(attestation.owner) });
2729
+ }
2730
+ } catch (error) {
2731
+ audit(config, 'known_good_attestation_deferred', {
2732
+ reason: error instanceof Error ? error.message : 'watchdog known-good attestation failed',
2733
+ });
2734
+ }
2735
+ }
2182
2736
  const stable = recordWatchdogRuntimeHealthy(
2183
2737
  scopeWatchdogStateToRuntimeRelease(scopedPrior, verified.releases.active),
2184
2738
  now,
@@ -2193,9 +2747,15 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2193
2747
  publicTunnelFailures: 0,
2194
2748
  publicTunnelFirstFailureAt: undefined,
2195
2749
  publicTunnelRepairFailures: 0,
2750
+ primaryConnectorFailures: 0,
2751
+ primaryConnectorFirstFailureAt: undefined,
2752
+ primaryConnectorRestartAttempts: 0,
2753
+ primaryConnectorRestartFailures: 0,
2754
+ primaryConnectorRestartLastAttemptAt: undefined,
2196
2755
  recoveryGatewayRestartUsed: false,
2197
2756
  lastFullVerifyAt,
2198
2757
  lastDecision: 'healthy',
2758
+ lastReason: 'primary runtime and standalone Recovery verification passed',
2199
2759
  };
2200
2760
  return { state, decision: { action: 'healthy', reason: 'primary runtime and standalone Recovery verification passed' }, verify: verified };
2201
2761
  }
@@ -2221,15 +2781,30 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2221
2781
  firstFailureAt: budgetPrior.firstFailureAt ?? now,
2222
2782
  publicTunnelFailures: (budgetPrior.publicTunnelFailures ?? 0) + 1,
2223
2783
  publicTunnelFirstFailureAt: budgetPrior.publicTunnelFirstFailureAt ?? now,
2784
+ primaryConnectorFailures: 0,
2785
+ primaryConnectorFirstFailureAt: undefined,
2224
2786
  }
2225
- : {
2226
- ...budgetPrior,
2227
- lastFullVerifyAt,
2228
- failures: budgetPrior.failures + 1,
2229
- firstFailureAt: budgetPrior.firstFailureAt ?? now,
2230
- publicTunnelFailures: 0,
2231
- publicTunnelFirstFailureAt: undefined,
2232
- };
2787
+ : primaryConnectorFailed
2788
+ ? {
2789
+ ...budgetPrior,
2790
+ lastFullVerifyAt,
2791
+ failures: budgetPrior.failures + 1,
2792
+ firstFailureAt: budgetPrior.firstFailureAt ?? now,
2793
+ publicTunnelFailures: 0,
2794
+ publicTunnelFirstFailureAt: undefined,
2795
+ primaryConnectorFailures: (budgetPrior.primaryConnectorFailures ?? 0) + 1,
2796
+ primaryConnectorFirstFailureAt: budgetPrior.primaryConnectorFirstFailureAt ?? now,
2797
+ }
2798
+ : {
2799
+ ...budgetPrior,
2800
+ lastFullVerifyAt,
2801
+ failures: budgetPrior.failures + 1,
2802
+ firstFailureAt: budgetPrior.firstFailureAt ?? now,
2803
+ publicTunnelFailures: 0,
2804
+ publicTunnelFirstFailureAt: undefined,
2805
+ primaryConnectorFailures: 0,
2806
+ primaryConnectorFirstFailureAt: undefined,
2807
+ };
2233
2808
  if (!localVerify.ok) state.runtimeHealthySince = undefined;
2234
2809
  const primaryConfig = configuredPrimaryRuntimeService(config);
2235
2810
  const decision = decideWatchdog({
@@ -2246,12 +2821,23 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2246
2821
  runtimeRecoveryLastAttemptAt: state.runtimeRecoveryLastAttemptAt,
2247
2822
  runtimeRecoveryCooldownMs: primaryConfig.recoveryCooldownMs,
2248
2823
  recoveryGatewayFailed: verified.probes.recovery_gateway?.ok === false,
2824
+ primaryConnectorConfigured,
2825
+ primaryConnectorFailed,
2826
+ primaryConnectorFailures: state.primaryConnectorFailures,
2827
+ primaryConnectorFirstFailureAt: state.primaryConnectorFirstFailureAt,
2828
+ primaryConnectorRestartAttempts: state.primaryConnectorRestartAttempts,
2829
+ primaryConnectorMaximumRestartAttempts: config.primaryConnectorService?.maximumRestartAttempts,
2830
+ primaryConnectorRestartLastAttemptAt: state.primaryConnectorRestartLastAttemptAt,
2831
+ primaryConnectorRestartCooldownMs: config.primaryConnectorService?.restartCooldownMs,
2832
+ primaryConnectorMinimumFailures: config.primaryConnectorService?.minimumFailures,
2833
+ primaryConnectorMinimumFailureDurationMs: config.primaryConnectorService?.minimumFailureDurationMs,
2249
2834
  publicTunnelConfigured: Boolean(configuredRecoveryTunnel(config)),
2250
2835
  publicTunnelFailed,
2251
2836
  publicTunnelMinimumFailures: configuredRecoveryTunnel(config)?.minimumFailures,
2252
2837
  publicTunnelMinimumFailureDurationMs: configuredRecoveryTunnel(config)?.minimumFailureDurationMs,
2253
2838
  });
2254
2839
  state.lastDecision = decision.action;
2840
+ state.lastReason = decision.reason;
2255
2841
  if (decision.action === 'repair_public_tunnel') {
2256
2842
  const publicTunnelRepair = await repairPublicTunnel(config);
2257
2843
  const nextState = publicTunnelRepair.ok
@@ -2263,6 +2849,28 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
2263
2849
  const recoveryGatewayRestart = await restartRecoveryGateway(config);
2264
2850
  return { state: { ...state, recoveryGatewayRestartUsed: true }, decision, verify: verified, recoveryGatewayRestart };
2265
2851
  }
2852
+ if (decision.action === 'restart_primary_connector') {
2853
+ const primaryConnectorRestart = await restartPrimaryConnector(config);
2854
+ const attempts = (state.primaryConnectorRestartAttempts ?? 0) + (primaryConnectorRestart.attempted ? 1 : 0);
2855
+ const nextState: WatchdogState = primaryConnectorRestart.ok
2856
+ ? {
2857
+ ...state,
2858
+ failures: 0,
2859
+ firstFailureAt: undefined,
2860
+ primaryConnectorFailures: 0,
2861
+ primaryConnectorFirstFailureAt: undefined,
2862
+ primaryConnectorRestartAttempts: 0,
2863
+ primaryConnectorRestartFailures: 0,
2864
+ primaryConnectorRestartLastAttemptAt: now,
2865
+ }
2866
+ : {
2867
+ ...state,
2868
+ primaryConnectorRestartAttempts: attempts,
2869
+ primaryConnectorRestartFailures: (state.primaryConnectorRestartFailures ?? 0) + (primaryConnectorRestart.attempted ? 1 : 0),
2870
+ primaryConnectorRestartLastAttemptAt: now,
2871
+ };
2872
+ return { state: nextState, decision, verify: verified, primaryConnectorRestart };
2873
+ }
2266
2874
  if (decision.action === 'restart_primary_runtime') {
2267
2875
  const primaryRuntimeRestart = await restartPrimaryRuntime(config);
2268
2876
  const attempts = (state.runtimeRestartAttempts ?? 0) + (primaryRuntimeRestart.attempted ? 1 : 0);