@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
@@ -0,0 +1,991 @@
1
+ import { createHash } from 'crypto';
2
+ import { withControllerLock } from '../../../cli/repositories/locks';
3
+ import { readRequirement } from '../persistence/requirement-store';
4
+ import {
5
+ listControlPlaneRecords,
6
+ readControlPlaneRecord,
7
+ writeControlPlaneRecord,
8
+ type ControlPlaneRecord,
9
+ } from '../persistence/sqlite-store';
10
+ import { controllerSessionBlocksRecovery, getControllerSession } from './controller-session-store';
11
+ import { getHandoffItem, listHandoffItems } from './handoff-inbox-store';
12
+ import { getWorkContract, readWorkContractStore } from './work-contract-store';
13
+ import {
14
+ isTerminalHandoffStatus,
15
+ isTerminalWorkContractStatus,
16
+ type ControllerSession,
17
+ type WorkContract,
18
+ } from './types';
19
+
20
+ export const CONTROLLER_ROUND_DISPOSITIONS = [
21
+ 'continue_immediately',
22
+ 'wait',
23
+ 'wait_for_user',
24
+ 'goal_complete',
25
+ ] as const;
26
+ export type ControllerRoundDisposition = (typeof CONTROLLER_ROUND_DISPOSITIONS)[number];
27
+
28
+ const CONTROLLER_DISPOSITION_COMPATIBILITY_PREFIX = 'controller.disposition:';
29
+
30
+ export function parseControllerDispositionCompatibilityCapability(
31
+ operation: string,
32
+ capabilityId: unknown,
33
+ ): { disposition: ControllerRoundDisposition; relayScopeId: string } | undefined {
34
+ if (operation !== 'repair' || typeof capabilityId !== 'string') return undefined;
35
+ const normalized = capabilityId.trim();
36
+ if (!normalized.startsWith(CONTROLLER_DISPOSITION_COMPATIBILITY_PREFIX)) return undefined;
37
+ const payload = normalized.slice(CONTROLLER_DISPOSITION_COMPATIBILITY_PREFIX.length);
38
+ const separator = payload.indexOf(':');
39
+ if (separator <= 0) throw new Error('CONTROLLER_RELAY_DISPOSITION_COMPATIBILITY_INVALID');
40
+ const disposition = payload.slice(0, separator) as ControllerRoundDisposition;
41
+ const relayScopeId = payload.slice(separator + 1).trim();
42
+ if (!CONTROLLER_ROUND_DISPOSITIONS.includes(disposition) || !relayScopeId) {
43
+ throw new Error('CONTROLLER_RELAY_DISPOSITION_COMPATIBILITY_INVALID');
44
+ }
45
+ return { disposition, relayScopeId };
46
+ }
47
+
48
+ export type ControllerRoundRelayStatus =
49
+ | 'pending_release'
50
+ | 'dispatching'
51
+ | 'dispatched'
52
+ | 'claimed'
53
+ | 'waiting'
54
+ | 'waiting_for_user'
55
+ | 'goal_complete'
56
+ | 'blocked'
57
+ | 'failed';
58
+
59
+ export type ControllerRoundTabSettlementStatus =
60
+ | 'round_open'
61
+ | 'retained_for_immediate_continuation'
62
+ | 'closed'
63
+ | 'preserved_user_owned'
64
+ | 'session_closed'
65
+ | 'failed';
66
+
67
+ export interface ControllerRoundRelayRecord {
68
+ schemaVersion: 1;
69
+ repoId: string;
70
+ relayScopeId: string;
71
+ originWorkId: string;
72
+ requirementId?: string;
73
+ disposition: ControllerRoundDisposition;
74
+ status: ControllerRoundRelayStatus;
75
+ controllerId: string;
76
+ principalId: string;
77
+ controllerInstanceId: string;
78
+ sessionId: string;
79
+ claimGeneration: number;
80
+ stateFingerprint: string;
81
+ roundCount: number;
82
+ repeatedStateCount: number;
83
+ consecutiveFailures: number;
84
+ maxRounds: number;
85
+ maxRepeatedState: number;
86
+ maxFailures: number;
87
+ handoffId?: string;
88
+ reason?: string;
89
+ browserSessionId?: string;
90
+ conversationUrl?: string;
91
+ blockedReason?: string;
92
+ lastError?: string;
93
+ submittedAt: string;
94
+ updatedAt: string;
95
+ dispatchedAt?: string;
96
+ claimedAt?: string;
97
+ tabSettlementStatus?: ControllerRoundTabSettlementStatus;
98
+ tabSettlementAt?: string;
99
+ tabSettlementError?: string;
100
+ }
101
+
102
+ export interface ControllerRoundRelayStoreOptions {
103
+ controllerHome: string;
104
+ repoId: string;
105
+ now?: () => string;
106
+ }
107
+
108
+ export interface ControllerRoundRelayIdentity {
109
+ controllerId: string;
110
+ principalId: string;
111
+ controllerInstanceId: string;
112
+ sessionId: string;
113
+ }
114
+
115
+ export interface SubmitControllerRoundDispositionInput {
116
+ workId: string;
117
+ identity: ControllerRoundRelayIdentity;
118
+ disposition: ControllerRoundDisposition;
119
+ relayScopeId?: string;
120
+ requirementId?: string;
121
+ handoffId?: string;
122
+ stateFingerprint?: string;
123
+ reason?: string;
124
+ browserSessionId?: string;
125
+ conversationUrl?: string;
126
+ maxRounds?: number;
127
+ maxRepeatedState?: number;
128
+ maxFailures?: number;
129
+ }
130
+
131
+ export interface BeginInitialControllerRoundDispatchInput {
132
+ workId: string;
133
+ identity: ControllerRoundRelayIdentity;
134
+ relayScopeId?: string;
135
+ requirementId?: string;
136
+ browserSessionId?: string;
137
+ conversationUrl?: string;
138
+ maxRounds?: number;
139
+ maxRepeatedState?: number;
140
+ maxFailures?: number;
141
+ }
142
+
143
+ const NAMESPACE = 'controller_round_relay';
144
+ const SCHEMA_VERSION = 1;
145
+ const DEFAULT_MAX_ROUNDS = 8;
146
+ const DEFAULT_MAX_REPEATED_STATE = 2;
147
+ const DEFAULT_MAX_FAILURES = 3;
148
+ const DEFAULT_UNCLOSED_ROUND_GRACE_MS = 10 * 60_000;
149
+ const MAX_UNCLOSED_ROUND_GRACE_MS = 60 * 60_000;
150
+
151
+ function nowIso(options: ControllerRoundRelayStoreOptions): string {
152
+ return options.now?.() ?? new Date().toISOString();
153
+ }
154
+
155
+ function bounded(value: string | undefined, max: number): string | undefined {
156
+ const normalized = value?.trim();
157
+ return normalized ? normalized.slice(0, max) : undefined;
158
+ }
159
+
160
+ function normalizeScopeId(value: string): string {
161
+ const normalized = value.trim();
162
+ if (!normalized || /[\r\n]/.test(normalized)) throw new Error('CONTROLLER_RELAY_SCOPE_INVALID');
163
+ return normalized.slice(0, 240);
164
+ }
165
+
166
+ function boundedInteger(value: number | undefined, fallback: number, min: number, max: number): number {
167
+ if (value === undefined || !Number.isFinite(value)) return fallback;
168
+ return Math.max(min, Math.min(Math.trunc(value), max));
169
+ }
170
+
171
+ function relayLock<T>(
172
+ options: ControllerRoundRelayStoreOptions,
173
+ relayScopeId: string,
174
+ actor: string,
175
+ operation: () => T,
176
+ ): T {
177
+ const lockId = createHash('sha256').update(relayScopeId).digest('hex').slice(0, 20);
178
+ return withControllerLock(
179
+ options.controllerHome,
180
+ { scope: 'task', repoId: options.repoId, taskId: `controller-round-relay-${lockId}` },
181
+ actor,
182
+ operation,
183
+ );
184
+ }
185
+
186
+ function readRelayRecord(
187
+ options: ControllerRoundRelayStoreOptions,
188
+ workId: string,
189
+ ): ControlPlaneRecord<ControllerRoundRelayRecord> | undefined {
190
+ return readControlPlaneRecord<ControllerRoundRelayRecord>(options.controllerHome, NAMESPACE, options.repoId, workId);
191
+ }
192
+
193
+ export function getControllerRoundRelay(
194
+ options: ControllerRoundRelayStoreOptions,
195
+ workId: string,
196
+ ): ControllerRoundRelayRecord | undefined {
197
+ return readRelayRecord(options, workId)?.value;
198
+ }
199
+
200
+ export function recordControllerRoundTabSettlement(
201
+ options: ControllerRoundRelayStoreOptions,
202
+ input: {
203
+ workId: string;
204
+ status: ControllerRoundTabSettlementStatus;
205
+ error?: string;
206
+ },
207
+ ): ControllerRoundRelayRecord | undefined {
208
+ const initial = readRelayRecord(options, input.workId);
209
+ if (!initial) return undefined;
210
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-tab-settlement:${input.workId}`, () => {
211
+ const current = readRelayRecord(options, input.workId);
212
+ if (!current) return undefined;
213
+ const at = nowIso(options);
214
+ const next: ControllerRoundRelayRecord = {
215
+ ...current.value,
216
+ tabSettlementStatus: input.status,
217
+ tabSettlementAt: at,
218
+ tabSettlementError: bounded(input.error, 2_000),
219
+ updatedAt: at,
220
+ };
221
+ writeControlPlaneRecord(options.controllerHome, {
222
+ namespace: NAMESPACE,
223
+ scope: options.repoId,
224
+ key: input.workId,
225
+ schemaVersion: SCHEMA_VERSION,
226
+ value: next,
227
+ action: 'controller_round_tab_settled',
228
+ expectedRevision: current.revision,
229
+ });
230
+ return next;
231
+ });
232
+ }
233
+
234
+ function relayHistory(options: ControllerRoundRelayStoreOptions, relayScopeId: string): ControllerRoundRelayRecord[] {
235
+ return listControlPlaneRecords<ControllerRoundRelayRecord>(options.controllerHome, {
236
+ namespace: NAMESPACE,
237
+ scope: options.repoId,
238
+ limit: 5_000,
239
+ })
240
+ .map((entry) => entry.value)
241
+ .filter((entry) => entry.relayScopeId === relayScopeId)
242
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
243
+ }
244
+
245
+ function latestRelayRecordsByScope(options: ControllerRoundRelayStoreOptions): ControllerRoundRelayRecord[] {
246
+ const latest = new Map<string, ControllerRoundRelayRecord>();
247
+ for (const entry of listControlPlaneRecords<ControllerRoundRelayRecord>(options.controllerHome, {
248
+ namespace: NAMESPACE,
249
+ scope: options.repoId,
250
+ limit: 5_000,
251
+ }).map((record) => record.value)) {
252
+ const current = latest.get(entry.relayScopeId);
253
+ if (!current || entry.updatedAt > current.updatedAt) latest.set(entry.relayScopeId, entry);
254
+ }
255
+ return [...latest.values()].sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
256
+ }
257
+
258
+ function requirementForRelay(options: ControllerRoundRelayStoreOptions, requirementId: string | undefined) {
259
+ return requirementId ? readRequirement({ controllerHome: options.controllerHome }, requirementId)?.value : undefined;
260
+ }
261
+
262
+ function relevantWork(options: ControllerRoundRelayStoreOptions, record: Pick<ControllerRoundRelayRecord, 'relayScopeId' | 'originWorkId' | 'requirementId'>): WorkContract[] {
263
+ const all = readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts;
264
+ const linkedWorkIds = new Set([
265
+ record.originWorkId,
266
+ ...relayHistory(options, record.relayScopeId).map((entry) => entry.originWorkId),
267
+ ]);
268
+ if (record.requirementId) {
269
+ for (const work of all) {
270
+ if (work.requirementId === record.requirementId) linkedWorkIds.add(work.workId);
271
+ }
272
+ }
273
+ let expanded = true;
274
+ while (expanded) {
275
+ expanded = false;
276
+ for (const work of all) {
277
+ if (linkedWorkIds.has(work.workId)) {
278
+ if (work.parentWorkId && !linkedWorkIds.has(work.parentWorkId)) {
279
+ linkedWorkIds.add(work.parentWorkId);
280
+ expanded = true;
281
+ }
282
+ continue;
283
+ }
284
+ if (work.parentWorkId && linkedWorkIds.has(work.parentWorkId)) {
285
+ linkedWorkIds.add(work.workId);
286
+ expanded = true;
287
+ }
288
+ }
289
+ }
290
+ return all
291
+ .filter((work) => linkedWorkIds.has(work.workId))
292
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
293
+ }
294
+
295
+ function mechanicalStateFingerprint(
296
+ options: ControllerRoundRelayStoreOptions,
297
+ work: WorkContract,
298
+ requirementId: string | undefined,
299
+ relayScopeId: string,
300
+ ): string {
301
+ const requirement = requirementForRelay(options, requirementId);
302
+ const works = relevantWork(options, { relayScopeId, originWorkId: work.workId, requirementId })
303
+ .map((entry) => ({
304
+ workId: entry.workId,
305
+ parentWorkId: entry.parentWorkId,
306
+ status: entry.status,
307
+ phase: entry.phase,
308
+ dispatchState: entry.dispatchState,
309
+ evidenceState: entry.evidenceState,
310
+ completionOutcome: entry.completionOutcome,
311
+ updatedAt: entry.updatedAt,
312
+ }))
313
+ .sort((left, right) => left.workId.localeCompare(right.workId));
314
+ const linkedWorkIds = new Set(works.map((entry) => entry.workId));
315
+ const handoffs = listHandoffItems({ controllerHome: options.controllerHome, repoId: options.repoId, status: 'active', limit: 100 })
316
+ .filter((handoff) => !handoff.workId || linkedWorkIds.has(handoff.workId))
317
+ .map((handoff) => ({ id: handoff.id, status: handoff.status, updatedAt: handoff.updatedAt }));
318
+ return createHash('sha256').update(JSON.stringify({
319
+ requirement: requirement ? {
320
+ requirementId: requirement.requirementId,
321
+ state: requirement.state,
322
+ revision: requirement.revision,
323
+ updatedAt: requirement.updatedAt,
324
+ } : undefined,
325
+ works,
326
+ handoffs,
327
+ })).digest('hex');
328
+ }
329
+
330
+ function resolveRequirementId(
331
+ options: ControllerRoundRelayStoreOptions,
332
+ work: WorkContract,
333
+ requestedRequirementId: string | undefined,
334
+ ): string | undefined {
335
+ const requested = bounded(requestedRequirementId, 160);
336
+ if (work.requirementId && requested && work.requirementId !== requested) {
337
+ throw new Error(`CONTROLLER_RELAY_REQUIREMENT_MISMATCH: Work ${work.workId} belongs to ${work.requirementId}`);
338
+ }
339
+ const requirementId = work.requirementId ?? requested;
340
+ if (requirementId && !requirementForRelay(options, requirementId)) {
341
+ throw new Error(`REQUIREMENT_NOT_FOUND: ${requirementId}`);
342
+ }
343
+ return requirementId;
344
+ }
345
+
346
+ function resolveRelayScope(
347
+ work: WorkContract,
348
+ requirementId: string | undefined,
349
+ requestedScopeId: string | undefined,
350
+ ): string {
351
+ const canonicalRequirementScope = requirementId ? `requirement:${requirementId}` : undefined;
352
+ if (canonicalRequirementScope) {
353
+ if (requestedScopeId && normalizeScopeId(requestedScopeId) !== canonicalRequirementScope) {
354
+ throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: expected ${canonicalRequirementScope}`);
355
+ }
356
+ return canonicalRequirementScope;
357
+ }
358
+ return requestedScopeId ? normalizeScopeId(requestedScopeId) : `goal:${work.workId}`;
359
+ }
360
+
361
+ function assertChatgptOwner(
362
+ options: ControllerRoundRelayStoreOptions,
363
+ workId: string,
364
+ identity: ControllerRoundRelayIdentity,
365
+ ): ControllerSession & { claimGeneration: number } {
366
+ const owner = getControllerSession(options, workId);
367
+ if (!owner) throw new Error(`WORK_CONTROLLER_OWNER_REQUIRED: ${workId}`);
368
+ if (owner.controllerType !== 'chatgpt') throw new Error(`CONTROLLER_RELAY_CHATGPT_ONLY: ${workId}`);
369
+ if (owner.controllerId !== identity.controllerId) throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${workId}`);
370
+ const ownerPrincipal = owner.principalId?.trim() || owner.controllerId;
371
+ if (ownerPrincipal !== identity.principalId.trim()) throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${workId}`);
372
+ if (owner.sessionId !== identity.sessionId.trim()) throw new Error(`WORK_CONTROLLER_SESSION_MISMATCH: ${workId}`);
373
+ const ownerInstanceId = owner.controllerInstanceId?.trim();
374
+ if (ownerInstanceId && ownerInstanceId !== identity.controllerInstanceId.trim()) {
375
+ throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
376
+ }
377
+ if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${workId}`);
378
+ return owner as ControllerSession & { claimGeneration: number };
379
+ }
380
+
381
+ export function submitControllerRoundDisposition(
382
+ options: ControllerRoundRelayStoreOptions,
383
+ input: SubmitControllerRoundDispositionInput,
384
+ ): ControllerRoundRelayRecord {
385
+ if (!CONTROLLER_ROUND_DISPOSITIONS.includes(input.disposition)) throw new Error('CONTROLLER_RELAY_DISPOSITION_INVALID');
386
+ const work = getWorkContract(options, input.workId);
387
+ if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
388
+ const requirementId = resolveRequirementId(options, work, input.requirementId);
389
+ const relayScopeId = resolveRelayScope(work, requirementId, input.relayScopeId);
390
+ const terminal = isTerminalWorkContractStatus(work.status);
391
+
392
+ return relayLock(options, relayScopeId, `controller-relay-submit:${input.identity.controllerId}`, () => {
393
+ const existing = readRelayRecord(options, work.workId);
394
+ if (!existing) throw new Error(`CONTROLLER_RELAY_ROUND_NOT_OPEN: ${work.workId}`);
395
+ if (existing.value.relayScopeId !== relayScopeId) {
396
+ throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: Work ${work.workId} is already bound to ${existing.value.relayScopeId}`);
397
+ }
398
+ if (existing.value.status !== 'claimed') {
399
+ throw new Error(`CONTROLLER_RELAY_ROUND_NOT_CLAIMED: ${existing.value.status}`);
400
+ }
401
+ if (terminal && (work.status !== 'completed' || input.disposition !== 'goal_complete')) {
402
+ throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${work.status}`);
403
+ }
404
+
405
+ const liveOwner = getControllerSession(options, work.workId);
406
+ const authority = terminal
407
+ ? (() => {
408
+ // The physical Work may be completed while its old live lease still exists.
409
+ // goal_complete is semantic round closure, not a terminal Work reclaim: use
410
+ // the authenticated controller/principal plus the already-claimed relay as
411
+ // authority and allow MCP session / Runtime instance rotation. If a live
412
+ // owner remains, it must still agree on controller type and principal.
413
+ const principalId = input.identity.principalId.trim();
414
+ const controllerInstanceId = input.identity.controllerInstanceId.trim();
415
+ const sessionId = input.identity.sessionId.trim();
416
+ if (!principalId || !controllerInstanceId || !sessionId) throw new Error(`CONTROLLER_RELAY_TERMINAL_AUTHORITY_REQUIRED: ${work.workId}`);
417
+ if (liveOwner) {
418
+ if (liveOwner.controllerType !== 'chatgpt') throw new Error(`CONTROLLER_RELAY_CHATGPT_ONLY: ${work.workId}`);
419
+ if (liveOwner.controllerId !== input.identity.controllerId) throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${work.workId}`);
420
+ if ((liveOwner.principalId?.trim() || liveOwner.controllerId) !== principalId) {
421
+ throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${work.workId}`);
422
+ }
423
+ }
424
+ if (existing.value.controllerId !== input.identity.controllerId) throw new Error(`CONTROLLER_RELAY_CLAIM_CONTROLLER_MISMATCH: ${work.workId}`);
425
+ if (existing.value.principalId !== principalId) throw new Error(`CONTROLLER_RELAY_CLAIM_PRINCIPAL_MISMATCH: ${work.workId}`);
426
+ if (existing.value.claimGeneration < 1) throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${work.workId}`);
427
+ return {
428
+ controllerId: existing.value.controllerId,
429
+ principalId: existing.value.principalId,
430
+ controllerInstanceId,
431
+ sessionId,
432
+ claimGeneration: existing.value.claimGeneration,
433
+ };
434
+ })()
435
+ : (() => {
436
+ const owner = assertChatgptOwner(options, work.workId, input.identity);
437
+ return {
438
+ controllerId: owner.controllerId,
439
+ principalId: owner.principalId?.trim() || owner.controllerId,
440
+ controllerInstanceId: owner.controllerInstanceId?.trim() || '',
441
+ sessionId: owner.sessionId,
442
+ claimGeneration: owner.claimGeneration,
443
+ };
444
+ })();
445
+ if (existing.value.controllerId !== authority.controllerId) {
446
+ throw new Error(`CONTROLLER_RELAY_CLAIM_CONTROLLER_MISMATCH: ${work.workId}`);
447
+ }
448
+ if (existing.value.principalId !== authority.principalId) {
449
+ throw new Error(`CONTROLLER_RELAY_CLAIM_PRINCIPAL_MISMATCH: ${work.workId}`);
450
+ }
451
+ // A completed Work may have already released its live controller session before
452
+ // the prompt-required terminal disposition arrives. At that point the exact
453
+ // authenticated controller/principal + Work + relay scope remain the lineage
454
+ // fence; MCP session and canonical Runtime instance are allowed to rotate.
455
+ const terminalLineageMigration = terminal;
456
+ if (!terminalLineageMigration && existing.value.controllerInstanceId !== authority.controllerInstanceId) {
457
+ throw new Error(`CONTROLLER_RELAY_CLAIM_INSTANCE_MISMATCH: ${work.workId}`);
458
+ }
459
+ if (!terminalLineageMigration && existing.value.claimGeneration !== authority.claimGeneration) {
460
+ throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_MISMATCH: ${work.workId}`);
461
+ }
462
+ const previous = relayHistory(options, relayScopeId)[0];
463
+ const requestedMaxRounds = boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32);
464
+ const requestedMaxRepeatedState = boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8);
465
+ const requestedMaxFailures = boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8);
466
+ const maxRounds = previous ? Math.min(previous.maxRounds, requestedMaxRounds) : requestedMaxRounds;
467
+ const maxRepeatedState = previous ? Math.min(previous.maxRepeatedState, requestedMaxRepeatedState) : requestedMaxRepeatedState;
468
+ const maxFailures = previous ? Math.min(previous.maxFailures, requestedMaxFailures) : requestedMaxFailures;
469
+ const stateFingerprint = bounded(input.stateFingerprint, 256) ?? mechanicalStateFingerprint(options, work, requirementId, relayScopeId);
470
+ const continuing = input.disposition === 'continue_immediately';
471
+ const roundCount = (previous?.roundCount ?? 0) + (continuing ? 1 : 0);
472
+ const repeatedStateCount = continuing
473
+ ? (previous?.stateFingerprint === stateFingerprint ? (previous.repeatedStateCount + 1) : 0)
474
+ : (previous?.repeatedStateCount ?? 0);
475
+ const consecutiveFailures = previous?.consecutiveFailures ?? 0;
476
+ const browserSessionId = bounded(input.browserSessionId, 500) ?? previous?.browserSessionId;
477
+ const conversationUrl = bounded(input.conversationUrl, 2_000) ?? previous?.conversationUrl;
478
+
479
+ let status: ControllerRoundRelayStatus = input.disposition === 'continue_immediately'
480
+ ? 'pending_release'
481
+ : input.disposition === 'wait'
482
+ ? 'waiting'
483
+ : input.disposition === 'wait_for_user'
484
+ ? 'waiting_for_user'
485
+ : 'goal_complete';
486
+ let blockedReason: string | undefined;
487
+
488
+ const handoffId = bounded(input.handoffId, 200);
489
+ if (input.disposition === 'wait_for_user') {
490
+ if (!handoffId) throw new Error('CONTROLLER_RELAY_WAIT_FOR_USER_HANDOFF_REQUIRED');
491
+ const handoff = getHandoffItem(options, handoffId);
492
+ if (!handoff) throw new Error(`HANDOFF_NOT_FOUND: ${handoffId}`);
493
+ if (isTerminalHandoffStatus(handoff.status)) throw new Error(`CONTROLLER_RELAY_HANDOFF_TERMINAL: ${handoff.status}`);
494
+ if (handoff.workId && handoff.workId !== work.workId) throw new Error(`CONTROLLER_RELAY_HANDOFF_WORK_MISMATCH: ${handoffId}`);
495
+ }
496
+
497
+ if (continuing) {
498
+ if (roundCount > maxRounds) blockedReason = `round_budget_exhausted:${roundCount}>${maxRounds}`;
499
+ else if (repeatedStateCount >= maxRepeatedState) blockedReason = `repeated_state:${repeatedStateCount}>=${maxRepeatedState}`;
500
+ else if (consecutiveFailures >= maxFailures) blockedReason = `consecutive_failures:${consecutiveFailures}>=${maxFailures}`;
501
+ if (blockedReason) status = 'blocked';
502
+ }
503
+
504
+ const at = nowIso(options);
505
+ const record: ControllerRoundRelayRecord = {
506
+ schemaVersion: 1,
507
+ repoId: options.repoId,
508
+ relayScopeId,
509
+ originWorkId: work.workId,
510
+ ...(requirementId ? { requirementId } : {}),
511
+ disposition: input.disposition,
512
+ status,
513
+ controllerId: authority.controllerId,
514
+ principalId: authority.principalId,
515
+ controllerInstanceId: authority.controllerInstanceId,
516
+ sessionId: authority.sessionId,
517
+ claimGeneration: authority.claimGeneration,
518
+ stateFingerprint,
519
+ roundCount,
520
+ repeatedStateCount,
521
+ consecutiveFailures,
522
+ maxRounds,
523
+ maxRepeatedState,
524
+ maxFailures,
525
+ ...(handoffId ? { handoffId } : {}),
526
+ ...(bounded(input.reason, 1_000) ? { reason: bounded(input.reason, 1_000) } : {}),
527
+ ...(browserSessionId ? { browserSessionId } : {}),
528
+ ...(conversationUrl ? { conversationUrl } : {}),
529
+ ...(blockedReason ? { blockedReason } : {}),
530
+ tabSettlementStatus: existing.value.tabSettlementStatus ?? 'round_open',
531
+ submittedAt: at,
532
+ updatedAt: at,
533
+ };
534
+ writeControlPlaneRecord(options.controllerHome, {
535
+ namespace: NAMESPACE,
536
+ scope: options.repoId,
537
+ key: work.workId,
538
+ schemaVersion: SCHEMA_VERSION,
539
+ value: record,
540
+ action: 'controller_round_disposition_submitted',
541
+ expectedRevision: existing?.revision ?? null,
542
+ });
543
+ return record;
544
+ });
545
+ }
546
+
547
+ export function beginInitialControllerRoundDispatch(
548
+ options: ControllerRoundRelayStoreOptions,
549
+ input: BeginInitialControllerRoundDispatchInput,
550
+ ): ControllerRoundRelayRecord {
551
+ const work = getWorkContract(options, input.workId);
552
+ if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
553
+ if (isTerminalWorkContractStatus(work.status)) throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${work.status}`);
554
+ const requirementId = resolveRequirementId(options, work, input.requirementId);
555
+ const requirement = requirementForRelay(options, requirementId);
556
+ if (requirement && !['planned', 'active'].includes(requirement.state)) {
557
+ throw new Error(`CONTROLLER_RELAY_REQUIREMENT_TERMINAL: ${requirement.state}`);
558
+ }
559
+ const relayScopeId = resolveRelayScope(work, requirementId, input.relayScopeId);
560
+
561
+ return relayLock(options, relayScopeId, `controller-relay-launch:${input.identity.controllerId}`, () => {
562
+ const existing = readRelayRecord(options, work.workId);
563
+ if (existing && existing.value.relayScopeId !== relayScopeId) {
564
+ throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: Work ${work.workId} is already bound to ${existing.value.relayScopeId}`);
565
+ }
566
+ const previous = relayHistory(options, relayScopeId)[0];
567
+ if (previous && ['pending_release', 'dispatching', 'dispatched', 'claimed'].includes(previous.status)) {
568
+ throw new Error(`CONTROLLER_RELAY_ROUND_ALREADY_OPEN: ${relayScopeId}`);
569
+ }
570
+ const requestedMaxRounds = boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32);
571
+ const requestedMaxRepeatedState = boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8);
572
+ const requestedMaxFailures = boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8);
573
+ const maxRounds = previous ? Math.min(previous.maxRounds, requestedMaxRounds) : requestedMaxRounds;
574
+ const maxRepeatedState = previous ? Math.min(previous.maxRepeatedState, requestedMaxRepeatedState) : requestedMaxRepeatedState;
575
+ const maxFailures = previous ? Math.min(previous.maxFailures, requestedMaxFailures) : requestedMaxFailures;
576
+ const stateFingerprint = mechanicalStateFingerprint(options, work, requirementId, relayScopeId);
577
+ // beginInitialControllerRoundDispatch is entered only for a fresh external
578
+ // wake after any prior round has closed. Mechanical round/repeated-state
579
+ // budgets fence one immediate relay chain; carrying them across an explicit
580
+ // later schedule/manual occurrence eventually blocks healthy periodic Work
581
+ // whose repository state intentionally remains unchanged. Open-round
582
+ // duplicate suppression above still rejects a second wake for the same live
583
+ // chain, while submit/recovery paths continue to accumulate these budgets.
584
+ const roundCount = 1;
585
+ const repeatedStateCount = 0;
586
+ const consecutiveFailures = previous?.consecutiveFailures ?? 0;
587
+ let blockedReason: string | undefined;
588
+ if (roundCount > maxRounds) blockedReason = `round_budget_exhausted:${roundCount}>${maxRounds}`;
589
+ else if (repeatedStateCount >= maxRepeatedState) blockedReason = `repeated_state:${repeatedStateCount}>=${maxRepeatedState}`;
590
+ else if (consecutiveFailures >= maxFailures) blockedReason = `consecutive_failures:${consecutiveFailures}>=${maxFailures}`;
591
+
592
+ const at = nowIso(options);
593
+ const record: ControllerRoundRelayRecord = {
594
+ schemaVersion: 1,
595
+ repoId: options.repoId,
596
+ relayScopeId,
597
+ originWorkId: work.workId,
598
+ ...(requirementId ? { requirementId } : {}),
599
+ // launcher_start is itself an explicit request to continue the Work. The
600
+ // launched controller must still submit its own end-of-round disposition.
601
+ disposition: 'continue_immediately',
602
+ status: blockedReason ? 'blocked' : 'dispatching',
603
+ controllerId: input.identity.controllerId.trim().slice(0, 240) || 'chatgpt-launcher',
604
+ principalId: input.identity.principalId.trim().slice(0, 240) || input.identity.controllerId.trim().slice(0, 240),
605
+ controllerInstanceId: input.identity.controllerInstanceId.trim().slice(0, 240),
606
+ sessionId: input.identity.sessionId.trim().slice(0, 240),
607
+ claimGeneration: 0,
608
+ stateFingerprint,
609
+ roundCount,
610
+ repeatedStateCount,
611
+ consecutiveFailures,
612
+ maxRounds,
613
+ maxRepeatedState,
614
+ maxFailures,
615
+ reason: 'launcher_start_requested_continuation',
616
+ ...(bounded(input.browserSessionId, 500) ? { browserSessionId: bounded(input.browserSessionId, 500) } : previous?.browserSessionId ? { browserSessionId: previous.browserSessionId } : {}),
617
+ ...(bounded(input.conversationUrl, 2_000) ? { conversationUrl: bounded(input.conversationUrl, 2_000) } : previous?.conversationUrl ? { conversationUrl: previous.conversationUrl } : {}),
618
+ ...(blockedReason ? { blockedReason } : {}),
619
+ submittedAt: at,
620
+ updatedAt: at,
621
+ };
622
+ writeControlPlaneRecord(options.controllerHome, {
623
+ namespace: NAMESPACE,
624
+ scope: options.repoId,
625
+ key: work.workId,
626
+ schemaVersion: SCHEMA_VERSION,
627
+ value: record,
628
+ action: blockedReason ? 'controller_round_initial_launch_blocked' : 'controller_round_initial_launch_begin',
629
+ expectedRevision: existing?.revision ?? null,
630
+ });
631
+ return record;
632
+ });
633
+ }
634
+
635
+ export function beginControllerRoundRelayAfterRelease(
636
+ options: ControllerRoundRelayStoreOptions,
637
+ input: { workId: string; releasedSession: ControllerSession },
638
+ ): ControllerRoundRelayRecord | undefined {
639
+ const initial = readRelayRecord(options, input.workId);
640
+ if (!initial || initial.value.status !== 'pending_release') return undefined;
641
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-begin:${input.releasedSession.controllerId}`, () => {
642
+ const current = readRelayRecord(options, input.workId);
643
+ if (!current || current.value.status !== 'pending_release') return undefined;
644
+ const record = current.value;
645
+ const releasedPrincipal = input.releasedSession.principalId?.trim() || input.releasedSession.controllerId;
646
+ if (
647
+ record.claimGeneration !== input.releasedSession.claimGeneration
648
+ || record.controllerId !== input.releasedSession.controllerId
649
+ || record.principalId !== releasedPrincipal
650
+ ) {
651
+ throw new Error(`CONTROLLER_RELAY_RELEASE_FENCE_MISMATCH: ${input.workId}`);
652
+ }
653
+ const at = nowIso(options);
654
+ const dispatching: ControllerRoundRelayRecord = { ...record, status: 'dispatching', updatedAt: at };
655
+ writeControlPlaneRecord(options.controllerHome, {
656
+ namespace: NAMESPACE,
657
+ scope: options.repoId,
658
+ key: input.workId,
659
+ schemaVersion: SCHEMA_VERSION,
660
+ value: dispatching,
661
+ action: 'controller_round_relay_dispatch_begin',
662
+ expectedRevision: current.revision,
663
+ });
664
+ return dispatching;
665
+ });
666
+ }
667
+
668
+ export function finishControllerRoundRelayDispatch(
669
+ options: ControllerRoundRelayStoreOptions,
670
+ input: { workId: string; ok: boolean; browserSessionId?: string; conversationUrl?: string; error?: string },
671
+ ): ControllerRoundRelayRecord | undefined {
672
+ const initial = readRelayRecord(options, input.workId);
673
+ if (!initial || initial.value.status !== 'dispatching') return initial?.value;
674
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-finish:${input.workId}`, () => {
675
+ const current = readRelayRecord(options, input.workId);
676
+ if (!current || current.value.status !== 'dispatching') return current?.value;
677
+ const at = nowIso(options);
678
+ const next: ControllerRoundRelayRecord = input.ok
679
+ ? {
680
+ ...current.value,
681
+ status: 'dispatched',
682
+ consecutiveFailures: 0,
683
+ lastError: undefined,
684
+ blockedReason: undefined,
685
+ tabSettlementStatus: 'round_open',
686
+ tabSettlementAt: undefined,
687
+ tabSettlementError: undefined,
688
+ ...(bounded(input.browserSessionId, 500) ? { browserSessionId: bounded(input.browserSessionId, 500) } : {}),
689
+ ...(bounded(input.conversationUrl, 2_000) ? { conversationUrl: bounded(input.conversationUrl, 2_000) } : {}),
690
+ dispatchedAt: at,
691
+ updatedAt: at,
692
+ }
693
+ : {
694
+ ...current.value,
695
+ status: 'failed',
696
+ consecutiveFailures: current.value.consecutiveFailures + 1,
697
+ lastError: bounded(input.error, 2_000) ?? 'CONTROLLER_RELAY_DISPATCH_FAILED',
698
+ updatedAt: at,
699
+ };
700
+ writeControlPlaneRecord(options.controllerHome, {
701
+ namespace: NAMESPACE,
702
+ scope: options.repoId,
703
+ key: input.workId,
704
+ schemaVersion: SCHEMA_VERSION,
705
+ value: next,
706
+ action: input.ok ? 'controller_round_relay_dispatched' : 'controller_round_relay_failed',
707
+ expectedRevision: current.revision,
708
+ });
709
+ return next;
710
+ });
711
+ }
712
+
713
+ export function acknowledgeControllerRoundClaim(
714
+ options: ControllerRoundRelayStoreOptions,
715
+ input: { workId: string; session: ControllerSession },
716
+ ): ControllerRoundRelayRecord | undefined {
717
+ const initial = readRelayRecord(options, input.workId);
718
+ if (!initial) return undefined;
719
+ if (initial.value.status === 'claimed') {
720
+ if (
721
+ initial.value.controllerId === input.session.controllerId
722
+ && initial.value.sessionId === input.session.sessionId
723
+ && initial.value.claimGeneration === input.session.claimGeneration
724
+ ) return initial.value;
725
+ } else if (initial.value.status === 'blocked') {
726
+ if (!initial.value.blockedReason?.startsWith('repeated_state:')) return initial.value;
727
+ } else if (initial.value.status !== 'dispatched') return initial.value;
728
+ if (input.session.controllerType !== 'chatgpt') throw new Error(`CONTROLLER_RELAY_CHATGPT_CLAIM_REQUIRED: ${input.workId}`);
729
+
730
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-claim:${input.session.controllerId}`, () => {
731
+ const current = readRelayRecord(options, input.workId);
732
+ if (!current) return undefined;
733
+ if (current.value.status === 'claimed') {
734
+ if (
735
+ current.value.controllerId === input.session.controllerId
736
+ && current.value.sessionId === input.session.sessionId
737
+ && current.value.claimGeneration === input.session.claimGeneration
738
+ ) return current.value;
739
+
740
+ const owner = getControllerSession(options, input.workId);
741
+ const ownerPrincipal = owner?.principalId?.trim() || owner?.controllerId;
742
+ const sessionPrincipal = input.session.principalId?.trim() || input.session.controllerId;
743
+ if (
744
+ !owner
745
+ || owner.controllerType !== 'chatgpt'
746
+ || owner.controllerId !== input.session.controllerId
747
+ || owner.sessionId !== input.session.sessionId
748
+ || owner.claimGeneration !== input.session.claimGeneration
749
+ || ownerPrincipal !== sessionPrincipal
750
+ || (owner.controllerInstanceId?.trim() || '') !== (input.session.controllerInstanceId?.trim() || '')
751
+ ) throw new Error(`CONTROLLER_RELAY_CLAIM_IDENTITY_MISMATCH: ${input.workId}`);
752
+ if (current.value.controllerId !== owner.controllerId || current.value.principalId !== ownerPrincipal) {
753
+ throw new Error(`CONTROLLER_RELAY_CLAIM_CONFLICT: ${input.workId}`);
754
+ }
755
+ if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) {
756
+ throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${input.workId}`);
757
+ }
758
+ const controllerInstanceId = owner.controllerInstanceId?.trim();
759
+ if (!controllerInstanceId) throw new Error(`CONTROLLER_RELAY_CLAIM_INSTANCE_REQUIRED: ${input.workId}`);
760
+ const at = nowIso(options);
761
+ const migrated: ControllerRoundRelayRecord = {
762
+ ...current.value,
763
+ controllerInstanceId,
764
+ sessionId: owner.sessionId,
765
+ claimGeneration: owner.claimGeneration,
766
+ claimedAt: at,
767
+ updatedAt: at,
768
+ lastError: undefined,
769
+ };
770
+ writeControlPlaneRecord(options.controllerHome, {
771
+ namespace: NAMESPACE,
772
+ scope: options.repoId,
773
+ key: input.workId,
774
+ schemaVersion: SCHEMA_VERSION,
775
+ value: migrated,
776
+ action: 'controller_round_relay_claim_migrated',
777
+ expectedRevision: current.revision,
778
+ });
779
+ return migrated;
780
+ }
781
+ if (current.value.status === 'blocked') {
782
+ if (!current.value.blockedReason?.startsWith('repeated_state:')) return current.value;
783
+ if (current.value.roundCount > current.value.maxRounds || current.value.consecutiveFailures >= current.value.maxFailures) {
784
+ return current.value;
785
+ }
786
+ const work = getWorkContract(options, input.workId);
787
+ if (!work || isTerminalWorkContractStatus(work.status)) return current.value;
788
+ const owner = getControllerSession(options, input.workId);
789
+ const ownerPrincipal = owner?.principalId?.trim() || owner?.controllerId;
790
+ const sessionPrincipal = input.session.principalId?.trim() || input.session.controllerId;
791
+ if (
792
+ !owner
793
+ || owner.controllerType !== 'chatgpt'
794
+ || owner.controllerId !== input.session.controllerId
795
+ || owner.sessionId !== input.session.sessionId
796
+ || owner.claimGeneration !== input.session.claimGeneration
797
+ || ownerPrincipal !== sessionPrincipal
798
+ || (owner.controllerInstanceId?.trim() || '') !== (input.session.controllerInstanceId?.trim() || '')
799
+ ) throw new Error(`CONTROLLER_RELAY_CLAIM_IDENTITY_MISMATCH: ${input.workId}`);
800
+ if (current.value.controllerId !== owner.controllerId || current.value.principalId !== ownerPrincipal) {
801
+ throw new Error(`CONTROLLER_RELAY_CLAIM_CONFLICT: ${input.workId}`);
802
+ }
803
+ if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) {
804
+ throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${input.workId}`);
805
+ }
806
+ const controllerInstanceId = owner.controllerInstanceId?.trim();
807
+ if (!controllerInstanceId) throw new Error(`CONTROLLER_RELAY_CLAIM_INSTANCE_REQUIRED: ${input.workId}`);
808
+ const stateFingerprint = mechanicalStateFingerprint(options, work, current.value.requirementId, current.value.relayScopeId);
809
+ if (stateFingerprint === current.value.stateFingerprint) return current.value;
810
+ const at = nowIso(options);
811
+ const rearmed: ControllerRoundRelayRecord = {
812
+ ...current.value,
813
+ status: 'claimed',
814
+ controllerId: owner.controllerId,
815
+ principalId: ownerPrincipal,
816
+ controllerInstanceId,
817
+ sessionId: owner.sessionId,
818
+ claimGeneration: owner.claimGeneration,
819
+ stateFingerprint,
820
+ repeatedStateCount: 0,
821
+ blockedReason: undefined,
822
+ lastError: undefined,
823
+ claimedAt: at,
824
+ updatedAt: at,
825
+ };
826
+ writeControlPlaneRecord(options.controllerHome, {
827
+ namespace: NAMESPACE,
828
+ scope: options.repoId,
829
+ key: input.workId,
830
+ schemaVersion: SCHEMA_VERSION,
831
+ value: rearmed,
832
+ action: 'controller_round_relay_claim_rearmed_after_state_change',
833
+ expectedRevision: current.revision,
834
+ });
835
+ return rearmed;
836
+ }
837
+ if (current.value.status !== 'dispatched') return current.value;
838
+ const owner = getControllerSession(options, input.workId);
839
+ if (!owner) throw new Error(`CONTROLLER_RELAY_ACTIVE_CLAIM_REQUIRED: ${input.workId}`);
840
+ if (
841
+ owner.controllerType !== 'chatgpt'
842
+ || owner.controllerId !== input.session.controllerId
843
+ || owner.sessionId !== input.session.sessionId
844
+ || owner.claimGeneration !== input.session.claimGeneration
845
+ || (owner.principalId?.trim() || owner.controllerId) !== (input.session.principalId?.trim() || input.session.controllerId)
846
+ ) throw new Error(`CONTROLLER_RELAY_CLAIM_IDENTITY_MISMATCH: ${input.workId}`);
847
+ if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) {
848
+ throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${input.workId}`);
849
+ }
850
+ const claimGeneration = owner.claimGeneration;
851
+ const at = nowIso(options);
852
+ const next: ControllerRoundRelayRecord = {
853
+ ...current.value,
854
+ status: 'claimed',
855
+ controllerId: owner.controllerId,
856
+ principalId: owner.principalId?.trim() || owner.controllerId,
857
+ controllerInstanceId: owner.controllerInstanceId?.trim() || current.value.controllerInstanceId,
858
+ sessionId: owner.sessionId,
859
+ claimGeneration,
860
+ claimedAt: at,
861
+ updatedAt: at,
862
+ lastError: undefined,
863
+ };
864
+ writeControlPlaneRecord(options.controllerHome, {
865
+ namespace: NAMESPACE,
866
+ scope: options.repoId,
867
+ key: input.workId,
868
+ schemaVersion: SCHEMA_VERSION,
869
+ value: next,
870
+ action: 'controller_round_relay_claim_acknowledged',
871
+ expectedRevision: current.revision,
872
+ });
873
+ return next;
874
+ });
875
+ }
876
+
877
+ export function claimStalledControllerRoundRelays(
878
+ options: ControllerRoundRelayStoreOptions,
879
+ input: { nowMs?: number; graceMs?: number; limit?: number } = {},
880
+ ): ControllerRoundRelayRecord[] {
881
+ const nowMs = input.nowMs ?? Date.now();
882
+ const graceMs = Math.max(60_000, Math.min(input.graceMs ?? DEFAULT_UNCLOSED_ROUND_GRACE_MS, MAX_UNCLOSED_ROUND_GRACE_MS));
883
+ const limit = Math.max(1, Math.min(Math.trunc(input.limit ?? 2), 16));
884
+ const claimed: ControllerRoundRelayRecord[] = [];
885
+
886
+ for (const candidate of latestRelayRecordsByScope(options)) {
887
+ if (claimed.length >= limit) break;
888
+ if (!['pending_release', 'dispatching', 'dispatched', 'claimed'].includes(candidate.status)) continue;
889
+ const roundOpenedAtMs = Date.parse(candidate.claimedAt ?? candidate.dispatchedAt ?? candidate.updatedAt);
890
+ if (!Number.isFinite(roundOpenedAtMs) || nowMs - roundOpenedAtMs < graceMs) continue;
891
+ const requirement = requirementForRelay(options, candidate.requirementId);
892
+ if (requirement && !['planned', 'active'].includes(requirement.state)) continue;
893
+ const candidateWorks = relevantWork(options, candidate);
894
+ const activeCandidateWorks = candidateWorks.filter((work) => !isTerminalWorkContractStatus(work.status));
895
+ if (activeCandidateWorks.length === 0) continue;
896
+ if (activeCandidateWorks.some((work) => controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) continue;
897
+
898
+ const next = relayLock(options, candidate.relayScopeId, `controller-relay-recover:${candidate.originWorkId}`, () => {
899
+ const latest = relayHistory(options, candidate.relayScopeId)[0];
900
+ if (!latest || latest.originWorkId !== candidate.originWorkId || latest.updatedAt !== candidate.updatedAt || latest.status !== candidate.status) return undefined;
901
+ const latestRoundOpenedAtMs = Date.parse(latest.claimedAt ?? latest.dispatchedAt ?? latest.updatedAt);
902
+ if (!Number.isFinite(latestRoundOpenedAtMs) || nowMs - latestRoundOpenedAtMs < graceMs) return undefined;
903
+ const latestRequirement = requirementForRelay(options, latest.requirementId);
904
+ if (latestRequirement && !['planned', 'active'].includes(latestRequirement.state)) return undefined;
905
+ const works = relevantWork(options, latest);
906
+ const activeWorks = works.filter((work) => !isTerminalWorkContractStatus(work.status));
907
+ if (activeWorks.length === 0) return undefined;
908
+ if (activeWorks.some((work) => controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) return undefined;
909
+
910
+ const currentRecord = readRelayRecord(options, latest.originWorkId);
911
+ if (!currentRecord || currentRecord.value.updatedAt !== latest.updatedAt || currentRecord.value.status !== latest.status) return undefined;
912
+ const fingerprintWork = activeWorks[0] ?? getWorkContract(options, latest.originWorkId);
913
+ const stateFingerprint = fingerprintWork
914
+ ? mechanicalStateFingerprint(options, fingerprintWork, latest.requirementId, latest.relayScopeId)
915
+ : latest.stateFingerprint;
916
+ const resumesUndispatchedRound = latest.status === 'pending_release' || latest.status === 'dispatching';
917
+ const roundCount = latest.roundCount + (resumesUndispatchedRound ? 0 : 1);
918
+ const repeatedStateCount = resumesUndispatchedRound
919
+ ? latest.repeatedStateCount
920
+ : latest.stateFingerprint === stateFingerprint ? latest.repeatedStateCount + 1 : 0;
921
+ let blockedReason: string | undefined;
922
+ if (roundCount > latest.maxRounds) blockedReason = `round_budget_exhausted:${roundCount}>${latest.maxRounds}`;
923
+ else if (repeatedStateCount >= latest.maxRepeatedState) blockedReason = `repeated_state:${repeatedStateCount}>=${latest.maxRepeatedState}`;
924
+
925
+ const at = new Date(nowMs).toISOString();
926
+ const recovered: ControllerRoundRelayRecord = {
927
+ ...latest,
928
+ status: blockedReason ? 'blocked' : 'dispatching',
929
+ stateFingerprint,
930
+ roundCount,
931
+ repeatedStateCount,
932
+ lastError: latest.status === 'pending_release'
933
+ ? 'CONTROLLER_RELAY_RELEASE_TRANSITION_INCOMPLETE'
934
+ : latest.status === 'dispatching'
935
+ ? 'CONTROLLER_RELAY_DISPATCH_TRANSITION_INCOMPLETE'
936
+ : latest.status === 'claimed' ? 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED' : 'CONTROLLER_RELAY_ROUND_UNCLOSED',
937
+ claimedAt: undefined,
938
+ ...(blockedReason ? { blockedReason } : { blockedReason: undefined }),
939
+ updatedAt: at,
940
+ };
941
+ writeControlPlaneRecord(options.controllerHome, {
942
+ namespace: NAMESPACE,
943
+ scope: options.repoId,
944
+ key: latest.originWorkId,
945
+ schemaVersion: SCHEMA_VERSION,
946
+ value: recovered,
947
+ action: blockedReason ? 'controller_round_relay_stalled_blocked' : 'controller_round_relay_stalled_recovery_begin',
948
+ expectedRevision: currentRecord.revision,
949
+ });
950
+ return blockedReason ? undefined : recovered;
951
+ });
952
+ if (next) claimed.push(next);
953
+ }
954
+ return claimed;
955
+ }
956
+
957
+ export function buildControllerRoundRelayPrompt(
958
+ options: ControllerRoundRelayStoreOptions,
959
+ record: ControllerRoundRelayRecord,
960
+ ): string {
961
+ const requirement = requirementForRelay(options, record.requirementId);
962
+ const relevantWorks = relevantWork(options, record);
963
+ const works = relevantWorks.slice(0, 8);
964
+ const workIds = new Set(relevantWorks.map((work) => work.workId));
965
+ const handoffs = listHandoffItems({ controllerHome: options.controllerHome, repoId: options.repoId, status: 'active', limit: 100 })
966
+ .filter((handoff) => !handoff.workId || workIds.has(handoff.workId))
967
+ .slice(0, 8);
968
+ const requirementLine = requirement
969
+ ? `Requirement ${requirement.requirementId}: state=${requirement.state}; outcome=${requirement.outcomeStatement.slice(0, 800)}`
970
+ : `No durable Requirement is bound; semantic relay scope is ${record.relayScopeId}.`;
971
+ const workLines = works.length > 0
972
+ ? works.map((work) => `- ${work.workId}: status=${work.status}; phase=${work.phase}; updated=${work.updatedAt}; objective=${work.objective.slice(0, 500)}`).join('\n')
973
+ : '- no linked Work snapshot found';
974
+ const handoffLines = handoffs.length > 0
975
+ ? handoffs.map((handoff) => `- ${handoff.id}: status=${handoff.status}; work=${handoff.workId ?? 'repo'}; ${handoff.title}; reason=${handoff.reason.slice(0, 300)}`).join('\n')
976
+ : '- no active linked Handoff';
977
+ return [
978
+ `Continue Forge Requirement/Goal relay ${record.relayScopeId} in repo ${record.repoId}.`,
979
+ 'This is a new ChatGPT controller round. First reread the latest Forge Requirement/Work/Handoff state; the snapshot below is only a launch hint.',
980
+ requirementLine,
981
+ `Linked Work snapshot:\n${workLines}`,
982
+ `Active Handoff snapshot:\n${handoffLines}`,
983
+ `Previous relay origin Work: ${record.originWorkId}. Do not assume the next action must continue that Work; select, start, or claim the appropriate Work from the latest semantic state.`,
984
+ `Mechanical relay budget: round=${record.roundCount}/${record.maxRounds}; repeated_state=${record.repeatedStateCount}/${record.maxRepeatedState}; consecutive_failures=${record.consecutiveFailures}/${record.maxFailures}.`,
985
+ ...(['CONTROLLER_RELAY_ROUND_UNCLOSED', 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED'].includes(record.lastError ?? '')
986
+ ? ['The previous ChatGPT round did not submit an explicit disposition before its liveness grace elapsed. Forge is only recovering liveness: reread durable state, make the semantic decision in ChatGPT, and close this round explicitly.']
987
+ : []),
988
+ `If the overall Requirement/Goal still needs another controller round, explicitly submit rh_work controller_disposition=continue_immediately with relay_scope_id=${record.relayScopeId} before releasing the Work you own. Otherwise use wait, wait_for_user with an active Handoff, or goal_complete. If a frozen client schema does not expose controller_disposition, use rh_work operation=repair with capability_id=controller.disposition:<disposition>:${record.relayScopeId}; Forge maps that compatibility call to the same canonical disposition path and fences it identically.`,
989
+ 'Forge must not infer the semantic next step. Existing Work ownership, Handoff authority, and external-effect authorization remain authoritative; claim the exact Work before any mutation.',
990
+ ].join('\n');
991
+ }