@moretea-labs/forge 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (528) hide show
  1. package/AGENTS.md +70 -7
  2. package/CHANGELOG.md +14 -0
  3. package/CLAUDE.md +17 -2
  4. package/CONTRIBUTING.md +3 -2
  5. package/THIRD_PARTY_NOTICES.md +1 -1
  6. package/adapters/chatgpt/browser-delivery-host.ts +7 -1
  7. package/adapters/chatgpt/browser-delivery-runtime.ts +529 -56
  8. package/adapters/chatgpt/controller-binding-store.ts +101 -22
  9. package/adapters/chatgpt/controller-round-host.ts +15 -25
  10. package/adapters/chatgpt/controller-round-settlement-store.ts +58 -4
  11. package/adapters/chatgpt/provider-delivery.ts +228 -1
  12. package/adapters/chatgpt/work-conversation-binding-store.ts +79 -53
  13. package/adapters/computer/chatgpt-extension-target.ts +463 -0
  14. package/adapters/computer/chatgpt-target-router.ts +103 -0
  15. package/adapters/computer/chrome-extension/background.js +385 -0
  16. package/adapters/computer/chrome-extension/content.js +378 -0
  17. package/adapters/computer/chrome-extension/core.js +24 -0
  18. package/adapters/computer/chrome-extension/manifest.json +30 -0
  19. package/adapters/computer/desktop-operator-discovery.ts +11 -31
  20. package/adapters/computer/interaction-target-authority.ts +142 -18
  21. package/adapters/mcp/auth.ts +0 -1
  22. package/adapters/mcp/instructions.ts +5 -4
  23. package/adapters/mcp/multi-repository.ts +3 -19
  24. package/adapters/mcp/oauth.ts +73 -0
  25. package/adapters/mcp/paths.ts +4 -1
  26. package/adapters/mcp/runtime-gateway/automation-receipt-adapter.ts +87 -0
  27. package/adapters/mcp/runtime-gateway/bounded-result-runtime.ts +62 -0
  28. package/adapters/mcp/runtime-gateway/cognition-settlement.ts +201 -0
  29. package/adapters/mcp/runtime-gateway/context-adapter.ts +411 -73
  30. package/adapters/mcp/runtime-gateway/context-pack-sidecar.ts +91 -0
  31. package/adapters/mcp/runtime-gateway/controller-authority-adapter.ts +20 -5
  32. package/adapters/mcp/runtime-gateway/core-capability-adapter.ts +258 -0
  33. package/adapters/mcp/runtime-gateway/engineering-preconditions.ts +10 -27
  34. package/adapters/mcp/runtime-gateway/engineering-tool-contract.ts +15 -0
  35. package/adapters/mcp/runtime-gateway/execution-tools.ts +8 -42
  36. package/adapters/mcp/runtime-gateway/gateway-execution-adapter.ts +2 -1
  37. package/adapters/mcp/runtime-gateway/plugin-adapter.ts +117 -45
  38. package/adapters/mcp/runtime-gateway/process-tools.ts +190 -22
  39. package/adapters/mcp/runtime-gateway/protected-computer-adapter.ts +16 -4
  40. package/adapters/mcp/runtime-gateway/recovery-adapter.ts +69 -52
  41. package/adapters/mcp/runtime-gateway/repository-compat-adapter.ts +4 -35
  42. package/adapters/mcp/runtime-gateway/result-adapter.ts +10 -4
  43. package/adapters/mcp/runtime-gateway/router.ts +18 -504
  44. package/adapters/mcp/runtime-gateway/routing-policy.ts +499 -0
  45. package/adapters/mcp/runtime-gateway/runtime-observation-adapter.ts +14 -65
  46. package/adapters/mcp/runtime-gateway/runtime-readiness-observation.ts +218 -0
  47. package/adapters/mcp/runtime-gateway/runtime-tool-definitions.ts +113 -173
  48. package/adapters/mcp/runtime-gateway/runtime-tools.ts +15 -5
  49. package/adapters/mcp/runtime-gateway/scheduler-adapter.ts +1 -0
  50. package/adapters/mcp/runtime-gateway/shared-adapter.ts +20 -0
  51. package/adapters/mcp/runtime-gateway/status-inbox-adapter.ts +358 -266
  52. package/adapters/mcp/runtime-gateway/typescript-navigation-sidecar.ts +136 -0
  53. package/adapters/mcp/runtime-gateway/work-adapter.ts +261 -3122
  54. package/adapters/mcp/runtime-gateway/work-controller-operations.ts +257 -0
  55. package/adapters/mcp/runtime-gateway/work-detail-projection.ts +60 -0
  56. package/adapters/mcp/runtime-gateway/work-input-wire-migration.ts +246 -0
  57. package/adapters/mcp/runtime-gateway/work-learning-operations.ts +145 -0
  58. package/adapters/mcp/runtime-gateway/work-list-projection.ts +38 -0
  59. package/adapters/mcp/runtime-gateway/work-plan-operations.ts +382 -0
  60. package/adapters/mcp/runtime-gateway/work-repair-adapter.ts +308 -0
  61. package/adapters/mcp/runtime-gateway/work-requirement-operations.ts +148 -0
  62. package/adapters/mcp/runtime-gateway/work-semantic-operations.ts +263 -0
  63. package/adapters/mcp/runtime-gateway/work-workflow-operations.ts +113 -0
  64. package/adapters/mcp/runtime-gateway/workflow-supervisor-adapter.ts +156 -0
  65. package/adapters/mcp/server.ts +67 -23
  66. package/adapters/mcp/setup.ts +1 -3
  67. package/adapters/mcp/tool-mapping/legacy-tool-service.ts +44 -170
  68. package/adapters/mcp/tool-mapping/post-finalize-work-attribution.ts +4 -9
  69. package/adapters/mcp/tool-mapping/repository-tools.ts +133 -142
  70. package/adapters/mcp/toolset-names.ts +5 -0
  71. package/adapters/mcp/toolset.ts +6 -5
  72. package/adapters/mcp/transports/http-observation.ts +365 -0
  73. package/adapters/mcp/transports/http.ts +154 -677
  74. package/adapters/mcp/transports/oauth-http.ts +322 -0
  75. package/adapters/mcp/transports/session-registry.ts +6 -0
  76. package/adapters/mcp/tunnels/openai-secure-tunnel.ts +89 -5
  77. package/assets/plugin-registry.v1.json +6 -4
  78. package/assets/reference-configs/agentic-development-flow.md +25 -0
  79. package/assets/reference-configs/global-working-rules.md +19 -0
  80. package/assets/skill-version.json +2 -2
  81. package/docs/architecture/CURRENT.md +111 -41
  82. package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +14 -5
  83. package/docs/architecture/decisions/20260823-immediate-controller-relay.md +6 -3
  84. package/docs/architecture/decisions/20260907-assistant-learning-loop.md +2 -0
  85. package/docs/architecture/decisions/20260907-controller-round-transition-policy.md +19 -4
  86. package/docs/architecture/decisions/20260916-workflow-supervisor-authority.md +249 -0
  87. package/docs/architecture/decisions/20260917-cognitive-memory-foundation.md +82 -0
  88. package/docs/architecture/decisions/20260918-stable-candidate-release-lifecycle.md +221 -0
  89. package/docs/architecture/decisions/20260919-autonomous-work-liveness.md +62 -0
  90. package/docs/architecture/decisions/20260924-thin-semantic-working-context.md +72 -0
  91. package/docs/architecture/decisions/20261001-supervisor-turn-authority.md +237 -0
  92. package/docs/architecture/decisions/20261005-forge-v3-desktop-client-boundary.md +77 -0
  93. package/docs/architecture/domains/verification.md +1 -0
  94. package/docs/architecture/domains/workflow-engine.md +6 -0
  95. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +1 -1
  96. package/docs/forge-plugin-management.md +2 -2
  97. package/docs/operations/controller-performance-and-502.md +17 -0
  98. package/docs/operations/controller-reliability-runbook.md +2 -2
  99. package/docs/operations/features.md +1 -1
  100. package/docs/operations/features.zh-CN.md +1 -1
  101. package/docs/operations/homebrew.md +2 -2
  102. package/docs/operations/mcp-tool-exposure.md +2 -0
  103. package/docs/operations/releasing.md +2 -0
  104. package/docs/operations/standalone-disaster-recovery.md +14 -6
  105. package/docs/wiki/Implementation.md +1 -1
  106. package/package.json +25 -14
  107. package/packages/kernel/cognition/api/index.ts +6 -0
  108. package/packages/kernel/cognition/application/activation-service.ts +451 -0
  109. package/packages/kernel/cognition/application/consolidation-service.ts +185 -0
  110. package/packages/kernel/cognition/application/legacy-experience-adapter.ts +38 -0
  111. package/packages/kernel/cognition/application/memory-service.ts +64 -0
  112. package/packages/kernel/cognition/domain/learning.ts +106 -0
  113. package/packages/kernel/cognition/domain/memory.ts +254 -0
  114. package/packages/kernel/controller/api/index.ts +1 -0
  115. package/packages/kernel/controller/application/continuation-service.ts +281 -0
  116. package/packages/kernel/controller/application/controller-round-service.ts +12 -0
  117. package/packages/kernel/controller/domain/controller-round-transition-policy.ts +242 -21
  118. package/packages/kernel/controller/domain/controller-round.ts +4 -1
  119. package/packages/kernel/controller/domain/execution-quality.ts +3 -1
  120. package/packages/kernel/controller/infrastructure/controller-round-store.ts +689 -103
  121. package/packages/kernel/controller/infrastructure/controller-session-store.ts +297 -71
  122. package/packages/kernel/controller/ports/controller-host.ts +2 -0
  123. package/packages/kernel/identity/api/index.ts +10 -0
  124. package/packages/kernel/identity/domain/activity-projection.ts +43 -0
  125. package/packages/kernel/identity/domain/capability-broker.ts +60 -0
  126. package/packages/kernel/identity/domain/grant.ts +72 -0
  127. package/packages/kernel/identity/domain/owned-resource.ts +116 -0
  128. package/packages/kernel/identity/domain/user-request.ts +93 -0
  129. package/packages/kernel/identity/infrastructure/activity-projection-store.ts +77 -0
  130. package/packages/kernel/identity/infrastructure/capability-broker.ts +81 -0
  131. package/packages/kernel/identity/infrastructure/grant-store.ts +284 -0
  132. package/packages/kernel/identity/infrastructure/owned-resource-store.ts +259 -0
  133. package/packages/kernel/identity/infrastructure/user-request-store.ts +182 -0
  134. package/packages/kernel/scheduler/api/index.ts +0 -2
  135. package/packages/kernel/scheduler/application/schedule-service.ts +3 -0
  136. package/packages/kernel/scheduler/application/settlement.ts +0 -15
  137. package/packages/kernel/scheduler/domain/schedule.ts +87 -0
  138. package/packages/kernel/scheduler/infrastructure/schedule-store.ts +153 -133
  139. package/packages/kernel/work/api/index.ts +3 -2
  140. package/packages/kernel/work/application/work-service.ts +17 -42
  141. package/packages/kernel/work/domain/current-task-lineage.ts +104 -0
  142. package/packages/kernel/work/domain/engineering-design.ts +10 -5
  143. package/packages/kernel/work/domain/engineering-profile.ts +8 -12
  144. package/packages/kernel/work/domain/evaluation-promotion-receipt.ts +186 -0
  145. package/packages/kernel/work/domain/execution-concurrency.ts +19 -26
  146. package/packages/kernel/work/domain/execution-snapshot.ts +0 -31
  147. package/packages/kernel/work/domain/implementation-review.ts +14 -331
  148. package/packages/kernel/work/domain/objective-graph.ts +119 -0
  149. package/packages/kernel/work/domain/project-engineering-contract.ts +23 -1
  150. package/packages/kernel/work/domain/repository-completion-receipt.ts +1 -1
  151. package/packages/kernel/work/domain/state-machine.ts +25 -63
  152. package/packages/kernel/work/domain/types.ts +57 -51
  153. package/packages/kernel/work/infrastructure/work-contract-store.ts +819 -728
  154. package/packages/kernel/work/ports/work-contract-store.ts +5 -2
  155. package/packages/plugin-runtime/browser/session-authority.ts +2 -1
  156. package/packages/plugin-runtime/computer/chatgpt-identity.ts +24 -0
  157. package/packages/plugin-runtime/computer/conversation-target.ts +143 -0
  158. package/packages/plugin-runtime/computer/index.ts +2 -0
  159. package/packages/plugin-runtime/computer/target-authority.ts +26 -1
  160. package/packages/plugin-runtime/external/unix-jsonl-transport.ts +142 -18
  161. package/packages/protocols/computer/contract.ts +27 -1
  162. package/scripts/activate-source-baseline.ts +7 -9
  163. package/scripts/canary-controller-semantic-wait.ts +12 -24
  164. package/scripts/check-mcp-compatibility.ts +48 -136
  165. package/scripts/check-runtime-architecture.mjs +622 -217
  166. package/scripts/forge-local-recovery-helper.mjs +93 -15
  167. package/scripts/lib/project-init-lib.sh +3 -0
  168. package/scripts/open-source-audit-allowlist.txt +1 -2
  169. package/scripts/run-bounded-command.mjs +5 -0
  170. package/scripts/run-governed-gate.ts +0 -1
  171. package/scripts/run-mobile-monitor-bridge.ts +0 -1
  172. package/scripts/smoke-control-plane-failure-domain.ts +94 -0
  173. package/scripts/smoke-mcp-http-runtime.ts +5 -0
  174. package/scripts/smoke-runtime-recovery.ts +12 -43
  175. package/scripts/smoke-schedule-engine.ts +23 -15
  176. package/scripts/smoke-unattended-transport-recovery.ts +223 -0
  177. package/scripts/smoke-user-request-continuation-restart.ts +232 -0
  178. package/scripts/smoke-workflow-supervisor-chrome-adapter.ts +92 -0
  179. package/scripts/smoke-workflow-supervisor-process-restart.ts +276 -0
  180. package/scripts/smoke-workflow-supervisor-restart-recovery.ts +150 -0
  181. package/scripts/smoke-workflow-supervisor.ts +103 -0
  182. package/scripts/verify-forge-runtime.sh +2 -1
  183. package/src/cli/agent-jobs/executor-health.ts +11 -34
  184. package/src/cli/agent-jobs/integration.ts +18 -49
  185. package/src/cli/agent-jobs/job-manager.ts +7 -3
  186. package/src/cli/commands/brain-assistant.ts +27 -13
  187. package/src/cli/commands/brain.ts +13 -15
  188. package/src/cli/commands/chatgpt.ts +1 -163
  189. package/src/cli/commands/recovery.ts +135 -121
  190. package/src/cli/commands/runtime.ts +3 -3
  191. package/src/cli/commands/setup-profile.ts +8 -0
  192. package/src/cli/controller/check-runner.ts +13 -2
  193. package/src/cli/controller/completion-backlog.ts +3 -3
  194. package/src/cli/controller/completion-orchestrator.ts +4 -43
  195. package/src/cli/controller/completion-target.ts +3 -72
  196. package/src/cli/controller/context/source-materializer.ts +81 -100
  197. package/src/cli/controller/context-pack.ts +10 -8
  198. package/src/cli/controller/execution-completion.ts +4 -7
  199. package/src/cli/controller/execution-policy.ts +36 -153
  200. package/src/cli/controller/governance.ts +2 -3
  201. package/src/cli/controller/issue-store.ts +4 -99
  202. package/src/cli/controller/launch-agents.ts +59 -18
  203. package/src/cli/controller/operational-plan.ts +24 -51
  204. package/src/cli/controller/runtime-config.ts +5 -1
  205. package/src/cli/controller/stuck-state-migration.ts +2 -3
  206. package/src/cli/controller/systemd-user.ts +39 -1
  207. package/src/cli/editing/edit-session.ts +1 -7
  208. package/src/cli/local-bridge/console-automations.ts +18 -13
  209. package/src/cli/local-bridge/console-view-models.ts +3 -12
  210. package/src/cli/local-bridge/facade-api.ts +64 -191
  211. package/src/cli/local-bridge/server.ts +24 -147
  212. package/src/cli/local-bridge/types.ts +0 -1
  213. package/src/cli/mcp/oauth-client-retirement.ts +74 -0
  214. package/src/cli/repositories/command-executor.ts +29 -23
  215. package/src/cli/repositories/command-scope.ts +35 -16
  216. package/src/cli/repositories/controller-home.ts +48 -0
  217. package/src/cli/repositories/ephemeral-workspace.ts +20 -30
  218. package/src/cli/repositories/git-command-executor.ts +2 -2
  219. package/src/cli/repositories/registry.ts +77 -11
  220. package/src/cli/repositories/structured-git.ts +62 -2
  221. package/src/cli/repository/session-cache.ts +92 -6
  222. package/src/runtime/context/assistant-context.ts +20 -7
  223. package/src/runtime/context/assistant-work-context.ts +89 -27
  224. package/src/runtime/context/automatic-learning.ts +565 -0
  225. package/src/runtime/context/cognitive-attention.ts +173 -0
  226. package/src/runtime/context/cognitive-skill-canary.ts +158 -0
  227. package/src/runtime/context/context-closure.ts +46 -9
  228. package/src/runtime/context/context-pack-process.ts +103 -0
  229. package/src/runtime/context/context-plane.ts +1 -9
  230. package/src/runtime/context/direct-controller-learning.ts +298 -0
  231. package/src/runtime/context/semantic-navigation-contract.ts +9 -0
  232. package/src/runtime/context/semantic-navigation.ts +6 -34
  233. package/src/runtime/context/typescript-navigation-process.ts +145 -0
  234. package/src/runtime/context/typescript-navigation.ts +89 -3
  235. package/src/runtime/control-plane/cleanup-artifact-retention.ts +212 -9
  236. package/src/runtime/control-plane/concurrency/work-execution-concurrency.ts +9 -47
  237. package/src/runtime/control-plane/execution/controller-authority-recovery.ts +34 -4
  238. package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +29 -20
  239. package/src/runtime/control-plane/execution/execution-identity.ts +23 -3
  240. package/src/runtime/control-plane/execution/repository-work-attribution.ts +7 -30
  241. package/src/runtime/control-plane/execution/retained-work-resume.ts +206 -199
  242. package/src/runtime/control-plane/execution/session-store.ts +31 -1
  243. package/src/runtime/control-plane/execution/validation.ts +6 -8
  244. package/src/runtime/control-plane/execution/work-authority-reconciler.ts +13 -42
  245. package/src/runtime/control-plane/execution/work-check-evidence.ts +137 -0
  246. package/src/runtime/control-plane/execution/work-completion-authority.ts +30 -85
  247. package/src/runtime/control-plane/execution/work-delivery-service.ts +497 -0
  248. package/src/runtime/control-plane/execution/work-evidence-policy.ts +12 -50
  249. package/src/runtime/control-plane/execution/work-execution-support.ts +50 -149
  250. package/src/runtime/control-plane/execution/work-handle-authority.ts +188 -109
  251. package/src/runtime/control-plane/execution/work-handle-store.ts +19 -43
  252. package/src/runtime/control-plane/execution/work-head-settlement.ts +53 -20
  253. package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +52 -29
  254. package/src/runtime/control-plane/execution/work-operation-service.ts +43 -27
  255. package/src/runtime/control-plane/execution/work-preparation-service.ts +126 -266
  256. package/src/runtime/control-plane/execution/work-remote-delivery.ts +2 -3
  257. package/src/runtime/control-plane/execution/work-revision-diff.ts +39 -0
  258. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +173 -186
  259. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +28 -84
  260. package/src/runtime/control-plane/execution/work-verification-service.ts +406 -50
  261. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +5 -1
  262. package/src/runtime/control-plane/facade/capability-registry.ts +362 -37
  263. package/src/runtime/control-plane/facade/check-normalization.ts +3 -9
  264. package/src/runtime/control-plane/facade/codex-delegation.ts +10 -11
  265. package/src/runtime/control-plane/facade/controller-connection.ts +135 -0
  266. package/src/runtime/control-plane/facade/goal-workloop-access.ts +1 -20
  267. package/src/runtime/control-plane/facade/goal-workloop.ts +281 -1753
  268. package/src/runtime/control-plane/facade/handoff-inbox-application.ts +12 -25
  269. package/src/runtime/control-plane/facade/handoff-inbox-store.ts +256 -52
  270. package/src/runtime/control-plane/facade/index.ts +0 -1
  271. package/src/runtime/control-plane/facade/operation-digest.ts +12 -15
  272. package/src/runtime/control-plane/facade/plan-contract-store.ts +451 -991
  273. package/src/runtime/control-plane/facade/policy-gate.ts +0 -23
  274. package/src/runtime/control-plane/facade/repository-work-admission.ts +12 -22
  275. package/src/runtime/control-plane/facade/requirement-authority.ts +102 -199
  276. package/src/runtime/control-plane/facade/requirement-board.ts +3 -3
  277. package/src/runtime/control-plane/facade/rh-work-operation-contract.ts +51 -14
  278. package/src/runtime/control-plane/facade/self-healing-loop.ts +18 -53
  279. package/src/runtime/control-plane/facade/suggested-actions.ts +15 -18
  280. package/src/runtime/control-plane/facade/types.ts +49 -108
  281. package/src/runtime/control-plane/facade/work-continuation.ts +8 -8
  282. package/src/runtime/control-plane/generated-cache-retention.ts +97 -1
  283. package/src/runtime/control-plane/global-scheduler/autonomous-continuation.ts +248 -0
  284. package/src/runtime/control-plane/global-scheduler/durable-admission.ts +1 -1
  285. package/src/runtime/control-plane/global-scheduler/maintenance.ts +239 -112
  286. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +2 -1
  287. package/src/runtime/control-plane/global-scheduler/scheduler.ts +66 -40
  288. package/src/runtime/control-plane/global-scheduler/wake-signal.ts +42 -2
  289. package/src/runtime/control-plane/governance/access-policy.ts +19 -2
  290. package/src/runtime/control-plane/governance/authorization.ts +20 -3
  291. package/src/runtime/control-plane/launcher/chatgpt-round-continuation.ts +63 -300
  292. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +28 -300
  293. package/src/runtime/control-plane/launcher/local-conversation-controller.ts +238 -0
  294. package/src/runtime/control-plane/launcher/provider-mcp-bootstrap.ts +5 -2
  295. package/src/runtime/control-plane/launcher/thin-launcher.ts +96 -240
  296. package/src/runtime/control-plane/persistence/cognition-store.ts +820 -0
  297. package/src/runtime/control-plane/persistence/experience-store.ts +162 -29
  298. package/src/runtime/control-plane/persistence/requirement-store.ts +216 -74
  299. package/src/runtime/control-plane/persistence/sqlite-store.ts +113 -6
  300. package/src/runtime/control-plane/release-retention.ts +135 -47
  301. package/src/runtime/control-plane/repository-namespace-retention.ts +2 -2
  302. package/src/runtime/control-plane/runtime-cleanup.ts +290 -21
  303. package/src/runtime/control-plane/workspace/project-onboarding.ts +80 -0
  304. package/src/runtime/control-plane/workspace/semantic-sync-service.ts +19 -5
  305. package/src/runtime/control-plane/workspace/workspace-store.ts +169 -0
  306. package/src/runtime/diagnostics/incident-repair.ts +313 -146
  307. package/src/runtime/diagnostics/mcp-timing.ts +114 -0
  308. package/src/runtime/evidence/artifact-store.ts +158 -40
  309. package/src/runtime/evidence/evidence-store.ts +112 -8
  310. package/src/runtime/evidence/output-handle-index.ts +225 -0
  311. package/src/runtime/evidence/result-store.ts +158 -44
  312. package/src/runtime/execution/jobs/store.ts +3 -4
  313. package/src/runtime/execution/managed-workspace.ts +57 -0
  314. package/src/runtime/execution/process-runtime/check-facade.ts +1 -1
  315. package/src/runtime/execution/process-runtime/check-result-retention.ts +2 -2
  316. package/src/runtime/execution/process-runtime/check-result.ts +105 -1
  317. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +18 -8
  318. package/src/runtime/execution/process-runtime/command-facade.ts +79 -27
  319. package/src/runtime/execution/process-runtime/gc.ts +15 -2
  320. package/src/runtime/execution/process-runtime/handle-index.ts +176 -0
  321. package/src/runtime/execution/process-runtime/host-command-lane.ts +139 -0
  322. package/src/runtime/execution/process-runtime/index.ts +3 -0
  323. package/src/runtime/execution/process-runtime/lightweight-managed.ts +251 -72
  324. package/src/runtime/execution/process-runtime/persisted-check.ts +3 -3
  325. package/src/runtime/execution/process-runtime/process-runner-entry.ts +47 -5
  326. package/src/runtime/execution/process-runtime/process-scope.ts +160 -0
  327. package/src/runtime/execution/process-runtime/resource-claims.ts +65 -61
  328. package/src/runtime/execution/process-runtime/runtime.ts +35 -2
  329. package/src/runtime/execution/process-runtime/store.ts +30 -2
  330. package/src/runtime/execution/process-runtime/types.ts +6 -1
  331. package/src/runtime/execution/workers/executor.ts +2 -6
  332. package/src/runtime/platform/service-inventory.ts +130 -0
  333. package/src/runtime/platform/service-manager.ts +8 -0
  334. package/src/runtime/plugins/action-application.ts +85 -28
  335. package/src/runtime/plugins/app-store-connect-adapter.ts +38 -14
  336. package/src/runtime/plugins/browser-adapter.ts +757 -34
  337. package/src/runtime/plugins/browser-automation-service.ts +23 -3
  338. package/src/runtime/plugins/browser-macos-bridge.ts +321 -29
  339. package/src/runtime/plugins/browser-manifest-surface.ts +45 -11
  340. package/src/runtime/plugins/browser-native-session-service.ts +63 -13
  341. package/src/runtime/plugins/browser-registration.ts +16 -0
  342. package/src/runtime/plugins/browser-runtime.ts +14 -1
  343. package/src/runtime/plugins/browser-session-authority.ts +2 -2
  344. package/src/runtime/plugins/browser-session-store.ts +46 -76
  345. package/src/runtime/plugins/capability-authorization-grants.ts +258 -62
  346. package/src/runtime/plugins/computer-chatgpt-browser-adapter-installer.ts +219 -0
  347. package/src/runtime/plugins/computer-chatgpt-macos-target.ts +891 -0
  348. package/src/runtime/plugins/computer-registration.ts +222 -11
  349. package/src/runtime/plugins/desktop-operator-external-policy.ts +36 -4
  350. package/src/runtime/plugins/desktop-operator-registration.ts +12 -4
  351. package/src/runtime/plugins/external-adapter.ts +42 -6
  352. package/src/runtime/plugins/external-registration.ts +21 -1
  353. package/src/runtime/plugins/gmail-adapter.ts +23 -2
  354. package/src/runtime/plugins/google-calendar-adapter.ts +2 -2
  355. package/src/runtime/plugins/google-shared.ts +14 -7
  356. package/src/runtime/plugins/google-tasks-adapter.ts +2 -2
  357. package/src/runtime/plugins/ios-adapter.ts +1 -1
  358. package/src/runtime/plugins/lightweight-action.ts +208 -2
  359. package/src/runtime/plugins/local-system-adapter.ts +12 -2
  360. package/src/runtime/plugins/managed-process-adapter.ts +12 -5
  361. package/src/runtime/plugins/plugin-action-sidecar.ts +17 -13
  362. package/src/runtime/plugins/plugin-management-adapter.ts +1 -1
  363. package/src/runtime/plugins/store.ts +602 -428
  364. package/src/runtime/plugins/types.ts +20 -0
  365. package/src/runtime/plugins/xiaohongshu-publish.ts +16 -0
  366. package/src/runtime/projections/invalidation.ts +7 -3
  367. package/src/runtime/projections/materialized-view.ts +80 -36
  368. package/src/runtime/recovery/actions.ts +3 -3
  369. package/src/runtime/recovery/capability-action-application.ts +46 -45
  370. package/src/runtime/recovery/lifecycle-inventory.ts +29 -29
  371. package/src/runtime/recovery/maintenance-executor.ts +374 -123
  372. package/src/runtime/recovery/store.ts +29 -11
  373. package/src/runtime/release/release-coordinator.ts +296 -0
  374. package/src/runtime/release/release-session.ts +716 -0
  375. package/src/runtime/release/release-state-migration.ts +25 -0
  376. package/src/runtime/resources/leases/store.ts +56 -25
  377. package/src/runtime/root/computer-composition.ts +191 -6
  378. package/src/runtime/root/controller-progression-composition.ts +192 -0
  379. package/src/runtime/root/controller-round-composition.ts +20 -1
  380. package/src/runtime/root/deployment-topology.ts +106 -0
  381. package/src/runtime/root/entry.ts +4 -3
  382. package/src/runtime/root/known-good-recovery.ts +184 -0
  383. package/src/runtime/root/local-bridge.ts +0 -1
  384. package/src/runtime/root/mcp-transport.ts +111 -18
  385. package/src/runtime/root/ownership.ts +261 -9
  386. package/src/runtime/root/package-connector-service.ts +105 -19
  387. package/src/runtime/root/package-runtime-release.ts +117 -18
  388. package/src/runtime/root/package-runtime-service.ts +17 -11
  389. package/src/runtime/root/release-execution-canary.ts +44 -12
  390. package/src/runtime/root/release-loader.ts +90 -8
  391. package/src/runtime/root/release-manifest.ts +80 -6
  392. package/src/runtime/root/release-materialize.ts +372 -28
  393. package/src/runtime/root/release-store.ts +395 -36
  394. package/src/runtime/root/runtime-lane.ts +191 -0
  395. package/src/runtime/root/runtime.ts +159 -27
  396. package/src/runtime/root/scheduled-controller-composition.ts +28 -3
  397. package/src/runtime/root/service-runner.ts +2 -2
  398. package/src/runtime/root/service.ts +36 -33
  399. package/src/runtime/root/status.ts +48 -0
  400. package/src/runtime/root/types.ts +32 -3
  401. package/src/runtime/root/workflow-supervisor-composition.ts +493 -0
  402. package/src/runtime/root/workflow-supervisor-runtime.ts +209 -0
  403. package/src/runtime/root/write-fence.ts +50 -2
  404. package/src/runtime/safe-tooling/google-credential-store.ts +76 -21
  405. package/src/runtime/safe-tooling/google-oauth-broker.ts +25 -7
  406. package/src/runtime/safe-tooling/workspace-auth.ts +1 -1
  407. package/src/runtime/shared/bounded-child-supervisor.ts +9 -1
  408. package/src/runtime/shared/process-environment.ts +20 -9
  409. package/src/runtime/shared/response-budget.ts +1 -3
  410. package/src/runtime/standalone-recovery/controller-home-migration.ts +16 -24
  411. package/src/runtime/standalone-recovery/core.ts +3702 -342
  412. package/src/runtime/standalone-recovery/entry.ts +469 -39
  413. package/src/runtime/standalone-recovery/installer.ts +262 -148
  414. package/src/runtime/standalone-recovery/mcp-server.ts +29 -193
  415. package/src/runtime/standalone-recovery/operation-lock.ts +1 -1
  416. package/src/runtime/standalone-recovery/performance.ts +54 -16
  417. package/src/runtime/standalone-recovery/release.ts +53 -5
  418. package/src/runtime/standalone-recovery/service-labels.ts +5 -2
  419. package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +5 -2
  420. package/src/runtime/workflow/schedules/browser-probe.ts +17 -6
  421. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +10 -3
  422. package/src/runtime/workflow/schedules/engine.ts +138 -172
  423. package/src/runtime/workflow/schedules/work-continuation.ts +44 -5
  424. package/src/runtime/workflows/runtime.ts +15 -19
  425. package/src/testing/test-governance.ts +5 -1
  426. package/supervisor/chatgpt-conversation.ts +23 -0
  427. package/supervisor/client.ts +201 -0
  428. package/supervisor/control-plane.ts +868 -0
  429. package/supervisor/entry.ts +15 -0
  430. package/supervisor/forge-validators.ts +80 -0
  431. package/supervisor/index.ts +5 -0
  432. package/supervisor/native-browser-adapter.ts +786 -0
  433. package/supervisor/native-messaging/host.ts +39 -0
  434. package/supervisor/native-messaging/manifest.ts +26 -0
  435. package/supervisor/native-messaging/protocol.ts +27 -0
  436. package/supervisor/paths.ts +18 -0
  437. package/supervisor/protocol.ts +202 -0
  438. package/supervisor/server.ts +404 -0
  439. package/supervisor/store.ts +1451 -0
  440. package/supervisor/tsconfig.json +8 -0
  441. package/supervisor/types.ts +255 -0
  442. package/adapters/chatgpt/controller-host.ts +0 -119
  443. package/adapters/mcp/controller-round-compatibility.ts +0 -184
  444. package/adapters/mcp/frozen-client-semantic-compatibility.ts +0 -292
  445. package/adapters/mcp/runtime-gateway/work-compat-adapter.ts +0 -502
  446. package/packages/kernel/progression/api/index.ts +0 -2
  447. package/packages/kernel/progression/application/projector.ts +0 -211
  448. package/packages/kernel/progression/domain/types.ts +0 -116
  449. package/packages/kernel/progression/index.ts +0 -1
  450. package/packages/kernel/scheduler/application/continuation-service.ts +0 -212
  451. package/packages/kernel/scheduler/domain/continuation.ts +0 -38
  452. package/packages/kernel/scheduler/infrastructure/continuation-dispatch-store.ts +0 -38
  453. package/packages/kernel/work/domain/admission-policy.ts +0 -34
  454. package/scripts/benchmark-background-check-overlap.ts +0 -228
  455. package/scripts/benchmark-computer-hotpath.ts +0 -255
  456. package/scripts/benchmark-controller-hotpath.ts +0 -212
  457. package/scripts/benchmark-controller-round2.ts +0 -191
  458. package/scripts/benchmark-ios-agent-device.ts +0 -511
  459. package/scripts/benchmark-mcp-host.sh +0 -117
  460. package/scripts/benchmark-mcp-transport.ts +0 -169
  461. package/scripts/benchmark-operational-memory-activation.ts +0 -235
  462. package/scripts/benchmark-operational-shadow.ts +0 -28
  463. package/scripts/benchmark-quality-harness.ts +0 -146
  464. package/scripts/benchmark-route-session-concurrency.ts +0 -915
  465. package/scripts/benchmark-thin-harness-gateway-ab.ts +0 -632
  466. package/scripts/benchmark-thin-harness-mcp-e2e.ts +0 -498
  467. package/scripts/benchmark-thin-harness.ts +0 -251
  468. package/scripts/benchmark-typescript-navigation.ts +0 -186
  469. package/scripts/benchmark-work-efficiency.ts +0 -334
  470. package/scripts/build-controller-ui.ts +0 -28
  471. package/scripts/check-npm-release.sh +0 -32
  472. package/scripts/check-public-release-surface.sh +0 -19
  473. package/scripts/check-release-published.sh +0 -57
  474. package/scripts/check-release-readiness.sh +0 -37
  475. package/scripts/check-release-version.mjs +0 -74
  476. package/scripts/check-tarball-install-smoke.sh +0 -150
  477. package/scripts/package-source-archive.sh +0 -147
  478. package/scripts/public-release-files.txt +0 -39
  479. package/scripts/public-release.ts +0 -174
  480. package/scripts/publish-release-tarball.sh +0 -27
  481. package/scripts/route-nl-vs-ts-eval.ts +0 -679
  482. package/scripts/stage-runtime-release.ts +0 -44
  483. package/src/cli/controller/progress.ts +0 -656
  484. package/src/cli/controller/work-mode.ts +0 -181
  485. package/src/cli/local-bridge/console-assets.ts +0 -17
  486. package/src/cli/local-bridge/dashboard.ts +0 -17
  487. package/src/cli/local-bridge/frontend/src/App.tsx +0 -10
  488. package/src/cli/local-bridge/frontend/src/components/Button.tsx +0 -1
  489. package/src/cli/local-bridge/frontend/src/components/DetailPane.tsx +0 -3
  490. package/src/cli/local-bridge/frontend/src/components/SearchField.tsx +0 -1
  491. package/src/cli/local-bridge/frontend/src/components/SectionHeader.tsx +0 -1
  492. package/src/cli/local-bridge/frontend/src/components/Segmented.tsx +0 -1
  493. package/src/cli/local-bridge/frontend/src/components/StatusText.tsx +0 -3
  494. package/src/cli/local-bridge/frontend/src/design/icons.tsx +0 -11
  495. package/src/cli/local-bridge/frontend/src/design/tokens.css +0 -8
  496. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +0 -153
  497. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +0 -37
  498. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +0 -183
  499. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +0 -2
  500. package/src/cli/local-bridge/frontend/src/features/system/SystemView.tsx +0 -2
  501. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +0 -4
  502. package/src/cli/local-bridge/frontend/src/lib/api.ts +0 -30
  503. package/src/cli/local-bridge/frontend/src/lib/format.ts +0 -4
  504. package/src/cli/local-bridge/frontend/src/lib/routes.ts +0 -7
  505. package/src/cli/local-bridge/frontend/src/main.tsx +0 -1
  506. package/src/cli/local-bridge/frontend/src/shell/AppShell.tsx +0 -1
  507. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +0 -2
  508. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +0 -4
  509. package/src/cli/local-bridge/frontend/src/styles.css +0 -28
  510. package/src/cli/local-bridge/frontend/src/types.ts +0 -15
  511. package/src/cli/local-bridge/frontend/src/vite-env.d.ts +0 -1
  512. package/src/cli/local-bridge/frontend/vite.config.ts +0 -27
  513. package/src/cli/local-bridge/ui-dist/app.css +0 -1
  514. package/src/cli/local-bridge/ui-dist/app.js +0 -11228
  515. package/src/runtime/control-plane/execution/content-equivalent-commit-authority.ts +0 -169
  516. package/src/runtime/control-plane/execution/direct-canonical-work-reconciliation.ts +0 -85
  517. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +0 -655
  518. package/src/runtime/control-plane/execution/work-finalization-service.ts +0 -2850
  519. package/src/runtime/control-plane/execution/work-path-scope.ts +0 -37
  520. package/src/runtime/control-plane/execution/work-task-receipt.ts +0 -468
  521. package/src/runtime/control-plane/facade/controller-round-relay.ts +0 -3
  522. package/src/runtime/control-plane/facade/work-admission-policy.ts +0 -142
  523. package/src/runtime/control-plane/facade/work-implementation-review.ts +0 -2
  524. package/src/runtime/control-plane/facade/work-state-machine.ts +0 -2
  525. package/src/runtime/control-plane/facade/work-task-projection.ts +0 -66
  526. package/src/runtime/control-plane/routing/index.ts +0 -1
  527. package/src/runtime/control-plane/routing/route-policy.ts +0 -401
  528. package/src/runtime/control-plane/routing/workspace-admission.ts +0 -70
@@ -8,12 +8,23 @@ import {
8
8
  withControlPlaneTransaction,
9
9
  writeControlPlaneRecord,
10
10
  writeControlPlaneRecordWithinTransaction,
11
+ withControlPlaneReadDatabase,
11
12
  type ControlPlaneRecord,
12
13
  } from '../../../../src/runtime/control-plane/persistence/sqlite-store';
13
14
  import { workHasActiveExecution } from '../../../../src/runtime/execution/work-activity';
14
- import { controllerSessionBlocksRecovery, getControllerSession } from './controller-session-store';
15
+ import {
16
+ assertControllerSessionWorkClaimable,
17
+ controllerSessionBlocksRecovery,
18
+ controllerSessionPrincipalId,
19
+ getControllerSession,
20
+ releaseObservedControllerSession,
21
+ resumeControllerSessionWithinTransaction,
22
+ withControllerSessionMutationLock,
23
+ type ClaimedControllerSession,
24
+ type ControllerSessionClaimInput,
25
+ } from './controller-session-store';
15
26
  import { getHandoffItem, listHandoffItems } from '../../../../src/runtime/control-plane/facade/handoff-inbox-store';
16
- import { getWorkContract, readWorkContractStore, isTerminalWorkContractStatus, type WorkContract } from '../../work/api/index';
27
+ import { currentTaskLineageWorkIds, getWorkContract, readActiveWorkCandidates, readWorkContractLineageSnapshot, readWorkContractStore, semanticWorkState, type WorkContract } from '../../work/api/index';
17
28
  import { isTerminalHandoffStatus } from '../../../protocols/handoff/index';
18
29
  import type { ControllerSession, ControllerType } from '../domain/types';
19
30
  import { deriveClosedRoundQualitySignals, type AssistantContextSnapshot, type AssistantContextUsage, type ClosedRoundObservation, type ExecutionQualityAdjustmentResult, type ExecutionQualityDecision, type ExecutionQualitySignal } from '../domain/execution-quality';
@@ -28,7 +39,9 @@ import {
28
39
  import {
29
40
  controllerRoundBlockerClass,
30
41
  controllerRoundProviderEffectId,
42
+ controllerRoundRelayClaimable,
31
43
  decideControllerRoundTransition,
44
+ type ControllerRoundBlockerClass,
32
45
  type ControllerRoundTransitionDecision,
33
46
  type ControllerRoundTransitionEvent,
34
47
  } from '../domain/controller-round-transition-policy';
@@ -54,6 +67,8 @@ export interface SubmitControllerRoundDispositionInput {
54
67
  handoffId?: string;
55
68
  stateFingerprint?: string;
56
69
  reason?: string;
70
+ /** Exact facade-authenticated authority for completed-Work provider-wait goal closure only. */
71
+ terminalAuthorityId?: string;
57
72
  /** Opaque provider binding owned by a ControllerHost adapter. */
58
73
  bindingId?: string;
59
74
  maxRounds?: number;
@@ -73,12 +88,24 @@ export interface BeginInitialControllerRoundDispatchInput {
73
88
  maxFailures?: number;
74
89
  /** Required for any later schedule/manual/replan occurrence after a prior semantic state exists. */
75
90
  occurrenceId?: string;
91
+ /**
92
+ * Scheduler-owned recovery may re-arm an unchanged semantic wait when the
93
+ * outer Workflow Supervisor task is no longer runnable. This is bounded by
94
+ * the existing repeated-state/round budgets and never applies to a user
95
+ * blocker or an unknown provider outcome.
96
+ */
97
+ allowSemanticWaitRecovery?: boolean;
98
+ /** Authenticated launcher evidence for a distinct occurrence after round-budget exhaustion. */
99
+ authorizeRoundBudgetOccurrence?: boolean;
76
100
  }
77
101
 
78
102
  export interface RecoverControllerRoundRelayAuthorityInput {
79
103
  workId: string;
80
104
  requestedBy?: string;
105
+ recoveryReason?: string;
81
106
  identity: ControllerRoundRelayIdentity;
107
+ /** Set only after the caller proves this identity is served by the live canonical Runtime. */
108
+ allowCanonicalRuntimeMigration?: boolean;
82
109
  }
83
110
 
84
111
  /** Legacy ControllerRound rows predate explicit controllerType and were ChatGPT-only. */
@@ -95,6 +122,14 @@ const DEFAULT_UNCLOSED_ROUND_GRACE_MS = 10 * 60_000;
95
122
  const MAX_UNCLOSED_ROUND_GRACE_MS = 60 * 60_000;
96
123
  const DEFAULT_STALLED_RECOVERY_BACKOFF_MS = 60_000;
97
124
  const MAX_STALLED_RECOVERY_BACKOFF_MS = 15 * 60_000;
125
+ const MAX_LATEST_RELAY_CACHE_ENTRIES = 128;
126
+
127
+ interface LatestRelayRecordsCacheEntry {
128
+ signature: string;
129
+ records: ControllerRoundRelayRecord[];
130
+ }
131
+
132
+ const latestRelayRecordsCache = new Map<string, LatestRelayRecordsCacheEntry>();
98
133
 
99
134
 
100
135
  function transitionDecisionOrThrow(decision: ControllerRoundTransitionDecision): { record: ControllerRoundRelayRecord; action?: string; changed: boolean } {
@@ -179,6 +214,18 @@ export function getControllerRoundRelay(
179
214
  return readRelayRecord(options, workId)?.value;
180
215
  }
181
216
 
217
+ export function getRequirementControllerRoundRelay(
218
+ options: ControllerRoundRelayStoreOptions,
219
+ requirementId: string,
220
+ ): ControllerRoundRelayRecord | undefined {
221
+ const normalizedRequirementId = requirementId.trim();
222
+ if (!normalizedRequirementId) return undefined;
223
+ const relayScopeId = `requirement:${normalizedRequirementId}`;
224
+ return latestRelayRecordsByScope(options).find((entry) =>
225
+ entry.relayScopeId === relayScopeId
226
+ && entry.requirementId === normalizedRequirementId);
227
+ }
228
+
182
229
  /**
183
230
  * Resolve an existing Requirement-scoped ControllerRound authority for another
184
231
  * Work that is mechanically part of the same durable Requirement/Work graph.
@@ -199,7 +246,11 @@ export function resolveRequirementControllerRoundRelayForWork(
199
246
  && Boolean(entry.requirementId)
200
247
  && `requirement:${entry.requirementId}` === relayScopeId);
201
248
  if (!candidate) return undefined;
202
- return relevantWork(options, candidate).some((work) => work.workId === workId) ? candidate : undefined;
249
+ const target = getWorkContract(options, workId);
250
+ if (!target) return undefined;
251
+ if (candidate.requirementId && target.requirementId !== candidate.requirementId) return undefined;
252
+ const all = readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts;
253
+ return currentTaskLineageWorkIds([candidate.originWorkId], all).has(workId) ? candidate : undefined;
203
254
  }
204
255
 
205
256
  function relayHistory(options: ControllerRoundRelayStoreOptions, relayScopeId: string): ControllerRoundRelayRecord[] {
@@ -214,7 +265,28 @@ function relayHistory(options: ControllerRoundRelayStoreOptions, relayScopeId: s
214
265
  }
215
266
 
216
267
  function latestRelayRecordsByScope(options: ControllerRoundRelayStoreOptions): ControllerRoundRelayRecord[] {
268
+ const cacheKey = `${options.controllerHome}\u0000${options.repoId}`;
269
+ const signature = withControlPlaneReadDatabase(options.controllerHome, (database) => {
270
+ const statement = database.prepare(`
271
+ SELECT COUNT(*) AS recordCount,
272
+ COALESCE(SUM(revision), 0) AS revisionSum,
273
+ MAX(updated_at) AS latestUpdatedAt
274
+ FROM control_plane_records
275
+ WHERE namespace = ? AND scope = ?
276
+ `);
277
+ try {
278
+ const row = statement.get(NAMESPACE, options.repoId) as { recordCount?: unknown; revisionSum?: unknown; latestUpdatedAt?: unknown } | undefined;
279
+ return `${String(row?.recordCount ?? 0)}:${String(row?.revisionSum ?? 0)}:${typeof row?.latestUpdatedAt === 'string' ? row.latestUpdatedAt : ''}`;
280
+ } finally {
281
+ statement.finalize?.();
282
+ }
283
+ });
284
+ const cached = latestRelayRecordsCache.get(cacheKey);
285
+ if (cached && cached.signature === signature) return cached.records;
286
+
217
287
  const latest = new Map<string, ControllerRoundRelayRecord>();
288
+ const retiredTerminalWork = (entry: ControllerRoundRelayRecord): boolean =>
289
+ entry.status === 'failed' && entry.failureClass === 'terminal_work';
218
290
  for (const entry of listControlPlaneRecords<ControllerRoundRelayRecord>(options.controllerHome, {
219
291
  namespace: NAMESPACE,
220
292
  scope: options.repoId,
@@ -222,48 +294,156 @@ function latestRelayRecordsByScope(options: ControllerRoundRelayStoreOptions): C
222
294
  }).map((record) => record.value)) {
223
295
  if (entry.status === 'handed_off') continue;
224
296
  const current = latest.get(entry.relayScopeId);
225
- if (!current || entry.updatedAt > current.updatedAt) latest.set(entry.relayScopeId, entry);
297
+ if (!current) {
298
+ latest.set(entry.relayScopeId, entry);
299
+ continue;
300
+ }
301
+ const currentRetired = retiredTerminalWork(current);
302
+ const candidateRetired = retiredTerminalWork(entry);
303
+ if (currentRetired !== candidateRetired) {
304
+ if (currentRetired && !candidateRetired) latest.set(entry.relayScopeId, entry);
305
+ continue;
306
+ }
307
+ if (entry.updatedAt > current.updatedAt) latest.set(entry.relayScopeId, entry);
308
+ }
309
+ const records = [...latest.values()].sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
310
+ latestRelayRecordsCache.set(cacheKey, { signature, records });
311
+ while (latestRelayRecordsCache.size > MAX_LATEST_RELAY_CACHE_ENTRIES) {
312
+ const oldest = latestRelayRecordsCache.keys().next().value;
313
+ if (typeof oldest !== 'string') break;
314
+ latestRelayRecordsCache.delete(oldest);
226
315
  }
227
- return [...latest.values()].sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
316
+ return records;
317
+ }
318
+
319
+ /** Read-only current relay projection for bounded lifecycle/resource reconciliation. */
320
+ export function listControllerRoundRelaysByBlocker(
321
+ options: ControllerRoundRelayStoreOptions,
322
+ blocker: ControllerRoundBlockerClass,
323
+ limit = 16,
324
+ ): ControllerRoundRelayRecord[] {
325
+ const boundedLimit = Math.max(1, Math.min(Math.trunc(limit), 100));
326
+ return latestRelayRecordsByScope(options)
327
+ .filter((record) => controllerRoundBlockerClass(record) === blocker)
328
+ .slice(0, boundedLimit);
329
+ }
330
+
331
+ /** Read-only latest relay projection used by bounded lifecycle/resource reconciliation. */
332
+ export function listCurrentControllerRoundRelays(
333
+ options: ControllerRoundRelayStoreOptions,
334
+ limit = 100,
335
+ ): ControllerRoundRelayRecord[] {
336
+ const boundedLimit = Math.max(1, Math.min(Math.trunc(limit), 100));
337
+ return latestRelayRecordsByScope(options).slice(0, boundedLimit);
338
+ }
339
+
340
+ /**
341
+ * Read-only dispatch candidate query. Filter mechanical provider-dispatch facts
342
+ * before applying the bounded result limit so unrelated historical relays cannot
343
+ * permanently hide a newer runnable continuation behind the projection window.
344
+ */
345
+ export function listControllerRoundRelaysAwaitingProviderDispatch(
346
+ options: ControllerRoundRelayStoreOptions,
347
+ input: {
348
+ limit?: number;
349
+ controllerTypes?: readonly ControllerType[];
350
+ occurrenceIdPrefix?: string;
351
+ } = {},
352
+ ): ControllerRoundRelayRecord[] {
353
+ const boundedLimit = Math.max(1, Math.min(Math.trunc(input.limit ?? 100), 100));
354
+ const occurrenceIdPrefix = input.occurrenceIdPrefix?.trim();
355
+ return latestRelayRecordsByScope(options)
356
+ .filter((record) => {
357
+ if (record.status !== 'dispatching') return false;
358
+ // Attempt count is diagnostic history, not evidence that an external
359
+ // provider effect may have committed. A resolved user-action blocker clears
360
+ // the concrete effect facts below while preserving the retry count; that
361
+ // exact round must become runnable again without inventing a new occurrence.
362
+ if (record.providerDispatchEffectId
363
+ || record.providerDispatchStartedAt
364
+ || record.providerDispatchReceiptId
365
+ || record.dispatchedAt) return false;
366
+ if (input.controllerTypes && !input.controllerTypes.includes(relayControllerType(record))) return false;
367
+ if (occurrenceIdPrefix && !record.occurrenceId?.startsWith(occurrenceIdPrefix)) return false;
368
+ return true;
369
+ })
370
+ .slice(0, boundedLimit);
228
371
  }
229
372
 
373
+
230
374
  function requirementForRelay(options: ControllerRoundRelayStoreOptions, requirementId: string | undefined) {
231
375
  return requirementId ? readRequirement({ controllerHome: options.controllerHome }, requirementId)?.value : undefined;
232
376
  }
233
377
 
234
- function relevantWork(options: ControllerRoundRelayStoreOptions, record: Pick<ControllerRoundRelayRecord, 'relayScopeId' | 'originWorkId' | 'requirementId'>): WorkContract[] {
235
- const all = readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts;
236
- const linkedWorkIds = new Set([
237
- record.originWorkId,
238
- ...relayHistory(options, record.relayScopeId).map((entry) => entry.originWorkId),
239
- ]);
240
- if (record.requirementId) {
241
- for (const work of all) {
242
- if (work.requirementId === record.requirementId) linkedWorkIds.add(work.workId);
243
- }
244
- }
245
- let expanded = true;
246
- while (expanded) {
247
- expanded = false;
248
- for (const work of all) {
249
- if (linkedWorkIds.has(work.workId)) {
250
- if (work.parentWorkId && !linkedWorkIds.has(work.parentWorkId)) {
251
- linkedWorkIds.add(work.parentWorkId);
252
- expanded = true;
253
- }
254
- continue;
255
- }
256
- if (work.parentWorkId && linkedWorkIds.has(work.parentWorkId)) {
257
- linkedWorkIds.add(work.workId);
258
- expanded = true;
259
- }
260
- }
378
+ function relevantWork(
379
+ options: ControllerRoundRelayStoreOptions,
380
+ record: Pick<ControllerRoundRelayRecord, 'relayScopeId' | 'originWorkId' | 'requirementId'>,
381
+ allWorkContracts?: readonly WorkContract[],
382
+ ): WorkContract[] {
383
+ const snapshot = allWorkContracts ? undefined : readWorkContractLineageSnapshot({
384
+ controllerHome: options.controllerHome,
385
+ repoId: options.repoId,
386
+ });
387
+ const all = allWorkContracts ?? snapshot!.contracts;
388
+ const invalid = snapshot?.invalid ?? [];
389
+ const candidates = invalid.length === 0
390
+ ? all
391
+ : [...all, ...invalid.map((work) => ({
392
+ workId: work.workId,
393
+ parentWorkId: work.parentWorkId,
394
+ predecessorWorkId: work.predecessorWorkId,
395
+ supersedes: work.supersedes,
396
+ supersededBy: work.supersededBy,
397
+ } as WorkContract))];
398
+ const linkedWorkIds = currentTaskLineageWorkIds([record.originWorkId], candidates);
399
+ const relatedInvalid = invalid.find((work) => linkedWorkIds.has(work.workId));
400
+ if (relatedInvalid) {
401
+ throw new Error(`WORK_LINEAGE_CONTRACT_INVALID: ${relatedInvalid.workId}: ${relatedInvalid.error}`);
261
402
  }
262
403
  return all
263
404
  .filter((work) => linkedWorkIds.has(work.workId))
264
405
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
265
406
  }
266
407
 
408
+ function recoveryFenceWork(
409
+ options: ControllerRoundRelayStoreOptions,
410
+ record: Pick<ControllerRoundRelayRecord, 'relayScopeId' | 'originWorkId' | 'requirementId'>,
411
+ allWorkContracts: readonly WorkContract[] = readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts,
412
+ ): WorkContract[] {
413
+ const explicit = relevantWork(options, record, allWorkContracts);
414
+ const requirementId = record.requirementId?.trim();
415
+ if (!requirementId || record.relayScopeId !== `requirement:${requirementId}`) return explicit;
416
+ const byId = new Map(explicit.map((work) => [work.workId, work] as const));
417
+ for (const work of allWorkContracts) {
418
+ if (work.requirementId?.trim() === requirementId) byId.set(work.workId, work);
419
+ }
420
+ return [...byId.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
421
+ }
422
+
423
+ function relayMayHaveActiveWork(
424
+ options: ControllerRoundRelayStoreOptions,
425
+ record: Pick<ControllerRoundRelayRecord, 'relayScopeId' | 'originWorkId' | 'requirementId'>,
426
+ activeWorkSnapshot: ReturnType<typeof readActiveWorkCandidates>,
427
+ ): boolean {
428
+ const all = readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts;
429
+ const recoveryFenceIds = new Set(recoveryFenceWork(options, record, all).map((work) => work.workId));
430
+ // Recovery is a destructive liveness decision. For Requirement-scoped relay
431
+ // authority it must conservatively fence every active Work in that Requirement,
432
+ // while normal semantic context continues to use exact current-task lineage.
433
+ if (activeWorkSnapshot.invalid.some((work) => recoveryFenceIds.has(work.workId))) return true;
434
+ return activeWorkSnapshot.contracts.some((work) => recoveryFenceIds.has(work.workId));
435
+ }
436
+
437
+ function relevantHandoffs(
438
+ options: ControllerRoundRelayStoreOptions,
439
+ works: readonly Pick<WorkContract, 'workId'>[],
440
+ explicitHandoffId?: string,
441
+ ) {
442
+ const linkedWorkIds = new Set(works.map((work) => work.workId));
443
+ return listHandoffItems({ controllerHome: options.controllerHome, repoId: options.repoId, status: 'active', limit: 100 })
444
+ .filter((handoff) => handoff.workId ? linkedWorkIds.has(handoff.workId) : handoff.id === explicitHandoffId);
445
+ }
446
+
267
447
  function semanticVerificationFacts(work: WorkContract): Array<{
268
448
  checkId: string; verificationInputFingerprint: string; checkDefinitionDigest: string;
269
449
  checkEnvironmentFingerprint: string; checkCacheKey: string; outcome: string; status: string;
@@ -297,13 +477,15 @@ function mechanicalStateFingerprint(
297
477
  work: WorkContract,
298
478
  requirementId: string | undefined,
299
479
  relayScopeId: string,
480
+ explicitHandoffId?: string,
481
+ allWorkContracts?: readonly WorkContract[],
300
482
  ): string {
301
483
  const requirement = requirementForRelay(options, requirementId);
302
- const works = relevantWork(options, { relayScopeId, originWorkId: work.workId, requirementId })
484
+ const works = relevantWork(options, { relayScopeId, originWorkId: work.workId, requirementId }, allWorkContracts)
303
485
  .map((entry) => ({
304
486
  workId: entry.workId,
305
487
  parentWorkId: entry.parentWorkId,
306
- status: entry.status,
488
+ state: entry.semanticState,
307
489
  phase: entry.phase,
308
490
  dispatchState: entry.dispatchState,
309
491
  evidenceState: entry.evidenceState,
@@ -322,9 +504,7 @@ function mechanicalStateFingerprint(
322
504
  } : undefined,
323
505
  }))
324
506
  .sort((left, right) => left.workId.localeCompare(right.workId));
325
- const linkedWorkIds = new Set(works.map((entry) => entry.workId));
326
- const handoffs = listHandoffItems({ controllerHome: options.controllerHome, repoId: options.repoId, status: 'active', limit: 100 })
327
- .filter((handoff) => !handoff.workId || linkedWorkIds.has(handoff.workId))
507
+ const handoffs = relevantHandoffs(options, works, explicitHandoffId)
328
508
  .map((handoff) => ({
329
509
  id: handoff.id,
330
510
  workId: handoff.workId,
@@ -355,7 +535,7 @@ export function readControllerRoundSemanticStateFingerprint(
355
535
  const work = getWorkContract(options, workId);
356
536
  const current = readRelayRecord(options, workId)?.value;
357
537
  if (!work || !current) return undefined;
358
- return mechanicalStateFingerprint(options, work, current.requirementId, current.relayScopeId);
538
+ return mechanicalStateFingerprint(options, work, current.requirementId, current.relayScopeId, current.handoffId);
359
539
  }
360
540
 
361
541
  function resolveRequirementId(
@@ -416,8 +596,8 @@ function assertControllerRoundSuccessorLineage(
416
596
  ): WorkContract {
417
597
  const successor = getWorkContract(options, successorWorkId);
418
598
  if (!successor) throw new Error(`CONTROLLER_RELAY_SUCCESSOR_WORK_NOT_FOUND: ${successorWorkId}`);
419
- if (isTerminalWorkContractStatus(successor.status)) {
420
- throw new Error(`CONTROLLER_RELAY_SUCCESSOR_WORK_TERMINAL: ${successor.workId}:${successor.status}`);
599
+ if (semanticWorkState(successor) !== 'open') {
600
+ throw new Error(`CONTROLLER_RELAY_SUCCESSOR_WORK_TERMINAL: ${successor.workId}:${semanticWorkState(successor)}`);
421
601
  }
422
602
  if (successor.predecessorWorkId !== predecessor.workId) {
423
603
  throw new Error(`CONTROLLER_RELAY_SUCCESSOR_LINEAGE_MISMATCH: ${successor.workId}:predecessor=${successor.predecessorWorkId ?? 'none'}:expected=${predecessor.workId}`);
@@ -444,16 +624,17 @@ export function bindControllerRoundSuccessorWork(
444
624
  ): ControllerRoundRelayRecord {
445
625
  const predecessor = getWorkContract(options, input.workId);
446
626
  if (!predecessor) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
447
- if (predecessor.status !== 'completed') {
448
- throw new Error(`CONTROLLER_RELAY_SUCCESSOR_PREDECESSOR_NOT_COMPLETED: ${predecessor.workId}:${predecessor.status}`);
627
+ if (semanticWorkState(predecessor) !== 'completed') {
628
+ throw new Error(`CONTROLLER_RELAY_SUCCESSOR_PREDECESSOR_NOT_COMPLETED: ${predecessor.workId}:${semanticWorkState(predecessor)}`);
449
629
  }
450
630
  const initial = readRelayRecord(options, predecessor.workId);
451
631
  if (!initial) throw new Error(`CONTROLLER_RELAY_ROUND_NOT_OPEN: ${predecessor.workId}`);
452
632
  return relayLock(options, initial.value.relayScopeId, `controller-relay-bind-successor:${input.identity.controllerId}`, () => {
453
633
  const current = readRelayRecord(options, predecessor.workId);
454
- if (!current || current.value.status !== 'claimed') {
455
- throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_REQUIRES_CLAIMED_ROUND: ${predecessor.workId}:${current?.value.status ?? 'missing'}`);
634
+ if (!current || !['claimed', 'failed'].includes(current.value.status)) {
635
+ throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_REQUIRES_ACTIVE_OR_FAILED_PRECLAIM_ROUND: ${predecessor.workId}:${current?.value.status ?? 'missing'}`);
456
636
  }
637
+ const failedPreclaim = current.value.status === 'failed';
457
638
  const expectedAuthorityId = current.value.authorityId?.trim() || '';
458
639
  const requestedAuthorityId = input.controllerAuthorityId?.trim() || '';
459
640
  if (expectedAuthorityId) {
@@ -463,19 +644,29 @@ export function bindControllerRoundSuccessorWork(
463
644
  if (!principalId || current.value.controllerId !== input.identity.controllerId || current.value.principalId !== principalId) {
464
645
  throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
465
646
  }
466
- if (relayControllerType(current.value) !== input.identity.controllerType || current.value.claimGeneration < 1) {
647
+ if (relayControllerType(current.value) !== input.identity.controllerType) {
467
648
  throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
468
649
  }
469
650
  const liveOwner = getControllerSession(options, predecessor.workId);
470
- if (liveOwner) {
471
- const livePrincipal = liveOwner.principalId?.trim() || liveOwner.controllerId;
472
- if (liveOwner.controllerId !== input.identity.controllerId
473
- || liveOwner.controllerType !== input.identity.controllerType
474
- || livePrincipal !== principalId) {
651
+ if (failedPreclaim) {
652
+ if (current.value.lifecycleStage !== 'dispatching' || current.value.claimGeneration !== 0 || liveOwner) {
653
+ throw new Error(`CONTROLLER_RELAY_FAILED_SUCCESSOR_HANDOFF_PRECLAIM_REQUIRED: ${predecessor.workId}`);
654
+ }
655
+ } else {
656
+ if (current.value.claimGeneration < 1) {
475
657
  throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
476
658
  }
659
+ if (liveOwner) {
660
+ const livePrincipal = liveOwner.principalId?.trim() || liveOwner.controllerId;
661
+ if (liveOwner.controllerId !== input.identity.controllerId
662
+ || liveOwner.controllerType !== input.identity.controllerType
663
+ || livePrincipal !== principalId) {
664
+ throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_MISMATCH: ${predecessor.workId}`);
665
+ }
666
+ }
477
667
  }
478
668
  } else {
669
+ if (failedPreclaim) throw new Error(`CONTROLLER_RELAY_SUCCESSOR_BIND_AUTHORITY_REQUIRED: ${predecessor.workId}`);
479
670
  // Legacy claimed rounds have no opaque per-round capability. Preserve only
480
671
  // their exact live owner epoch; rollover is available after the round has
481
672
  // been upgraded by the canonical launcher/recovery path.
@@ -487,6 +678,44 @@ export function bindControllerRoundSuccessorWork(
487
678
  }
488
679
  }
489
680
  const successor = assertControllerRoundSuccessorLineage(options, predecessor, input.successorWorkId.trim());
681
+ if (failedPreclaim) {
682
+ const at = nowIso(options);
683
+ const successorStateFingerprint = mechanicalStateFingerprint(options, successor, current.value.requirementId, current.value.relayScopeId, current.value.handoffId);
684
+ return withControlPlaneTransaction(options.controllerHome, (database) => {
685
+ const predecessorRelay = readControlPlaneRecordWithinTransaction<ControllerRoundRelayRecord>(
686
+ database, NAMESPACE, options.repoId, predecessor.workId,
687
+ );
688
+ if (!predecessorRelay || predecessorRelay.value.status !== 'failed' || predecessorRelay.value.claimGeneration !== 0) {
689
+ throw new Error(`CONTROLLER_RELAY_FAILED_SUCCESSOR_HANDOFF_STALE: ${predecessor.workId}`);
690
+ }
691
+ const existingSuccessorRelay = readControlPlaneRecordWithinTransaction<ControllerRoundRelayRecord>(
692
+ database, NAMESPACE, options.repoId, successor.workId,
693
+ );
694
+ if (existingSuccessorRelay) {
695
+ if (existingSuccessorRelay.value.relayScopeId === current.value.relayScopeId
696
+ && ['dispatching', 'dispatched', 'claimed'].includes(existingSuccessorRelay.value.status)) {
697
+ return existingSuccessorRelay.value;
698
+ }
699
+ throw new Error(`CONTROLLER_RELAY_SUCCESSOR_ALREADY_HAS_ROUND: ${successor.workId}`);
700
+ }
701
+ const decision = atomicTransitionDecisionOrThrow(decideControllerRoundTransition(predecessorRelay.value, {
702
+ type: 'failed_dispatch_successor_handoff',
703
+ at,
704
+ successorWorkId: successor.workId,
705
+ successorStateFingerprint,
706
+ proposedAuthorityId: newControllerRoundAuthorityId(),
707
+ }));
708
+ writeControlPlaneRecordWithinTransaction(database, {
709
+ namespace: NAMESPACE, scope: options.repoId, key: predecessor.workId, schemaVersion: SCHEMA_VERSION,
710
+ value: decision.next, action: decision.action, expectedRevision: predecessorRelay.revision,
711
+ });
712
+ writeControlPlaneRecordWithinTransaction(database, {
713
+ namespace: NAMESPACE, scope: options.repoId, key: decision.relatedWorkId, schemaVersion: SCHEMA_VERSION,
714
+ value: decision.relatedNext, action: decision.relatedAction, expectedRevision: null,
715
+ });
716
+ return decision.relatedNext;
717
+ });
718
+ }
490
719
  return applyControllerRoundTransition(options, current, {
491
720
  type: 'successor_bound', at: nowIso(options), successorWorkId: successor.workId,
492
721
  });
@@ -518,13 +747,21 @@ function validatedAssistantContextUsage(
518
747
  for (const item of usage) {
519
748
  const key = `${item.kind}:${item.itemId}`;
520
749
  if (!expected.has(key) || seen.has(key) || !['used', 'rejected'].includes(item.decision)
521
- || typeof item.reason !== 'string' || !item.reason.trim() || item.reason.length > 1_000) {
750
+ || typeof item.reason !== 'string' || !item.reason.trim() || item.reason.length > 1_000
751
+ || (item.rejectionKind !== undefined && !['irrelevant', 'stale', 'contradicted'].includes(item.rejectionKind))
752
+ || (item.decision === 'used' && item.rejectionKind !== undefined)) {
522
753
  throw new Error('CONTROLLER_ASSISTANT_CONTEXT_USAGE_INVALID');
523
754
  }
524
755
  seen.add(key);
525
756
  }
526
757
  if (seen.size !== expected.size) throw new Error('CONTROLLER_ASSISTANT_CONTEXT_USAGE_INCOMPLETE');
527
- return usage.map((item) => ({ ...item, reason: item.reason.trim() }));
758
+ return usage.map((item) => ({
759
+ ...item,
760
+ reason: item.reason.trim(),
761
+ // Missing rejectionKind is accepted only for frozen clients and means ordinary
762
+ // irrelevance. Forge never derives stale/contradicted semantics from prose.
763
+ ...(item.decision === 'rejected' ? { rejectionKind: item.rejectionKind ?? 'irrelevant' as const } : {}),
764
+ }));
528
765
  }
529
766
 
530
767
  function persistClaimedAssistantContextSnapshot(
@@ -603,7 +840,7 @@ export function submitControllerRoundDisposition(
603
840
  if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
604
841
  const requirementId = resolveRequirementId(options, work, input.requirementId);
605
842
  const relayScopeId = resolveRelayScope(work, requirementId, input.relayScopeId);
606
- const terminal = isTerminalWorkContractStatus(work.status);
843
+ const terminal = semanticWorkState(work) !== 'open';
607
844
 
608
845
  return relayLock(options, relayScopeId, `controller-relay-submit:${input.identity.controllerId}`, () => {
609
846
  const existing = readRelayRecord(options, work.workId);
@@ -611,20 +848,30 @@ export function submitControllerRoundDisposition(
611
848
  if (existing.value.relayScopeId !== relayScopeId) {
612
849
  throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: Work ${work.workId} is already bound to ${existing.value.relayScopeId}`);
613
850
  }
614
- if (existing.value.status !== 'claimed') {
851
+ const terminalAuthorityId = bounded(input.terminalAuthorityId, 256);
852
+ const providerWaitTerminalGoalComplete = terminal
853
+ && semanticWorkState(work) === 'completed'
854
+ && input.disposition === 'goal_complete'
855
+ && existing.value.status === 'waiting_for_user'
856
+ && controllerRoundBlockerClass(existing.value) === 'provider_user_action_required';
857
+ if (providerWaitTerminalGoalComplete) {
858
+ if (!terminalAuthorityId || terminalAuthorityId !== existing.value.authorityId) {
859
+ throw new Error(`CONTROLLER_RELAY_TERMINAL_AUTHORITY_MISMATCH: ${work.workId}`);
860
+ }
861
+ } else if (existing.value.status !== 'claimed') {
615
862
  throw new Error(`CONTROLLER_RELAY_ROUND_NOT_CLAIMED: ${existing.value.status}`);
616
863
  }
617
864
  const terminalSuccessor = terminal
618
- && work.status === 'completed'
865
+ && semanticWorkState(work) === 'completed'
619
866
  && input.disposition === 'continue_immediately'
620
867
  && existing.value.successorWorkId
621
868
  ? assertControllerRoundSuccessorLineage(options, work, existing.value.successorWorkId)
622
869
  : undefined;
623
870
  const terminalRoundClosureAllowed = terminal
624
- && work.status === 'completed'
871
+ && semanticWorkState(work) === 'completed'
625
872
  && (input.disposition === 'goal_complete' || Boolean(terminalSuccessor));
626
873
  if (terminal && !terminalRoundClosureAllowed) {
627
- throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${work.status}`);
874
+ throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${semanticWorkState(work)}`);
628
875
  }
629
876
 
630
877
  const liveOwner = getControllerSession(options, work.workId);
@@ -650,7 +897,7 @@ export function submitControllerRoundDisposition(
650
897
  }
651
898
  if (existing.value.controllerId !== input.identity.controllerId) throw new Error(`CONTROLLER_RELAY_CLAIM_CONTROLLER_MISMATCH: ${work.workId}`);
652
899
  if (existing.value.principalId !== principalId) throw new Error(`CONTROLLER_RELAY_CLAIM_PRINCIPAL_MISMATCH: ${work.workId}`);
653
- if (existing.value.claimGeneration < 1) throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${work.workId}`);
900
+ if (existing.value.claimGeneration < 1 && !providerWaitTerminalGoalComplete) throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${work.workId}`);
654
901
  return {
655
902
  controllerId: existing.value.controllerId,
656
903
  controllerType: expectedControllerType,
@@ -730,9 +977,10 @@ export function submitControllerRoundDisposition(
730
977
  const requestedMaxRounds = boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32);
731
978
  const requestedMaxRepeatedState = boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8);
732
979
  const requestedMaxFailures = boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8);
733
- const stateFingerprint = bounded(input.stateFingerprint, 256) ?? mechanicalStateFingerprint(options, terminalSuccessor ?? work, requirementId, relayScopeId);
734
- const bindingId = bounded(input.bindingId, 500) ?? existing.value.bindingId;
735
980
  const handoffId = bounded(input.handoffId, 200);
981
+ const stateFingerprint = bounded(input.stateFingerprint, 256)
982
+ ?? mechanicalStateFingerprint(options, terminalSuccessor ?? work, requirementId, relayScopeId, handoffId ?? existing.value.handoffId);
983
+ const bindingId = bounded(input.bindingId, 500) ?? existing.value.bindingId;
736
984
  if (input.disposition === 'wait_for_user') {
737
985
  if (!handoffId) throw new Error('CONTROLLER_RELAY_WAIT_FOR_USER_HANDOFF_REQUIRED');
738
986
  const handoff = getHandoffItem(options, handoffId);
@@ -750,6 +998,7 @@ export function submitControllerRoundDisposition(
750
998
  type: 'semantic_disposition_submitted', at, disposition: input.disposition, stateFingerprint,
751
999
  maxRounds: requestedMaxRounds, maxRepeatedState: requestedMaxRepeatedState, maxFailures: requestedMaxFailures,
752
1000
  controllerSession: authority,
1001
+ ...(providerWaitTerminalGoalComplete ? { terminalGoalComplete: true } : {}),
753
1002
  qualityDecisions: accumulatedQualityDecisions, qualityAdjustmentResults: accumulatedQualityAdjustmentResults, observationWindow,
754
1003
  ...(handoffId ? { handoffId } : {}),
755
1004
  ...(bounded(input.reason, 1_000) ? { reason: bounded(input.reason, 1_000) } : {}),
@@ -758,35 +1007,94 @@ export function submitControllerRoundDisposition(
758
1007
  });
759
1008
  }
760
1009
 
1010
+ /**
1011
+ * Observe that the exact provider Controller turn has settled. If the Work is
1012
+ * still nonterminal and the Controller did not explicitly close the round,
1013
+ * canonical Kernel policy creates the continuation obligation. Provider
1014
+ * adapters supply only completion evidence; they do not choose the disposition.
1015
+ */
1016
+ export function settleControllerRoundAfterTurn(
1017
+ options: ControllerRoundRelayStoreOptions,
1018
+ input: { workId: string; completionEvidenceId: string },
1019
+ ): ControllerRoundRelayRecord | undefined {
1020
+ const initial = readRelayRecord(options, input.workId);
1021
+ if (!initial) return undefined;
1022
+ return relayLock(options, initial.value.relayScopeId, `controller-turn-settled:${input.workId}`, () => {
1023
+ const current = readRelayRecord(options, input.workId);
1024
+ if (!current) return undefined;
1025
+ const completionEvidenceId = bounded(input.completionEvidenceId, 500);
1026
+ if (!completionEvidenceId) throw new Error('CONTROLLER_RELAY_TURN_COMPLETION_EVIDENCE_REQUIRED');
1027
+ const work = getWorkContract(options, input.workId);
1028
+ if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
1029
+ const at = nowIso(options);
1030
+ const successor = semanticWorkState(work) === 'completed' && current.value.successorWorkId
1031
+ ? getWorkContract(options, current.value.successorWorkId)
1032
+ : undefined;
1033
+ const terminalSuccessorContinuation = Boolean(successor && semanticWorkState(successor) === 'open');
1034
+ if (semanticWorkState(work) !== 'open' && !terminalSuccessorContinuation) {
1035
+ return applyControllerRoundTransition(options, current, {
1036
+ type: 'terminal_work_observed', at, error: `Controller turn settled after terminal Work ${semanticWorkState(work)}`,
1037
+ });
1038
+ }
1039
+ const semanticWork = successor ?? work;
1040
+ const blockingHandoff = relevantHandoffs(options, relevantWork(options, current.value), current.value.handoffId)
1041
+ .find((handoff) => Boolean(handoff.blockingDecision?.trim())
1042
+ && (handoff.workId === semanticWork.workId || handoff.id === current.value.handoffId));
1043
+ const stateFingerprint = mechanicalStateFingerprint(options, semanticWork, current.value.requirementId, current.value.relayScopeId, current.value.handoffId);
1044
+ return applyControllerRoundTransition(options, current, {
1045
+ type: 'controller_turn_settled', at, stateFingerprint, completionEvidenceId,
1046
+ ...(blockingHandoff ? { blockingHandoffId: blockingHandoff.id } : {}),
1047
+ });
1048
+ });
1049
+ }
1050
+
761
1051
  export function beginInitialControllerRoundDispatch(
762
1052
  options: ControllerRoundRelayStoreOptions,
763
1053
  input: BeginInitialControllerRoundDispatchInput,
764
1054
  ): ControllerRoundRelayRecord {
765
1055
  const work = getWorkContract(options, input.workId);
766
1056
  if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
767
- if (isTerminalWorkContractStatus(work.status)) throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${work.status}`);
1057
+ if (semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${semanticWorkState(work)}`);
768
1058
  const requirementId = resolveRequirementId(options, work, input.requirementId);
769
1059
  const requirement = requirementForRelay(options, requirementId);
770
1060
  if (requirement && !['planned', 'active'].includes(requirement.state)) {
771
1061
  throw new Error(`CONTROLLER_RELAY_REQUIREMENT_TERMINAL: ${requirement.state}`);
772
1062
  }
773
1063
  const relayScopeId = resolveRelayScope(work, requirementId, input.relayScopeId);
1064
+ const occurrenceId = bounded(input.occurrenceId, 500);
774
1065
 
775
1066
  return relayLock(options, relayScopeId, `controller-relay-launch:${input.identity.controllerId}`, () => {
776
1067
  const existing = readRelayRecord(options, work.workId);
777
1068
  if (existing && existing.value.relayScopeId !== relayScopeId) {
778
1069
  throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: Work ${work.workId} is already bound to ${existing.value.relayScopeId}`);
779
1070
  }
1071
+ if (existing) {
1072
+ const requestedControllerId = input.identity.controllerId.trim().slice(0, 240) || 'controller-host';
1073
+ const requestedPrincipalId = input.identity.principalId.trim().slice(0, 240) || requestedControllerId;
1074
+ const reusableUnsubmittedRelay = existing.value.originWorkId === work.workId
1075
+ && ['pending_release', 'dispatching'].includes(existing.value.status)
1076
+ && existing.value.controllerId === requestedControllerId
1077
+ && relayControllerType(existing.value) === input.identity.controllerType
1078
+ && existing.value.principalId === requestedPrincipalId
1079
+ && Boolean(existing.value.authorityId?.trim())
1080
+ && !existing.value.providerDispatchEffectId
1081
+ && !existing.value.providerDispatchStartedAt
1082
+ && !existing.value.providerDispatchReceiptId
1083
+ && (existing.value.providerDispatchAttempt ?? 0) === 0
1084
+ && (existing.value.occurrenceId ?? '') === (occurrenceId ?? '');
1085
+ if (reusableUnsubmittedRelay) return existing.value;
1086
+ }
780
1087
  const previous = relayHistory(options, relayScopeId)[0];
781
1088
  const abandonedReleasedRound = previous?.status === 'failed' && previous.failureClass === 'abandoned_release';
782
1089
  const stateFingerprint = mechanicalStateFingerprint(options, work, requirementId, relayScopeId);
783
- const occurrenceId = bounded(input.occurrenceId, 500);
784
1090
  return applyControllerRoundTransition(options, existing, {
785
1091
  type: 'occurrence_requested', at: nowIso(options), repoId: options.repoId, relayScopeId, originWorkId: work.workId,
786
1092
  ...(requirementId ? { requirementId } : {}), identity: input.identity, stateFingerprint, proposedAuthorityId: newControllerRoundAuthorityId(),
787
1093
  maxRounds: boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32), maxRepeatedState: boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8),
788
1094
  maxFailures: boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8),
789
1095
  ...(bounded(input.bindingId, 500) ? { bindingId: bounded(input.bindingId, 500) } : {}), ...(occurrenceId ? { occurrenceId } : {}),
1096
+ ...(input.allowSemanticWaitRecovery ? { allowSemanticWaitRecovery: true } : {}),
1097
+ ...(input.authorizeRoundBudgetOccurrence ? { authorizeRoundBudgetOccurrence: true } : {}),
790
1098
  abandonedReleaseRecovery: Boolean(abandonedReleasedRound),
791
1099
  });
792
1100
  });
@@ -813,11 +1121,11 @@ export function beginControllerRoundRelayAfterRelease(
813
1121
  const at = nowIso(options);
814
1122
  if (record.successorWorkId) {
815
1123
  const predecessor = getWorkContract(options, input.workId);
816
- if (!predecessor || predecessor.status !== 'completed') {
817
- throw new Error(`CONTROLLER_RELAY_SUCCESSOR_PREDECESSOR_NOT_COMPLETED: ${input.workId}:${predecessor?.status ?? 'missing'}`);
1124
+ if (!predecessor || semanticWorkState(predecessor) !== 'completed') {
1125
+ throw new Error(`CONTROLLER_RELAY_SUCCESSOR_PREDECESSOR_NOT_COMPLETED: ${input.workId}:${predecessor ? semanticWorkState(predecessor) : 'missing'}`);
818
1126
  }
819
1127
  const successor = assertControllerRoundSuccessorLineage(options, predecessor, record.successorWorkId);
820
- const successorStateFingerprint = mechanicalStateFingerprint(options, successor, record.requirementId, record.relayScopeId);
1128
+ const successorStateFingerprint = mechanicalStateFingerprint(options, successor, record.requirementId, record.relayScopeId, record.handoffId);
821
1129
  return withControlPlaneTransaction(options.controllerHome, (database) => {
822
1130
  const predecessorRelay = readControlPlaneRecordWithinTransaction<ControllerRoundRelayRecord>(
823
1131
  database, NAMESPACE, options.repoId, input.workId,
@@ -885,10 +1193,10 @@ export function reconcileControllerRoundAfterAbandonedRelease(
885
1193
  }
886
1194
  const work = getWorkContract(options, input.workId);
887
1195
  // Completed Work still requires semantic goal_complete or a valid terminal
888
- // successor. Failed/cancelled Work has no legal semantic continuation, so
1196
+ // successor. Cancelled Work has no legal semantic continuation, so
889
1197
  // its exact released controller epoch may mechanically abandon the claimed
890
1198
  // round and free the relay scope for later recovery/replanning.
891
- if (!work || work.status === 'completed') return undefined;
1199
+ if (!work || semanticWorkState(work) === 'completed') return undefined;
892
1200
 
893
1201
  return applyControllerRoundTransition(options, current, {
894
1202
  type: 'abandoned_release_observed', at: nowIso(options), error: CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR,
@@ -898,7 +1206,7 @@ export function reconcileControllerRoundAfterAbandonedRelease(
898
1206
 
899
1207
  /**
900
1208
  * Mechanically retire any surviving ControllerRound once Work lifecycle authority
901
- * is durably failed/cancelled and no Controller lease remains. This is cleanup,
1209
+ * is durably cancelled and no Controller lease remains. This is cleanup,
902
1210
  * not a semantic disposition: completed Work is intentionally excluded.
903
1211
  */
904
1212
  export function reconcileControllerRoundAfterTerminalWork(
@@ -908,7 +1216,7 @@ export function reconcileControllerRoundAfterTerminalWork(
908
1216
  const initial = readRelayRecord(options, input.workId);
909
1217
  if (!initial) return undefined;
910
1218
  const work = getWorkContract(options, input.workId);
911
- if (!work || !['failed', 'cancelled'].includes(work.status)) return undefined;
1219
+ if (!work || semanticWorkState(work) !== 'cancelled') return undefined;
912
1220
  if (getControllerSession(options, input.workId)) {
913
1221
  throw new Error(`CONTROLLER_RELAY_TERMINAL_WORK_ACTIVE_CLAIM: ${input.workId}`);
914
1222
  }
@@ -917,7 +1225,7 @@ export function reconcileControllerRoundAfterTerminalWork(
917
1225
  const current = readRelayRecord(options, input.workId);
918
1226
  if (!current) return undefined;
919
1227
  const currentWork = getWorkContract(options, input.workId);
920
- if (!currentWork || !['failed', 'cancelled'].includes(currentWork.status)) return undefined;
1228
+ if (!currentWork || semanticWorkState(currentWork) !== 'cancelled') return undefined;
921
1229
  if (getControllerSession(options, input.workId)) {
922
1230
  throw new Error(`CONTROLLER_RELAY_TERMINAL_WORK_ACTIVE_CLAIM: ${input.workId}`);
923
1231
  }
@@ -928,15 +1236,47 @@ export function reconcileControllerRoundAfterTerminalWork(
928
1236
  });
929
1237
  }
930
1238
 
1239
+ export function beginControllerRoundProviderDispatch(
1240
+ options: ControllerRoundRelayStoreOptions,
1241
+ input: { workId: string; authorityId: string; expectedUpdatedAt: string; bindingId?: string },
1242
+ ): ControllerRoundRelayRecord {
1243
+ const initial = readRelayRecord(options, input.workId);
1244
+ if (!initial) throw new Error(`CONTROLLER_RELAY_PROVIDER_DISPATCH_RELAY_REQUIRED: ${input.workId}`);
1245
+ return relayLock(options, initial.value.relayScopeId, `controller-provider-dispatch-start:${input.workId}`, () => {
1246
+ const current = readRelayRecord(options, input.workId);
1247
+ if (!current) throw new Error(`CONTROLLER_RELAY_PROVIDER_DISPATCH_RELAY_REQUIRED: ${input.workId}`);
1248
+ if (current.value.status !== 'dispatching') throw new Error(`CONTROLLER_RELAY_DISPATCH_STATE_INVALID:${current.value.status}`);
1249
+ if (current.value.authorityId !== input.authorityId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_DISPATCH_AUTHORITY_MISMATCH: ${input.workId}`);
1250
+ if (current.value.updatedAt !== input.expectedUpdatedAt.trim()) {
1251
+ if (current.value.providerDispatchStartedAt) throw new Error(`CONTROLLER_CONTINUATION_ALREADY_DISPATCHING:${input.workId}`);
1252
+ throw new Error(`CONTROLLER_RELAY_PROVIDER_DISPATCH_STALE: ${input.workId}`);
1253
+ }
1254
+ if (current.value.providerDispatchStartedAt) throw new Error(`CONTROLLER_CONTINUATION_ALREADY_DISPATCHING:${input.workId}`);
1255
+ const providerDispatchEffectId = current.value.providerDispatchEffectId ?? controllerRoundProviderEffectId(current.value);
1256
+ return applyControllerRoundTransition(options, current, {
1257
+ type: 'provider_dispatch_started', at: nowIso(options), providerDispatchEffectId,
1258
+ ...(bounded(input.bindingId, 500) ? { bindingId: bounded(input.bindingId, 500) } : {}),
1259
+ });
1260
+ });
1261
+ }
1262
+
931
1263
  export function finishControllerRoundRelayDispatch(
932
1264
  options: ControllerRoundRelayStoreOptions,
933
1265
  input: { workId: string; ok: boolean; bindingId?: string; providerDispatchEffectId?: string; providerDispatchReceiptId?: string; error?: string; recovery?: boolean; outcomeUnknown?: boolean; waitForUser?: boolean; handoffId?: string; nowMs?: number },
934
1266
  ): ControllerRoundRelayRecord | undefined {
935
1267
  const initial = readRelayRecord(options, input.workId);
936
- if (!initial || initial.value.status !== 'dispatching') return initial?.value;
1268
+ const initialCanFinish = Boolean(initial && (
1269
+ initial.value.status === 'dispatching'
1270
+ || (input.ok && initial.value.status === 'blocked' && controllerRoundBlockerClass(initial.value) === 'provider_dispatch_outcome_unknown')
1271
+ ));
1272
+ if (!initial || !initialCanFinish) return initial?.value;
937
1273
  return relayLock(options, initial.value.relayScopeId, `controller-relay-finish:${input.workId}`, () => {
938
1274
  const current = readRelayRecord(options, input.workId);
939
- if (!current || current.value.status !== 'dispatching') return current?.value;
1275
+ const currentCanFinish = Boolean(current && (
1276
+ current.value.status === 'dispatching'
1277
+ || (input.ok && current.value.status === 'blocked' && controllerRoundBlockerClass(current.value) === 'provider_dispatch_outcome_unknown')
1278
+ ));
1279
+ if (!current || !currentCanFinish) return current?.value;
940
1280
  const at = typeof input.nowMs === 'number' ? new Date(input.nowMs).toISOString() : nowIso(options);
941
1281
  const handoffId = bounded(input.handoffId, 200);
942
1282
  if (input.waitForUser) {
@@ -954,7 +1294,8 @@ export function finishControllerRoundRelayDispatch(
954
1294
  ? 'CONTROLLER_RELAY_RECOVERY_FAILED'
955
1295
  : 'CONTROLLER_RELAY_DISPATCH_FAILED');
956
1296
  const providerDispatchEffectId = bounded(input.providerDispatchEffectId, 500)
957
- ?? (current.value.authorityId ? controllerRoundProviderEffectId(current.value.relayScopeId, current.value.authorityId) : undefined);
1297
+ ?? current.value.providerDispatchEffectId
1298
+ ?? controllerRoundProviderEffectId(current.value);
958
1299
  let event: ControllerRoundTransitionEvent;
959
1300
  if (input.ok) {
960
1301
  if (!providerDispatchEffectId) throw new Error('CONTROLLER_RELAY_PROVIDER_EFFECT_ID_REQUIRED');
@@ -974,6 +1315,92 @@ export function finishControllerRoundRelayDispatch(
974
1315
  });
975
1316
  }
976
1317
 
1318
+ export interface ClaimControllerRoundSessionInput {
1319
+ workId: string;
1320
+ relayWorkId: string;
1321
+ sessionClaim: ControllerSessionClaimInput & { principalId: string; controllerInstanceId: string };
1322
+ assistantContextSnapshot?: AssistantContextSnapshot | null;
1323
+ allowUserResume?: boolean;
1324
+ }
1325
+
1326
+ /**
1327
+ * Atomically claim one relay-bound Work. Relay claimability is rechecked inside
1328
+ * the same BEGIN IMMEDIATE transaction that persists ControllerSession ownership.
1329
+ * A Requirement-scope inherited relay is fenced but not rewritten for a sibling Work.
1330
+ */
1331
+ export function claimControllerRoundSession(
1332
+ options: ControllerRoundRelayStoreOptions,
1333
+ input: ClaimControllerRoundSessionInput,
1334
+ ): { session: ClaimedControllerSession; relay?: ControllerRoundRelayRecord } {
1335
+ const relayWorkId = input.relayWorkId.trim();
1336
+ const initial = readRelayRecord(options, relayWorkId);
1337
+ if (!initial) throw new Error(`CONTROLLER_RELAY_ROUND_NOT_OPEN: ${relayWorkId}`);
1338
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-session-claim:${input.sessionClaim.controllerId}`, () => {
1339
+ const lockedRelay = readRelayRecord(options, relayWorkId);
1340
+ if (!lockedRelay || !controllerRoundRelayClaimable(lockedRelay.value, { allowUserResume: input.allowUserResume })) {
1341
+ throw new Error(`CONTROLLER_RELAY_CLAIM_STATE_INVALID:${lockedRelay?.value.status ?? 'missing'}`);
1342
+ }
1343
+ const repeatedStateFingerprint = controllerRoundBlockerClass(lockedRelay.value) === 'repeated_state'
1344
+ ? (() => {
1345
+ const work = getWorkContract(options, input.workId);
1346
+ if (!work) throw new Error(`WORK_CONTROLLER_CONTEXT_MISSING: ${input.workId}`);
1347
+ return mechanicalStateFingerprint(options, work, lockedRelay.value.requirementId, lockedRelay.value.relayScopeId, lockedRelay.value.handoffId);
1348
+ })()
1349
+ : undefined;
1350
+ return withControllerSessionMutationLock(options, input.workId, `controller-relay-session-claim:${input.sessionClaim.controllerId}`, () => {
1351
+ assertControllerSessionWorkClaimable(options, input.workId);
1352
+ return withControlPlaneTransaction(options.controllerHome, (database) => {
1353
+ const current = readControlPlaneRecordWithinTransaction<ControllerRoundRelayRecord>(
1354
+ database, NAMESPACE, options.repoId, relayWorkId,
1355
+ );
1356
+ if (!current || !controllerRoundRelayClaimable(current.value, { allowUserResume: input.allowUserResume })) {
1357
+ throw new Error(`CONTROLLER_RELAY_CLAIM_STATE_INVALID:${current?.value.status ?? 'missing'}`);
1358
+ }
1359
+ if (input.sessionClaim.controllerType !== relayControllerType(current.value)) {
1360
+ throw new Error(`CONTROLLER_RELAY_CONTROLLER_TYPE_MISMATCH: ${input.workId}`);
1361
+ }
1362
+ const session = resumeControllerSessionWithinTransaction(database, options, input.sessionClaim);
1363
+ if (relayWorkId !== input.workId) return { session };
1364
+
1365
+ const at = nowIso(options);
1366
+ const blocker = controllerRoundBlockerClass(current.value);
1367
+ const event: ControllerRoundTransitionEvent = blocker === 'repeated_state'
1368
+ ? {
1369
+ type: 'semantic_state_changed', at,
1370
+ stateFingerprint: repeatedStateFingerprint!, session,
1371
+ principalId: input.sessionClaim.principalId,
1372
+ controllerInstanceId: input.sessionClaim.controllerInstanceId,
1373
+ }
1374
+ : {
1375
+ type: 'controller_claim_observed', at, session,
1376
+ principalId: input.sessionClaim.principalId,
1377
+ controllerInstanceId: input.sessionClaim.controllerInstanceId,
1378
+ ...(input.allowUserResume ? { userResume: true } : {}),
1379
+ };
1380
+ const decided = transitionDecisionOrThrow(decideControllerRoundTransition(current.value, event));
1381
+ let next = decided.record;
1382
+ const requested = input.assistantContextSnapshot;
1383
+ if (requested !== undefined && !sameAssistantContextSnapshot(next.assistantContextSnapshot, requested)) {
1384
+ if (next.assistantContextSnapshot) throw new Error(`CONTROLLER_ASSISTANT_CONTEXT_CLAIM_MISMATCH: ${input.workId}`);
1385
+ if (requested !== null) next = { ...next, assistantContextSnapshot: requested, updatedAt: at };
1386
+ }
1387
+ if (decided.changed || next !== decided.record) {
1388
+ writeControlPlaneRecordWithinTransaction(database, {
1389
+ namespace: NAMESPACE,
1390
+ scope: options.repoId,
1391
+ key: relayWorkId,
1392
+ schemaVersion: SCHEMA_VERSION,
1393
+ value: next,
1394
+ action: decided.action ?? 'controller_round_relay_claim_acknowledged',
1395
+ expectedRevision: current.revision,
1396
+ });
1397
+ }
1398
+ return { session, relay: next };
1399
+ });
1400
+ });
1401
+ });
1402
+ }
1403
+
977
1404
  export function acknowledgeControllerRoundClaim(
978
1405
  options: ControllerRoundRelayStoreOptions,
979
1406
  input: { workId: string; session: ControllerSession; assistantContextSnapshot?: AssistantContextSnapshot | null },
@@ -993,10 +1420,7 @@ export function acknowledgeControllerRoundClaim(
993
1420
  && sameAssistantContextSnapshot(current.value.assistantContextSnapshot, input.assistantContextSnapshot)) return current.value;
994
1421
 
995
1422
  const blocker = controllerRoundBlockerClass(current.value);
996
- const policyClaimable = ['dispatching', 'dispatched', 'claimed'].includes(current.value.status)
997
- || blocker === 'provider_dispatch_outcome_unknown'
998
- || blocker === 'repeated_state';
999
- if (!policyClaimable) return current.value;
1423
+ if (!controllerRoundRelayClaimable(current.value)) return current.value;
1000
1424
 
1001
1425
  const owner = getControllerSession(options, input.workId);
1002
1426
  const ownerPrincipal = owner?.principalId?.trim() || owner?.controllerId;
@@ -1024,8 +1448,8 @@ export function acknowledgeControllerRoundClaim(
1024
1448
 
1025
1449
  if (blocker === 'repeated_state') {
1026
1450
  const work = getWorkContract(options, input.workId);
1027
- if (!work || isTerminalWorkContractStatus(work.status)) return current.value;
1028
- const stateFingerprint = mechanicalStateFingerprint(options, work, current.value.requirementId, current.value.relayScopeId);
1451
+ if (!work || semanticWorkState(work) !== 'open') return current.value;
1452
+ const stateFingerprint = mechanicalStateFingerprint(options, work, current.value.requirementId, current.value.relayScopeId, current.value.handoffId);
1029
1453
  const transitioned = applyControllerRoundTransition(options, current, {
1030
1454
  type: 'semantic_state_changed', at, stateFingerprint, session, principalId: ownerPrincipal!, controllerInstanceId,
1031
1455
  });
@@ -1058,8 +1482,8 @@ export function recoverControllerRoundRelayAuthority(
1058
1482
  }
1059
1483
  const work = getWorkContract(options, workId);
1060
1484
  if (!work) throw new Error(`WORK_NOT_FOUND: ${workId}`);
1061
- if (isTerminalWorkContractStatus(work.status)) {
1062
- throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_TERMINAL: ${workId}:${work.status}`);
1485
+ if (semanticWorkState(work) !== 'open') {
1486
+ throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_TERMINAL: ${workId}:${semanticWorkState(work)}`);
1063
1487
  }
1064
1488
  const initial = readRelayRecord(options, workId);
1065
1489
  if (!initial) throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_REQUIRED: ${workId}`);
@@ -1070,8 +1494,14 @@ export function recoverControllerRoundRelayAuthority(
1070
1494
  if (initial.value.controllerId !== input.identity.controllerId || initial.value.principalId !== input.identity.principalId) {
1071
1495
  throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_PRINCIPAL_MISMATCH: ${workId}`);
1072
1496
  }
1073
- const recoverableStatuses: readonly ControllerRoundRelayStatus[] = ['pending_release', 'dispatching', 'dispatched', 'claimed', 'failed'];
1074
- if (!recoverableStatuses.includes(initial.value.status)) {
1497
+ const recoverableStatuses: readonly ControllerRoundRelayStatus[] = ['pending_release', 'dispatching', 'dispatched', 'claimed', 'waiting_for_user', 'failed'];
1498
+ const initialRepeatedStateBlock = initial.value.status === 'blocked' && initial.value.blockedReason?.startsWith('repeated_state:');
1499
+ const initialConsecutiveFailureBlock = controllerRoundBlockerClass(initial.value) === 'consecutive_failures';
1500
+ const initialRecoveryReason = input.recoveryReason?.trim() ?? '';
1501
+ if (initialRepeatedStateBlock && !initialRecoveryReason) {
1502
+ throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_REASON_REQUIRED: ${workId}; repeated-state recovery must state why the bounded recovery is being opened.`);
1503
+ }
1504
+ if (!recoverableStatuses.includes(initial.value.status) && !initialRepeatedStateBlock && !initialConsecutiveFailureBlock) {
1075
1505
  throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_STATE_INVALID: ${workId}:${initial.value.status}`);
1076
1506
  }
1077
1507
 
@@ -1082,7 +1512,12 @@ export function recoverControllerRoundRelayAuthority(
1082
1512
  if (!latest || latest.originWorkId !== workId || latest.updatedAt !== current.value.updatedAt) {
1083
1513
  throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_NOT_CURRENT: ${workId}`);
1084
1514
  }
1085
- if (!recoverableStatuses.includes(current.value.status)) {
1515
+ const currentRepeatedStateBlock = current.value.status === 'blocked' && current.value.blockedReason?.startsWith('repeated_state:');
1516
+ const currentConsecutiveFailureBlock = controllerRoundBlockerClass(current.value) === 'consecutive_failures';
1517
+ if (currentRepeatedStateBlock && !initialRecoveryReason) {
1518
+ throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_REASON_REQUIRED: ${workId}; repeated-state recovery must state why the bounded recovery is being opened.`);
1519
+ }
1520
+ if (!recoverableStatuses.includes(current.value.status) && !currentRepeatedStateBlock && !currentConsecutiveFailureBlock) {
1086
1521
  throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_RELAY_STATE_INVALID: ${workId}:${current.value.status}`);
1087
1522
  }
1088
1523
  if (relayControllerType(current.value) !== input.identity.controllerType
@@ -1092,20 +1527,86 @@ export function recoverControllerRoundRelayAuthority(
1092
1527
  }
1093
1528
  const currentWork = getWorkContract(options, workId);
1094
1529
  if (!currentWork) throw new Error(`WORK_NOT_FOUND: ${workId}`);
1095
- if (isTerminalWorkContractStatus(currentWork.status)) {
1096
- throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_TERMINAL: ${workId}:${currentWork.status}`);
1530
+ if (semanticWorkState(currentWork) !== 'open') {
1531
+ throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_TERMINAL: ${workId}:${semanticWorkState(currentWork)}`);
1097
1532
  }
1098
- const activeWorks = relevantWork(options, current.value).filter((entry) => !isTerminalWorkContractStatus(entry.status));
1533
+ const activeWorks = recoveryFenceWork(options, current.value).filter((entry) => semanticWorkState(entry) === 'open');
1099
1534
  if (activeWorks.some((entry) => workHasActiveExecution(options.controllerHome, options.repoId, entry.workId))) {
1100
1535
  throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_EXECUTION: ${workId}`);
1101
1536
  }
1102
- if (activeWorks.some((entry) => Boolean(getControllerSession(options, entry.workId)))) {
1537
+ const claimedWorks = activeWorks
1538
+ .map((entry) => ({ work: entry, owner: getControllerSession(options, entry.workId) }))
1539
+ .filter((entry): entry is typeof entry & { owner: NonNullable<typeof entry.owner> } => Boolean(entry.owner));
1540
+ if (claimedWorks.some((entry) => entry.work.workId !== workId)) {
1541
+ throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_CLAIM: ${workId}`);
1542
+ }
1543
+ const currentOwner = claimedWorks.find((entry) => entry.work.workId === workId)?.owner;
1544
+ if (currentConsecutiveFailureBlock && currentOwner) {
1103
1545
  throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_CLAIM: ${workId}`);
1104
1546
  }
1547
+ if (currentOwner) {
1548
+ const ownerPrincipal = controllerSessionPrincipalId(currentOwner);
1549
+ const ownerInstanceId = currentOwner.controllerInstanceId?.trim() || '';
1550
+ const samePrincipalOwner = currentOwner.controllerId === input.identity.controllerId
1551
+ && currentOwner.controllerType === input.identity.controllerType
1552
+ && ownerPrincipal === input.identity.principalId;
1553
+ const sameRuntimeOwner = ownerInstanceId === input.identity.controllerInstanceId;
1554
+ const canonicalRuntimeMigration = input.allowCanonicalRuntimeMigration === true
1555
+ && ownerInstanceId !== input.identity.controllerInstanceId;
1556
+ if (!samePrincipalOwner || (!sameRuntimeOwner && !canonicalRuntimeMigration)) {
1557
+ throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_CLAIM: ${workId}`);
1558
+ }
1559
+ const released = releaseObservedControllerSession(options, {
1560
+ workId,
1561
+ actor: `controller-relay-explicit-authority-recovery:${workId}`,
1562
+ owner: currentOwner,
1563
+ });
1564
+ if (!released.allowed) {
1565
+ throw new Error(`WORK_CONTROLLER_AUTHORITY_RECOVERY_ACTIVE_CLAIM: ${workId}:${released.reason}`);
1566
+ }
1567
+ }
1105
1568
 
1106
1569
  return applyControllerRoundTransition(options, current, {
1107
1570
  type: 'authority_recovery_requested', at: nowIso(options), proposedAuthorityId: newControllerRoundAuthorityId(),
1108
1571
  keepsConfirmedDispatch: current.value.status === 'dispatched',
1572
+ preserveBlockedState: currentConsecutiveFailureBlock,
1573
+ preserveWaitingForUserState: current.value.status === 'waiting_for_user',
1574
+ ...(initialRecoveryReason ? { reason: initialRecoveryReason } : {}),
1575
+ });
1576
+ });
1577
+ }
1578
+
1579
+ export interface RetryFailedControllerRoundProviderDispatchInput {
1580
+ workId: string;
1581
+ relayScopeId: string;
1582
+ authorityId: string;
1583
+ expectedUpdatedAt: string;
1584
+ occurrenceId?: string;
1585
+ }
1586
+
1587
+ /** Retry one known, non-ambiguous provider failure without creating a new semantic round or authority. */
1588
+ export function retryFailedControllerRoundProviderDispatch(
1589
+ options: ControllerRoundRelayStoreOptions,
1590
+ input: RetryFailedControllerRoundProviderDispatchInput,
1591
+ ): ControllerRoundRelayRecord {
1592
+ const workId = input.workId.trim();
1593
+ const initial = readRelayRecord(options, workId);
1594
+ if (!initial) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_RELAY_REQUIRED:${workId}`);
1595
+ if (initial.value.relayScopeId !== input.relayScopeId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_SCOPE_MISMATCH:${workId}`);
1596
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-provider-retry:${workId}`, () => {
1597
+ const current = readRelayRecord(options, workId);
1598
+ if (!current) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_RELAY_REQUIRED:${workId}`);
1599
+ if (current.value.updatedAt !== input.expectedUpdatedAt.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_STALE:${workId}`);
1600
+ if (current.value.relayScopeId !== input.relayScopeId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_SCOPE_MISMATCH:${workId}`);
1601
+ if ((current.value.authorityId?.trim() || '') !== input.authorityId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_AUTHORITY_MISMATCH:${workId}`);
1602
+ const work = getWorkContract(options, workId);
1603
+ if (!work || semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_WORK_TERMINAL:${workId}:${work ? semanticWorkState(work) : 'missing'}`);
1604
+ const requirement = requirementForRelay(options, current.value.requirementId);
1605
+ if (requirement && !['planned', 'active'].includes(requirement.state)) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_REQUIREMENT_TERMINAL:${requirement.state}`);
1606
+ if (workHasActiveExecution(options.controllerHome, options.repoId, workId)) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_ACTIVE_EXECUTION:${workId}`);
1607
+ if (getControllerSession(options, workId)) throw new Error(`CONTROLLER_RELAY_PROVIDER_RETRY_ACTIVE_CLAIM:${workId}`);
1608
+ return applyControllerRoundTransition(options, current, {
1609
+ type: 'provider_retry_requested', at: nowIso(options), occurrenceId: input.occurrenceId?.trim() || undefined,
1109
1610
  });
1110
1611
  });
1111
1612
  }
@@ -1127,6 +1628,51 @@ export interface BindLegacyControllerRoundOccurrenceInput {
1127
1628
  identity: Pick<ControllerRoundRelayIdentity, 'controllerId' | 'controllerType' | 'principalId'>;
1128
1629
  }
1129
1630
 
1631
+ export interface RearmControllerRoundAfterProviderUserActionInput {
1632
+ workId: string;
1633
+ handoffId: string;
1634
+ /** Compatibility fence for explicit callers; automatic Handoff resolution reads durable authority internally. */
1635
+ authorityId?: string;
1636
+ /** Compatibility CAS for explicit callers; automatic Handoff resolution validates current state under the relay lock. */
1637
+ expectedUpdatedAt?: string;
1638
+ /** Compatibility override only. Omit to preserve the existing semantic occurrence identity. */
1639
+ occurrenceId?: string;
1640
+ }
1641
+
1642
+ /** Rearm the same provider dispatch responsibility only after its exact user-action Handoff is resolved. */
1643
+ export function rearmControllerRoundAfterProviderUserAction(
1644
+ options: ControllerRoundRelayStoreOptions,
1645
+ input: RearmControllerRoundAfterProviderUserActionInput,
1646
+ ): ControllerRoundRelayRecord {
1647
+ const workId = input.workId.trim();
1648
+ const initial = readRelayRecord(options, workId);
1649
+ if (!initial) throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_RELAY_REQUIRED: ${workId}`);
1650
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-provider-user-action-resolved:${workId}`, () => {
1651
+ const current = readRelayRecord(options, workId);
1652
+ if (!current) throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_RELAY_REQUIRED: ${workId}`);
1653
+ if (input.expectedUpdatedAt?.trim() && current.value.updatedAt !== input.expectedUpdatedAt.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_STALE: ${workId}`);
1654
+ if (input.authorityId?.trim() && (current.value.authorityId?.trim() || '') !== input.authorityId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_AUTHORITY_MISMATCH: ${workId}`);
1655
+ if (controllerRoundBlockerClass(current.value) !== 'provider_user_action_required') throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_BLOCKER_MISMATCH: ${workId}`);
1656
+ const handoffId = bounded(input.handoffId, 200);
1657
+ if (!handoffId || current.value.handoffId !== handoffId) throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_HANDOFF_MISMATCH: ${workId}`);
1658
+ const handoff = getHandoffItem(options, handoffId);
1659
+ if (!handoff) throw new Error(`HANDOFF_NOT_FOUND: ${handoffId}`);
1660
+ if (handoff.workId && handoff.workId !== workId) throw new Error(`CONTROLLER_RELAY_HANDOFF_WORK_MISMATCH: ${handoffId}`);
1661
+ if (handoff.status !== 'resolved') throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_HANDOFF_NOT_RESOLVED: ${handoff.status}`);
1662
+ const requestedOccurrenceId = bounded(input.occurrenceId, 240);
1663
+ if (requestedOccurrenceId && current.value.occurrenceId && requestedOccurrenceId !== current.value.occurrenceId) {
1664
+ throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_OCCURRENCE_MISMATCH: ${workId}`);
1665
+ }
1666
+ const occurrenceId = requestedOccurrenceId ?? current.value.occurrenceId;
1667
+ if (!occurrenceId) throw new Error('CONTROLLER_RELAY_OCCURRENCE_ID_REQUIRED');
1668
+ const work = getWorkContract(options, workId);
1669
+ if (!work || semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_PROVIDER_USER_ACTION_WORK_TERMINAL: ${workId}:${work ? semanticWorkState(work) : 'missing'}`);
1670
+ return applyControllerRoundTransition(options, current, {
1671
+ type: 'provider_user_action_resolved', at: nowIso(options), handoffId, occurrenceId,
1672
+ });
1673
+ });
1674
+ }
1675
+
1130
1676
  /** Exact evidence-gated provider/environment recovery for one exhausted same-round dispatch responsibility. */
1131
1677
  export function rearmControllerRoundAfterProviderRecovery(
1132
1678
  options: ControllerRoundRelayStoreOptions,
@@ -1144,10 +1690,10 @@ export function rearmControllerRoundAfterProviderRecovery(
1144
1690
  if ((current.value.authorityId?.trim() || '') !== input.authorityId.trim()) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_AUTHORITY_MISMATCH: ${workId}`);
1145
1691
  if (controllerRoundBlockerClass(current.value) !== 'consecutive_failures') throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_BLOCKER_MISMATCH: ${workId}`);
1146
1692
  const work = getWorkContract(options, workId);
1147
- if (!work || isTerminalWorkContractStatus(work.status)) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_WORK_TERMINAL: ${workId}:${work?.status ?? 'missing'}`);
1693
+ if (!work || semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_WORK_TERMINAL: ${workId}:${work ? semanticWorkState(work) : 'missing'}`);
1148
1694
  const requirement = requirementForRelay(options, current.value.requirementId);
1149
1695
  if (requirement && !['planned', 'active'].includes(requirement.state)) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_REQUIREMENT_TERMINAL: ${requirement.state}`);
1150
- const activeWorks = relevantWork(options, current.value).filter((entry) => !isTerminalWorkContractStatus(entry.status));
1696
+ const activeWorks = recoveryFenceWork(options, current.value).filter((entry) => semanticWorkState(entry) === 'open');
1151
1697
  if (activeWorks.some((entry) => workHasActiveExecution(options.controllerHome, options.repoId, entry.workId))) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_ACTIVE_EXECUTION: ${workId}`);
1152
1698
  if (activeWorks.some((entry) => Boolean(getControllerSession(options, entry.workId)))) throw new Error(`CONTROLLER_RELAY_PROVIDER_RECOVERY_ACTIVE_CLAIM: ${workId}`);
1153
1699
  const evidenceId = bounded(input.evidenceId, 500);
@@ -1189,7 +1735,7 @@ export function bindLegacyControllerRoundOccurrence(
1189
1735
  throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_CONTROLLER_MISMATCH: ${workId}`);
1190
1736
  }
1191
1737
  const work = getWorkContract(options, workId);
1192
- if (!work || isTerminalWorkContractStatus(work.status)) throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_WORK_TERMINAL: ${workId}:${work?.status ?? 'missing'}`);
1738
+ if (!work || semanticWorkState(work) !== 'open') throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_WORK_TERMINAL: ${workId}:${work ? semanticWorkState(work) : 'missing'}`);
1193
1739
  const requirement = requirementForRelay(options, current.value.requirementId);
1194
1740
  if (requirement && !['planned', 'active'].includes(requirement.state)) throw new Error(`CONTROLLER_RELAY_LEGACY_OCCURRENCE_REQUIREMENT_TERMINAL: ${requirement.state}`);
1195
1741
  return applyControllerRoundTransition(options, current, { type: 'legacy_occurrence_bound', at: nowIso(options), occurrenceId });
@@ -1204,6 +1750,20 @@ export function claimStalledControllerRoundRelays(
1204
1750
  const graceMs = Math.max(60_000, Math.min(input.graceMs ?? DEFAULT_UNCLOSED_ROUND_GRACE_MS, MAX_UNCLOSED_ROUND_GRACE_MS));
1205
1751
  const limit = Math.max(1, Math.min(Math.trunc(input.limit ?? 2), 16));
1206
1752
  const claimed: ControllerRoundRelayRecord[] = [];
1753
+ // A recovery pass evaluates many relay candidates for the same repository.
1754
+ // Work contracts are immutable for this read phase, so share one snapshot
1755
+ // across candidates. The locked transition below still refreshes the Work
1756
+ // snapshot before deriving the durable recovery decision.
1757
+ let scanWorkContracts: readonly WorkContract[] | undefined;
1758
+ let scanActiveWorkSnapshot: ReturnType<typeof readActiveWorkCandidates> | undefined;
1759
+ const activeWorkSnapshotForScan = (): ReturnType<typeof readActiveWorkCandidates> => {
1760
+ scanActiveWorkSnapshot ??= readActiveWorkCandidates({ controllerHome: options.controllerHome, repoId: options.repoId, limit: 1_000 });
1761
+ return scanActiveWorkSnapshot;
1762
+ };
1763
+ const workSnapshotForScan = (): readonly WorkContract[] => {
1764
+ scanWorkContracts ??= readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts;
1765
+ return scanWorkContracts;
1766
+ };
1207
1767
 
1208
1768
  for (const candidate of latestRelayRecordsByScope(options)) {
1209
1769
  if (claimed.length >= limit) break;
@@ -1219,14 +1779,15 @@ export function claimStalledControllerRoundRelays(
1219
1779
  }
1220
1780
  const requirement = requirementForRelay(options, candidate.requirementId);
1221
1781
  if (requirement && !['planned', 'active'].includes(requirement.state)) continue;
1222
- const candidateWorks = relevantWork(options, candidate);
1223
- const activeCandidateWorks = candidateWorks.filter((work) => !isTerminalWorkContractStatus(work.status));
1782
+ if (!relayMayHaveActiveWork(options, candidate, activeWorkSnapshotForScan())) continue;
1783
+ const candidateWorks = recoveryFenceWork(options, candidate, workSnapshotForScan());
1784
+ const activeCandidateWorks = candidateWorks.filter((work) => semanticWorkState(work) === 'open');
1224
1785
  if (activeCandidateWorks.length === 0) continue;
1225
1786
  if (activeCandidateWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) continue;
1226
1787
  if (repeatedStateBlocked) {
1227
1788
  const fingerprintWork = activeCandidateWorks[0] ?? getWorkContract(options, candidate.originWorkId);
1228
1789
  const currentFingerprint = fingerprintWork
1229
- ? mechanicalStateFingerprint(options, fingerprintWork, candidate.requirementId, candidate.relayScopeId)
1790
+ ? mechanicalStateFingerprint(options, fingerprintWork, candidate.requirementId, candidate.relayScopeId, candidate.handoffId)
1230
1791
  : candidate.stateFingerprint;
1231
1792
  if (currentFingerprint === candidate.stateFingerprint) continue;
1232
1793
  }
@@ -1246,16 +1807,35 @@ export function claimStalledControllerRoundRelays(
1246
1807
  }
1247
1808
  const latestRequirement = requirementForRelay(options, latest.requirementId);
1248
1809
  if (latestRequirement && !['planned', 'active'].includes(latestRequirement.state)) return undefined;
1249
- const works = relevantWork(options, latest);
1250
- const activeWorks = works.filter((work) => !isTerminalWorkContractStatus(work.status));
1810
+ const lockedWorkContracts = readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts;
1811
+ const works = recoveryFenceWork(options, latest, lockedWorkContracts);
1812
+ const activeWorks = works.filter((work) => semanticWorkState(work) === 'open');
1251
1813
  if (activeWorks.length === 0) return undefined;
1252
1814
  if (activeWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) return undefined;
1253
1815
 
1816
+ // A claimed ControllerRound may become recoverable after its execution
1817
+ // activity grace expires while the durable owner lease itself is still
1818
+ // unexpired. Never rotate the opaque per-round capability while leaving
1819
+ // that old owner authoritative: doing so makes release/recovery mutually
1820
+ // inaccessible. Fence the exact observed stale owner first. A crash after
1821
+ // this release but before relay transition is safe and retryable because
1822
+ // the old relay authority remains unchanged and no stale writer survives.
1823
+ for (const work of activeWorks) {
1824
+ const staleOwner = getControllerSession(options, work.workId);
1825
+ if (!staleOwner) continue;
1826
+ const released = releaseObservedControllerSession(options, {
1827
+ workId: work.workId,
1828
+ actor: `controller-relay-stalled-recovery:${latest.originWorkId}`,
1829
+ owner: staleOwner,
1830
+ });
1831
+ if (!released.allowed) return undefined;
1832
+ }
1833
+
1254
1834
  const currentRecord = readRelayRecord(options, latest.originWorkId);
1255
1835
  if (!currentRecord || currentRecord.value.updatedAt !== latest.updatedAt || currentRecord.value.status !== latest.status) return undefined;
1256
1836
  const fingerprintWork = activeWorks[0] ?? getWorkContract(options, latest.originWorkId);
1257
1837
  const stateFingerprint = fingerprintWork
1258
- ? mechanicalStateFingerprint(options, fingerprintWork, latest.requirementId, latest.relayScopeId)
1838
+ ? mechanicalStateFingerprint(options, fingerprintWork, latest.requirementId, latest.relayScopeId, latest.handoffId, lockedWorkContracts)
1259
1839
  : latest.stateFingerprint;
1260
1840
  const at = new Date(nowMs).toISOString();
1261
1841
  const lastError = latestRepeatedStateBlocked
@@ -1267,6 +1847,15 @@ export function claimStalledControllerRoundRelays(
1267
1847
  : latest.status === 'dispatching'
1268
1848
  ? 'CONTROLLER_RELAY_DISPATCH_TRANSITION_INCOMPLETE'
1269
1849
  : latest.status === 'claimed' ? 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED' : 'CONTROLLER_RELAY_ROUND_UNCLOSED';
1850
+ if (latest.status === 'dispatching' && latest.providerDispatchStartedAt && latest.providerDispatchEffectId) {
1851
+ applyControllerRoundTransition(options, currentRecord, {
1852
+ type: 'provider_dispatch_outcome_unknown',
1853
+ at,
1854
+ error: 'CONTROLLER_RELAY_PROVIDER_DISPATCH_STALLED_AFTER_EFFECT_START',
1855
+ providerDispatchEffectId: latest.providerDispatchEffectId,
1856
+ });
1857
+ return undefined;
1858
+ }
1270
1859
  const recovered = applyControllerRoundTransition(options, currentRecord, {
1271
1860
  type: 'stalled_round_observed', at, stateFingerprint, proposedAuthorityId: newControllerRoundAuthorityId(),
1272
1861
  ...(lastError ? { lastError } : {}),
@@ -1294,7 +1883,7 @@ export interface ControllerRoundContextSnapshot {
1294
1883
  };
1295
1884
  works: Array<{
1296
1885
  workId: string;
1297
- status: string;
1886
+ state: string;
1298
1887
  phase: string;
1299
1888
  updatedAt: string;
1300
1889
  objective: string;
@@ -1328,10 +1917,7 @@ export function readControllerRoundContextSnapshot(
1328
1917
  const requirement = requirementForRelay(options, record.requirementId);
1329
1918
  const relevantWorks = relevantWork(options, record);
1330
1919
  const works = relevantWorks.slice(0, 8);
1331
- const workIds = new Set(relevantWorks.map((work) => work.workId));
1332
- const handoffs = listHandoffItems({ controllerHome: options.controllerHome, repoId: options.repoId, status: 'active', limit: 100 })
1333
- .filter((handoff) => !handoff.workId || workIds.has(handoff.workId))
1334
- .slice(0, 8);
1920
+ const handoffs = relevantHandoffs(options, relevantWorks, record.handoffId).slice(0, 8);
1335
1921
  return {
1336
1922
  repoId: record.repoId,
1337
1923
  relayScopeId: record.relayScopeId,
@@ -1349,7 +1935,7 @@ export function readControllerRoundContextSnapshot(
1349
1935
  const origin = work.workId === record.originWorkId;
1350
1936
  return {
1351
1937
  workId: work.workId,
1352
- status: work.status,
1938
+ state: semanticWorkState(work),
1353
1939
  phase: work.phase,
1354
1940
  updatedAt: work.updatedAt,
1355
1941
  objective: work.objective.slice(0, origin