@gajae-code/coding-agent 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (465) hide show
  1. package/CHANGELOG.md +50 -2
  2. package/dist/types/append-only-mode.d.ts +1 -1
  3. package/dist/types/cli/args.d.ts +4 -5
  4. package/dist/types/cli/daemon-cli.d.ts +12 -1
  5. package/dist/types/cli/notify-cli.d.ts +21 -1
  6. package/dist/types/cli.d.ts +3 -0
  7. package/dist/types/commands/daemon.d.ts +39 -0
  8. package/dist/types/commands/launch.d.ts +3 -0
  9. package/dist/types/commands/notify.d.ts +28 -1
  10. package/dist/types/commands/sdk.d.ts +33 -0
  11. package/dist/types/config/atomic-yaml-patch.d.ts +85 -0
  12. package/dist/types/config/model-profile-activation.d.ts +35 -15
  13. package/dist/types/config/model-profiles.d.ts +11 -5
  14. package/dist/types/config/model-registry.d.ts +3 -3
  15. package/dist/types/config/model-resolver.d.ts +39 -6
  16. package/dist/types/config/model-selector-value.d.ts +17 -0
  17. package/dist/types/config/models-config-schema.d.ts +10 -6
  18. package/dist/types/config/settings-schema.d.ts +133 -6
  19. package/dist/types/config/settings.d.ts +63 -35
  20. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -6
  21. package/dist/types/coordinator-mcp/server.d.ts +6 -49
  22. package/dist/types/daemon/builtin.d.ts +6 -5
  23. package/dist/types/daemon/control-types.d.ts +1 -1
  24. package/dist/types/discovery/mcp-json.d.ts +12 -1
  25. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  26. package/dist/types/extensibility/extensions/compact-handler.d.ts +4 -2
  27. package/dist/types/extensibility/extensions/get-commands-handler.d.ts +2 -2
  28. package/dist/types/extensibility/extensions/loader.d.ts +10 -1
  29. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  30. package/dist/types/extensibility/extensions/types.d.ts +141 -16
  31. package/dist/types/extensibility/hooks/types.d.ts +1 -1
  32. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -2
  33. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  35. package/dist/types/gjc-runtime/team-runtime.d.ts +3 -4
  36. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +13 -0
  37. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -1
  38. package/dist/types/gjc-runtime/windows-powershell-command.d.ts +9 -0
  39. package/dist/types/harness-control-plane/operate.d.ts +5 -5
  40. package/dist/types/harness-control-plane/owner.d.ts +18 -4
  41. package/dist/types/harness-control-plane/receipts.d.ts +0 -1
  42. package/dist/types/harness-control-plane/sdk-transport.d.ts +43 -0
  43. package/dist/types/harness-control-plane/session-transport.d.ts +48 -0
  44. package/dist/types/harness-control-plane/types.d.ts +3 -4
  45. package/dist/types/lsp/index.d.ts +1 -1
  46. package/dist/types/main.d.ts +32 -38
  47. package/dist/types/modes/acp/acp-agent.d.ts +224 -53
  48. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -0
  49. package/dist/types/modes/acp/acp-mode.d.ts +6 -15
  50. package/dist/types/modes/acp/permission-mode.d.ts +4 -0
  51. package/dist/types/modes/acp/startup-options.d.ts +6 -0
  52. package/dist/types/modes/components/agent-dashboard.d.ts +3 -0
  53. package/dist/types/modes/components/model-selector.d.ts +1 -0
  54. package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
  55. package/dist/types/modes/components/settings-selector.d.ts +6 -4
  56. package/dist/types/modes/components/welcome.d.ts +1 -1
  57. package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
  58. package/dist/types/modes/index.d.ts +0 -4
  59. package/dist/types/modes/interactive-mode.d.ts +3 -0
  60. package/dist/types/modes/runtime-init.d.ts +2 -2
  61. package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
  62. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
  63. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
  64. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
  65. package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
  66. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +169 -30
  67. package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
  68. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
  69. package/dist/types/modes/types.d.ts +5 -0
  70. package/dist/types/runtime-mcp/client.d.ts +2 -0
  71. package/dist/types/runtime-mcp/config.d.ts +5 -1
  72. package/dist/types/runtime-mcp/manager.d.ts +8 -1
  73. package/dist/types/runtime-mcp/types.d.ts +4 -0
  74. package/dist/types/sdk/acp/adapter.d.ts +67 -0
  75. package/dist/types/sdk/acp/index.d.ts +2 -0
  76. package/dist/types/sdk/broker/broker.d.ts +53 -0
  77. package/dist/types/sdk/broker/discovery.d.ts +30 -0
  78. package/dist/types/sdk/broker/ensure.d.ts +33 -0
  79. package/dist/types/sdk/broker/identity.d.ts +3 -0
  80. package/dist/types/sdk/broker/index.d.ts +8 -0
  81. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
  82. package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
  83. package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
  84. package/dist/types/sdk/broker/runtime.d.ts +33 -0
  85. package/dist/types/sdk/broker/session-index.d.ts +57 -0
  86. package/dist/types/sdk/broker/state-version.d.ts +8 -0
  87. package/dist/types/sdk/broker/transport.d.ts +9 -0
  88. package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
  89. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
  90. package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
  91. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
  92. package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
  93. package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
  94. package/dist/types/sdk/bus/config.d.ts +274 -0
  95. package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
  96. package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
  97. package/dist/types/sdk/bus/discord-daemon.d.ts +45 -0
  98. package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
  99. package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
  100. package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
  101. package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
  102. package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
  103. package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
  104. package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
  105. package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
  106. package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
  107. package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
  108. package/dist/types/sdk/bus/session-control.d.ts +83 -0
  109. package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
  110. package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
  111. package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
  112. package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
  113. package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
  114. package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
  115. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
  116. package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
  117. package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +55 -17
  118. package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
  119. package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
  120. package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
  121. package/dist/types/sdk/cli/index.d.ts +1 -0
  122. package/dist/types/sdk/cli/session-cli.d.ts +19 -0
  123. package/dist/types/sdk/client/client.d.ts +1 -0
  124. package/dist/types/sdk/client/discovery.d.ts +27 -0
  125. package/dist/types/sdk/client/index.d.ts +2 -0
  126. package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
  127. package/dist/types/sdk/host/control/index.d.ts +2 -0
  128. package/dist/types/sdk/host/control/operations.d.ts +65 -0
  129. package/dist/types/sdk/host/events.d.ts +35 -0
  130. package/dist/types/sdk/host/host.d.ts +35 -0
  131. package/dist/types/sdk/host/index.d.ts +4 -0
  132. package/dist/types/sdk/host/query/cursor.d.ts +62 -0
  133. package/dist/types/sdk/host/query/handlers.d.ts +70 -0
  134. package/dist/types/sdk/host/query/index.d.ts +3 -0
  135. package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
  136. package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
  137. package/dist/types/sdk/host/types.d.ts +32 -0
  138. package/dist/types/sdk/index.d.ts +6 -0
  139. package/dist/types/sdk/lifecycle-session.d.ts +14 -0
  140. package/dist/types/sdk/mcp/index.d.ts +1 -0
  141. package/dist/types/sdk/mcp/server.d.ts +37 -0
  142. package/dist/types/sdk/models.d.ts +44 -0
  143. package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
  144. package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
  145. package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
  146. package/dist/types/sdk/startup-capability.d.ts +50 -0
  147. package/dist/types/session/agent-session.d.ts +114 -21
  148. package/dist/types/session/default-model-selection.d.ts +25 -0
  149. package/dist/types/session/fallback-chain-controller.d.ts +52 -0
  150. package/dist/types/session/session-manager.d.ts +58 -1
  151. package/dist/types/setup/credential-auto-import.d.ts +36 -6
  152. package/dist/types/tools/ask-answer-registry.d.ts +1 -1
  153. package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
  154. package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
  155. package/dist/types/tools/browser/launch.d.ts +22 -0
  156. package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
  157. package/dist/types/tools/browser/profile-posture.d.ts +37 -0
  158. package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
  159. package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
  160. package/dist/types/tools/browser/registry.d.ts +19 -0
  161. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  162. package/dist/types/tools/index.d.ts +8 -8
  163. package/package.json +34 -13
  164. package/scripts/build-sdk-package-smoke.ts +94 -0
  165. package/scripts/capture-notifications-settings-showcase.ts +435 -0
  166. package/scripts/compile-args.ts +4 -2
  167. package/scripts/g005-daemon-smoke.ts +4 -5
  168. package/scripts/g011-daemon-path-smoke.ts +5 -5
  169. package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
  170. package/scripts/generate-sdk-operation-inventory.ts +867 -0
  171. package/scripts/generate-telegram-baseline-manifest.ts +128 -0
  172. package/scripts/manifest-schema.ts +124 -0
  173. package/scripts/run-test-manifest.ts +153 -0
  174. package/scripts/run-threat-model-check.ts +157 -0
  175. package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
  176. package/scripts/verify-gjc-sdk-rename.ts +90 -0
  177. package/src/append-only-mode.ts +1 -1
  178. package/src/cli/args.ts +43 -12
  179. package/src/cli/auth-gateway-cli.ts +2 -2
  180. package/src/cli/config-cli.ts +2 -1
  181. package/src/cli/daemon-cli.ts +63 -14
  182. package/src/cli/list-models.ts +1 -1
  183. package/src/cli/notify-cli.ts +288 -286
  184. package/src/cli.ts +21 -2
  185. package/src/commands/daemon.ts +50 -6
  186. package/src/commands/harness.ts +93 -36
  187. package/src/commands/launch.ts +5 -2
  188. package/src/commands/mcp-serve.ts +19 -12
  189. package/src/commands/notify.ts +36 -3
  190. package/src/commands/sdk.ts +426 -0
  191. package/src/commit/agentic/agent.ts +1 -0
  192. package/src/config/atomic-yaml-patch.ts +423 -0
  193. package/src/config/model-profile-activation.ts +240 -89
  194. package/src/config/model-profiles.ts +18 -15
  195. package/src/config/model-registry.ts +51 -26
  196. package/src/config/model-resolver.ts +138 -51
  197. package/src/config/model-selector-value.ts +50 -0
  198. package/src/config/models-config-schema.ts +14 -21
  199. package/src/config/settings-schema.ts +128 -10
  200. package/src/config/settings.ts +1120 -158
  201. package/src/coordinator-mcp/model-preset.ts +74 -20
  202. package/src/coordinator-mcp/server.ts +1597 -3052
  203. package/src/daemon/builtin.ts +8 -5
  204. package/src/daemon/control-types.ts +1 -1
  205. package/src/defaults/gjc/skills/team/SKILL.md +4 -4
  206. package/src/discovery/mcp-json.ts +327 -20
  207. package/src/extensibility/custom-tools/types.ts +1 -1
  208. package/src/extensibility/extensions/compact-handler.ts +3 -3
  209. package/src/extensibility/extensions/get-commands-handler.ts +2 -2
  210. package/src/extensibility/extensions/loader.ts +69 -5
  211. package/src/extensibility/extensions/runner.ts +112 -4
  212. package/src/extensibility/extensions/types.ts +176 -17
  213. package/src/extensibility/hooks/types.ts +1 -1
  214. package/src/gjc-runtime/launch-tmux.ts +170 -86
  215. package/src/gjc-runtime/psmux-detect.ts +82 -4
  216. package/src/gjc-runtime/session-state-sidecar.ts +41 -31
  217. package/src/gjc-runtime/team-runtime.ts +54 -35
  218. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
  219. package/src/gjc-runtime/tmux-owner-isolation.ts +1 -1
  220. package/src/gjc-runtime/tmux-sessions.ts +19 -13
  221. package/src/gjc-runtime/ultragoal-guard.ts +53 -6
  222. package/src/gjc-runtime/ultragoal-runtime.ts +155 -3
  223. package/src/gjc-runtime/windows-powershell-command.ts +58 -0
  224. package/src/harness-control-plane/operate.ts +9 -9
  225. package/src/harness-control-plane/owner.ts +283 -39
  226. package/src/harness-control-plane/receipts.ts +0 -1
  227. package/src/harness-control-plane/sdk-transport.ts +395 -0
  228. package/src/harness-control-plane/session-transport.ts +94 -0
  229. package/src/harness-control-plane/types.ts +1 -1
  230. package/src/internal-urls/docs-index.generated.ts +34 -35
  231. package/src/lsp/client.ts +42 -6
  232. package/src/lsp/index.ts +2 -2
  233. package/src/main.ts +299 -204
  234. package/src/modes/DESIGN.md +258 -0
  235. package/src/modes/acp/acp-agent.ts +1228 -2612
  236. package/src/modes/acp/acp-client-bridge.ts +2 -20
  237. package/src/modes/acp/acp-event-mapper.ts +18 -2
  238. package/src/modes/acp/acp-mode.ts +8 -36
  239. package/src/modes/acp/permission-mode.ts +25 -0
  240. package/src/modes/acp/startup-options.ts +6 -0
  241. package/src/modes/components/agent-dashboard.ts +37 -17
  242. package/src/modes/components/model-selector.ts +93 -35
  243. package/src/modes/components/notifications-settings-editor.ts +1379 -0
  244. package/src/modes/components/settings-selector.ts +60 -11
  245. package/src/modes/components/welcome.ts +4 -4
  246. package/src/modes/controllers/command-controller.ts +1 -2
  247. package/src/modes/controllers/event-controller.ts +11 -16
  248. package/src/modes/controllers/extension-ui-controller.ts +348 -4
  249. package/src/modes/controllers/selector-controller.ts +674 -36
  250. package/src/modes/index.ts +0 -22
  251. package/src/modes/interactive-mode.ts +59 -20
  252. package/src/modes/runtime-init.ts +298 -6
  253. package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
  254. package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
  255. package/src/modes/shared/agent-wire/event-contract.ts +7 -8
  256. package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
  257. package/src/modes/shared/agent-wire/event-observation.ts +16 -7
  258. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1254 -87
  259. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
  260. package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
  261. package/src/modes/types.ts +5 -0
  262. package/src/runtime-mcp/client.ts +94 -19
  263. package/src/runtime-mcp/config.ts +27 -15
  264. package/src/runtime-mcp/manager.ts +343 -105
  265. package/src/runtime-mcp/oauth-flow.ts +42 -12
  266. package/src/runtime-mcp/transports/http.ts +49 -19
  267. package/src/runtime-mcp/transports/stdio.ts +47 -28
  268. package/src/runtime-mcp/types.ts +10 -0
  269. package/src/sdk/acp/adapter.ts +418 -0
  270. package/src/sdk/acp/index.ts +2 -0
  271. package/src/sdk/broker/broker.ts +642 -0
  272. package/src/sdk/broker/discovery.ts +94 -0
  273. package/src/sdk/broker/ensure.ts +249 -0
  274. package/src/sdk/broker/identity.ts +42 -0
  275. package/src/sdk/broker/index.ts +8 -0
  276. package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
  277. package/src/sdk/broker/internal-source.bunfig.toml +1 -0
  278. package/src/sdk/broker/lifecycle-ledger.ts +286 -0
  279. package/src/sdk/broker/lifecycle.ts +2157 -0
  280. package/src/sdk/broker/process-incarnation.ts +159 -0
  281. package/src/sdk/broker/runtime.ts +164 -0
  282. package/src/sdk/broker/session-index.ts +318 -0
  283. package/src/sdk/broker/state-version.ts +23 -0
  284. package/src/sdk/broker/transport.ts +125 -0
  285. package/src/sdk/bus/chat-command-policy.ts +107 -0
  286. package/src/sdk/bus/chat-daemon-cli.ts +214 -0
  287. package/src/sdk/bus/chat-daemon-control.ts +713 -0
  288. package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
  289. package/src/sdk/bus/chat-effect-journal.ts +353 -0
  290. package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
  291. package/src/sdk/bus/config.ts +433 -0
  292. package/src/sdk/bus/conversation-store.ts +391 -0
  293. package/src/sdk/bus/discord-conversation.ts +85 -0
  294. package/src/sdk/bus/discord-daemon.ts +2275 -0
  295. package/src/sdk/bus/discord-live-provider.ts +521 -0
  296. package/src/sdk/bus/discord-provider.ts +56 -0
  297. package/src/{notifications → sdk/bus}/html-format.ts +2 -2
  298. package/src/sdk/bus/index.ts +3943 -0
  299. package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
  300. package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
  301. package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
  302. package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
  303. package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
  304. package/src/sdk/bus/natives-augment.d.ts +38 -0
  305. package/src/sdk/bus/notification-orchestration.ts +535 -0
  306. package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
  307. package/src/sdk/bus/session-control.ts +333 -0
  308. package/src/sdk/bus/slack-conversation.ts +74 -0
  309. package/src/sdk/bus/slack-daemon.ts +2144 -0
  310. package/src/sdk/bus/slack-live-provider.ts +380 -0
  311. package/src/sdk/bus/slack-provider.ts +76 -0
  312. package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
  313. package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
  314. package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
  315. package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
  316. package/src/{notifications → sdk/bus}/telegram-daemon.ts +502 -54
  317. package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
  318. package/src/sdk/bus/telegram-setup.ts +663 -0
  319. package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
  320. package/src/sdk/cli/index.ts +1 -0
  321. package/src/sdk/cli/session-cli.ts +275 -0
  322. package/src/sdk/client/client.ts +1 -0
  323. package/src/sdk/client/discovery.ts +108 -0
  324. package/src/sdk/client/index.ts +2 -0
  325. package/src/sdk/host/control/dispatch.ts +367 -0
  326. package/src/sdk/host/control/index.ts +10 -0
  327. package/src/sdk/host/control/operations.ts +63 -0
  328. package/src/sdk/host/events.ts +91 -0
  329. package/src/sdk/host/host.ts +296 -0
  330. package/src/sdk/host/index.ts +4 -0
  331. package/src/sdk/host/query/cursor.ts +189 -0
  332. package/src/sdk/host/query/handlers.ts +533 -0
  333. package/src/sdk/host/query/index.ts +3 -0
  334. package/src/sdk/host/query/revision-store.ts +1126 -0
  335. package/src/sdk/host/reverse-leases.ts +325 -0
  336. package/src/sdk/host/types.ts +29 -0
  337. package/src/sdk/index.ts +13 -0
  338. package/src/sdk/lifecycle-session.ts +39 -0
  339. package/src/sdk/mcp/index.ts +1 -0
  340. package/src/sdk/mcp/server.ts +332 -0
  341. package/src/sdk/models.ts +168 -0
  342. package/src/sdk/protocol/adapter-validation.ts +48 -0
  343. package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
  344. package/src/sdk/protocol/operation-registry.ts +312 -0
  345. package/src/{sdk.ts → sdk/session.ts} +339 -212
  346. package/src/sdk/startup-capability.ts +189 -0
  347. package/src/session/agent-session.ts +2453 -894
  348. package/src/session/default-model-selection.ts +93 -0
  349. package/src/session/fallback-chain-controller.ts +136 -0
  350. package/src/session/session-manager.ts +438 -11
  351. package/src/session/session-storage.ts +8 -1
  352. package/src/setup/credential-auto-import.ts +290 -35
  353. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
  354. package/src/slash-commands/builtin-registry.ts +2 -1
  355. package/src/task/executor.ts +62 -13
  356. package/src/tools/ask-answer-registry.ts +1 -1
  357. package/src/tools/ask.ts +13 -8
  358. package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
  359. package/src/tools/browser/benchmark/detector-report.ts +157 -0
  360. package/src/tools/browser/benchmark/run-suite.ts +39 -0
  361. package/src/tools/browser/launch.ts +124 -19
  362. package/src/tools/browser/profile-discovery.ts +63 -0
  363. package/src/tools/browser/profile-posture.ts +59 -0
  364. package/src/tools/browser/profile-reuse.ts +102 -0
  365. package/src/tools/browser/profile-warmup.ts +109 -0
  366. package/src/tools/browser/registry.ts +87 -11
  367. package/src/tools/browser/tab-protocol.ts +1 -0
  368. package/src/tools/browser/tab-supervisor.ts +1 -0
  369. package/src/tools/browser/tab-worker.ts +1 -1
  370. package/src/tools/browser.ts +6 -0
  371. package/src/tools/index.ts +8 -8
  372. package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
  373. package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
  374. package/src/tools/telegram-send.ts +2 -2
  375. package/vendor/insane-search/MANIFEST.json +2 -1
  376. package/vendor/insane-search/engine/bias_check.py +4 -1
  377. package/vendor/insane-search/engine/phase0.py +26 -1
  378. package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
  379. package/dist/types/modes/bridge/auth.d.ts +0 -12
  380. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
  381. package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
  382. package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
  383. package/dist/types/modes/bridge/event-stream.d.ts +0 -8
  384. package/dist/types/modes/rpc/host-tools.d.ts +0 -1
  385. package/dist/types/modes/rpc/host-uris.d.ts +0 -1
  386. package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
  387. package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
  388. package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
  389. package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
  390. package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
  391. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
  392. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
  393. package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
  394. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
  395. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
  396. package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
  397. package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
  398. package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
  399. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
  400. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
  401. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
  402. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
  403. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
  404. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
  405. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
  406. package/dist/types/notifications/config.d.ts +0 -132
  407. package/src/harness-control-plane/rpc-adapter.ts +0 -290
  408. package/src/modes/bridge/auth.ts +0 -41
  409. package/src/modes/bridge/bridge-client-bridge.ts +0 -47
  410. package/src/modes/bridge/bridge-mode.ts +0 -675
  411. package/src/modes/bridge/bridge-ui-context.ts +0 -200
  412. package/src/modes/bridge/event-stream.ts +0 -70
  413. package/src/modes/rpc/host-tools.ts +0 -1
  414. package/src/modes/rpc/host-uris.ts +0 -1
  415. package/src/modes/rpc/rpc-client.ts +0 -1166
  416. package/src/modes/rpc/rpc-mode.ts +0 -1029
  417. package/src/modes/rpc/rpc-socket-security.ts +0 -115
  418. package/src/modes/rpc/rpc-types.ts +0 -637
  419. package/src/modes/shared/agent-wire/command-contract.ts +0 -18
  420. package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
  421. package/src/modes/shared/agent-wire/command-validation.ts +0 -181
  422. package/src/modes/shared/agent-wire/handshake.ts +0 -157
  423. package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
  424. package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
  425. package/src/modes/shared/agent-wire/protocol.ts +0 -46
  426. package/src/modes/shared/agent-wire/responses.ts +0 -17
  427. package/src/modes/shared/agent-wire/scopes.ts +0 -94
  428. package/src/modes/shared/agent-wire/session-registry.ts +0 -109
  429. package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
  430. package/src/modes/shared/agent-wire/ui-result.ts +0 -48
  431. package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
  432. package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
  433. package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
  434. package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
  435. package/src/notifications/config.ts +0 -219
  436. package/src/notifications/index.ts +0 -1835
  437. package/src/notifications/telegram-daemon-cli.ts +0 -243
  438. package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
  439. /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
  440. /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
  441. /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
  442. /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
  443. /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
  444. /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
  445. /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
  446. /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
  447. /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
  448. /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
  449. /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
  450. /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
  451. /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
  452. /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
  453. /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
  454. /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
  455. /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
  456. /package/src/{notifications → sdk/bus}/engine.ts +0 -0
  457. /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
  458. /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
  459. /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
  460. /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
  461. /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
  462. /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
  463. /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
  464. /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
  465. /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
@@ -14,25 +14,27 @@
14
14
  import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import type { AskSelectedAckOutcome } from "../../../tools";
17
- import type {
18
- RpcWorkflowGate,
19
- RpcWorkflowGateContext,
20
- RpcWorkflowGateKind,
21
- RpcWorkflowGateOption,
22
- RpcWorkflowGateResolution,
23
- RpcWorkflowGateResponse,
24
- RpcWorkflowGateValidationError,
25
- RpcWorkflowStage,
26
- } from "../../rpc/rpc-types";
27
- import { RESERVED_WORKFLOW_STAGES } from "../../rpc/rpc-types";
28
17
  import { classifyAskGateDisposition } from "./deep-interview-gate";
29
18
  import { answerHashOf, canonicalJson, compileGateSchema, schemaHash, validateGateAnswer } from "./workflow-gate-schema";
19
+ import type {
20
+ WorkflowGate,
21
+ WorkflowGateContext,
22
+ WorkflowGateDiagnostic,
23
+ WorkflowGateKind,
24
+ WorkflowGateOption,
25
+ WorkflowGateQueryRecord,
26
+ WorkflowGateResolution,
27
+ WorkflowGateResponse,
28
+ WorkflowGateValidationError,
29
+ WorkflowStage,
30
+ } from "./workflow-gate-types";
31
+ import { RESERVED_WORKFLOW_STAGES } from "./workflow-gate-types";
30
32
 
31
33
  export type PersistedSemanticDisposition = "commit" | "resolve_without_commit";
32
34
  export type PersistedResolutionOrigin =
33
- | { kind: "generic"; channel: "rpc" | "bridge" | "other" }
34
- | { kind: "telegram_notification"; interactionActionId: string }
35
- | { kind: "legacy_unknown" };
35
+ | { kind: "generic"; channel: "sdk" | "other" }
36
+ | { kind: "telegram_notification"; interactionActionId: string };
37
+
36
38
  export type PersistedAckPolicy =
37
39
  | { kind: "none"; reason: "non_telegram" | "semantic_noncommit" | "legacy_unproven" }
38
40
  | {
@@ -51,35 +53,457 @@ export interface GateResolutionOptions {
51
53
  beforeAdvance?: () => Promise<void>;
52
54
  }
53
55
 
54
- const V1_STAGES: readonly RpcWorkflowStage[] = ["deep-interview", "ralplan", "ultragoal"];
56
+ export type WorkflowGateTerminalProof = "retired" | "already_terminal" | "not_published";
57
+
58
+ export interface WorkflowGateTerminalController {
59
+ completeGateInteractions(gateId: string): WorkflowGateTerminalProof | Promise<WorkflowGateTerminalProof>;
60
+ cancelGateInteractions(gateId: string, reason: string): void | Promise<void>;
61
+ }
62
+
63
+ export interface NotificationGateResolutionOptions {
64
+ interactionActionId: string;
65
+ replyReceiptId: string;
66
+ answerJson: string;
67
+ idempotencyKey?: string;
68
+ requestSelectedAck(input: {
69
+ replyReceiptId: string;
70
+ actionId: string;
71
+ commitKey: string;
72
+ daemonDeadlineAt: number;
73
+ hostTimeoutMs: number;
74
+ }): Promise<AskSelectedAckOutcome>;
75
+ resolveClaim(): void;
76
+ closeClaimInvalid(reason: string): void;
77
+ }
78
+
79
+ export interface AskSelectedAckRecoveryParticipant {
80
+ requestRecoveredAskSelectedAck(input: {
81
+ sessionId: string;
82
+ actionId: string;
83
+ commitKey: string;
84
+ deadlineAt: number;
85
+ hostTimeoutMs: number;
86
+ }): Promise<AskSelectedAckOutcome>;
87
+ }
88
+
89
+ /** SDK-native surface for emitting a workflow gate and awaiting its answer. */
90
+ export interface WorkflowGateEmitter {
91
+ isUnattended(): boolean;
92
+ emitGate(input: OpenGateInput): Promise<unknown>;
93
+ onGateEmitted?(listener: (gate: WorkflowGate) => void): () => void;
94
+ resolveGate?(response: WorkflowGateResponse): Promise<WorkflowGateResolution>;
95
+ resolveGateFromNotification?(
96
+ response: WorkflowGateResponse,
97
+ options: NotificationGateResolutionOptions,
98
+ ): Promise<WorkflowGateResolution>;
99
+ registerGateTerminalController?(controller: WorkflowGateTerminalController): () => void;
100
+ /** Records the exact terminalization proof established by direct control preparation. */
101
+ prepareTerminalization?(gateId: string, proof: WorkflowGateTerminalProof): boolean;
102
+ /** Discards a prepared proof when the gate remains pending after a rejected direct response. */
103
+ clearPreparedTerminalization?(gateId: string): void;
104
+ listPendingGates?(): WorkflowGate[];
105
+ listGateDiagnostics?(): WorkflowGateDiagnostic[];
106
+ listWorkflowGateQueryRecords?(): WorkflowGateQueryRecord[];
107
+ quarantineGate?(gateId: string): void;
108
+ setAckRecoveryParticipant?(participant: AskSelectedAckRecoveryParticipant | null): void;
109
+ /** Explicit host hook for same-process accepted-but-unadvanced recovery. */
110
+ recoverAcceptedGates?(): Promise<string[]>;
111
+ /** Permanently revoke all in-process continuation authority and reject their waiters. */
112
+ fence?(): void;
113
+ /** Temporarily make this emitter unavailable while a session transition is reversible. */
114
+ suspend?(): void;
115
+ resume?(): void;
116
+ }
117
+
118
+ /**
119
+ * SDK-native workflow-gate emitter backed by a durable broker/store pair.
120
+ *
121
+ * Answers are resolved by the broker, so validation and idempotency have one
122
+ * authority. Listeners replay durable pending gates when attached, allowing an
123
+ * SDK host that starts after a gate was opened to discover it.
124
+ */
125
+ export class BrokerWorkflowGateEmitter implements WorkflowGateEmitter {
126
+ readonly #listeners = new Set<(gate: WorkflowGate) => void>();
127
+ readonly #waiters = new Map<string, { resolve(answer: unknown): void; reject(error: Error): void }>();
128
+ readonly #broker: WorkflowGateBroker;
129
+ #terminalController: WorkflowGateTerminalController | undefined;
130
+ #recoveryParticipant: AskSelectedAckRecoveryParticipant | undefined;
131
+ #recoveryPromise: Promise<void> | undefined;
132
+ readonly #participantReady = Promise.withResolvers<void>();
133
+ #recoveryGraceTimer: NodeJS.Timeout | undefined;
134
+ #recoveryGraceWake: (() => void) | undefined;
135
+ #recoveryTimer: NodeJS.Timeout | undefined;
136
+ #recoveryAttempts = 0;
137
+ #fenced = false;
138
+ #suspended = false;
139
+ readonly #runId: string;
140
+ readonly #store: GateStore;
141
+ readonly #emitterHooks: Pick<BrokerHooks, "advance">;
142
+
143
+ constructor(runId: string, store: GateStore, emitterHooks: Pick<BrokerHooks, "advance"> = {}) {
144
+ this.#runId = runId;
145
+ this.#store = store;
146
+ this.#emitterHooks = emitterHooks;
147
+ this.#broker = new WorkflowGateBroker(runId, store, {
148
+ emit: gate => {
149
+ for (const listener of this.#listeners) listener(gate);
150
+ },
151
+ advance: (gate, answer) => this.#emitterHooks.advance?.(gate, answer),
152
+ completeAccepted: record => this.#completeAccepted(record),
153
+ finalizeAccepted: record => this.#finalizeAccepted(record),
154
+ terminalizeAccepted: record => this.#terminalizeAccepted(record),
155
+ });
156
+ }
157
+
158
+ isUnattended(): boolean {
159
+ return true;
160
+ }
161
+
162
+ emitGate(input: OpenGateInput): Promise<unknown> {
163
+ if (this.#fenced || this.#suspended) return Promise.reject(new Error("workflow gate emitter is unavailable"));
164
+ const waiter = Promise.withResolvers<unknown>();
165
+ const gate = this.#broker.openGate(input, {
166
+ activate: opened => this.#waiters.set(opened.gate_id, waiter),
167
+ isLive: gateId => this.#waiters.has(gateId),
168
+ release: gateId => {
169
+ const activeWaiter = this.#waiters.get(gateId);
170
+ if (!activeWaiter) return;
171
+ this.#waiters.delete(gateId);
172
+ activeWaiter.reject(new Error(`workflow gate ${gateId} continuation was fenced`));
173
+ },
174
+ });
175
+ // A listener may answer synchronously while the broker emits. The durable
176
+ // record is the source of truth in that race; never strand its promise.
177
+ const persisted = this.#store.get(gate.gate_id);
178
+ return persisted?.status === "accepted" ? Promise.resolve(persisted.answer) : waiter.promise;
179
+ }
180
+
181
+ onGateEmitted(listener: (gate: WorkflowGate) => void): () => void {
182
+ this.#listeners.add(listener);
183
+ for (const gate of this.listPendingGates()) listener(gate);
184
+ return () => this.#listeners.delete(listener);
185
+ }
186
+
187
+ resolveGate(response: WorkflowGateResponse): Promise<WorkflowGateResolution> {
188
+ if (this.#fenced || this.#suspended)
189
+ return Promise.reject(new WorkflowGateBrokerError("unknown_gate", `workflow gate emitter is unavailable`));
190
+ return this.#broker.resolve(response).catch(error => {
191
+ if (!this.#fenced && !this.#suspended) this.#scheduleRecovery();
192
+ throw error;
193
+ });
194
+ }
195
+ prepareTerminalization(gateId: string, proof: WorkflowGateTerminalProof): boolean {
196
+ return this.#broker.prepareTerminalization(gateId, proof);
197
+ }
198
+ clearPreparedTerminalization(gateId: string): void {
199
+ this.#broker.clearPreparedTerminalization(gateId);
200
+ }
201
+
202
+ listPendingGates(): WorkflowGate[] {
203
+ return this.#suspended ? [] : this.#broker.listPendingGates();
204
+ }
205
+
206
+ listGateDiagnostics(): WorkflowGateDiagnostic[] {
207
+ return this.#broker.listGateDiagnostics();
208
+ }
209
+
210
+ listWorkflowGateQueryRecords(): WorkflowGateQueryRecord[] {
211
+ return this.#broker.listWorkflowGateQueryRecords();
212
+ }
213
+
214
+ suspend(): void {
215
+ if (!this.#fenced) this.#suspended = true;
216
+ }
217
+
218
+ resume(): void {
219
+ if (!this.#fenced) this.#suspended = false;
220
+ }
221
+
222
+ fence(): void {
223
+ if (this.#fenced) return;
224
+ this.#fenced = true;
225
+ this.#suspended = false;
226
+ if (this.#recoveryTimer) clearTimeout(this.#recoveryTimer);
227
+ this.#recoveryTimer = undefined;
228
+ if (this.#recoveryGraceTimer) clearTimeout(this.#recoveryGraceTimer);
229
+ this.#recoveryGraceTimer = undefined;
230
+ this.#recoveryGraceWake?.();
231
+ this.#recoveryGraceWake = undefined;
232
+ for (const [gateId, waiter] of this.#waiters) {
233
+ this.#waiters.delete(gateId);
234
+ waiter.reject(new Error(`workflow gate ${gateId} continuation was fenced`));
235
+ }
236
+ this.#broker.fenceContinuations();
237
+ }
238
+
239
+ quarantineGate(gateId: string): void {
240
+ const waiter = this.#waiters.get(gateId);
241
+ if (waiter) {
242
+ this.#waiters.delete(gateId);
243
+ waiter.reject(new Error(`workflow gate ${gateId} continuation was fenced`));
244
+ }
245
+ this.#broker.quarantineGate(gateId);
246
+ }
247
+
248
+ registerGateTerminalController(controller: WorkflowGateTerminalController): () => void {
249
+ if (this.#terminalController && this.#terminalController !== controller)
250
+ throw new Error("a workflow gate terminal controller is already registered");
251
+ this.#terminalController = controller;
252
+ return () => {
253
+ if (this.#terminalController === controller) this.#terminalController = undefined;
254
+ };
255
+ }
256
+
257
+ setAckRecoveryParticipant(participant: AskSelectedAckRecoveryParticipant | null): void {
258
+ this.#recoveryParticipant = participant ?? undefined;
259
+ if (participant) this.#participantReady.resolve();
260
+ void this.#startRecoveryOnce();
261
+ }
262
+ async recoverAcceptedGates(): Promise<string[]> {
263
+ const recovered = await this.#broker.recover();
264
+ if (recovered.length > 0 || this.#broker.hasRecoverableAcceptedGate()) {
265
+ this.#scheduleRecovery();
266
+ } else {
267
+ this.#recoveryAttempts = 0;
268
+ }
269
+ return recovered;
270
+ }
271
+
272
+ async resolveGateFromNotification(
273
+ response: WorkflowGateResponse,
274
+ options: NotificationGateResolutionOptions,
275
+ ): Promise<WorkflowGateResolution> {
276
+ let claimSettled = false;
277
+ const resolveClaim = () => {
278
+ if (claimSettled) return;
279
+ claimSettled = true;
280
+ options.resolveClaim();
281
+ };
282
+ const closeClaimInvalid = (reason: string) => {
283
+ if (claimSettled) return;
284
+ claimSettled = true;
285
+ options.closeClaimInvalid(reason);
286
+ };
287
+ try {
288
+ JSON.parse(options.answerJson);
289
+ } catch {
290
+ closeClaimInvalid("invalid_structured_answer");
291
+ return {
292
+ gate_id: response.gate_id,
293
+ status: "rejected",
294
+ answer_hash: "",
295
+ resolved_at: new Date().toISOString(),
296
+ error: this.#broker.validationError(response.gate_id, "json_parse", "invalid structured answer"),
297
+ };
298
+ }
299
+ let semanticDisposition: PersistedSemanticDisposition;
300
+ try {
301
+ semanticDisposition = this.#broker.classifyDisposition(response.gate_id, response.answer);
302
+ } catch (error) {
303
+ const message = error instanceof Error ? error.message : "invalid answer";
304
+ closeClaimInvalid(message);
305
+ return {
306
+ gate_id: response.gate_id,
307
+ status: "rejected",
308
+ answer_hash: "",
309
+ resolved_at: new Date().toISOString(),
310
+ error: this.#broker.validationError(response.gate_id, "semantic_disposition", message),
311
+ };
312
+ }
313
+ const commitKey = `${response.gate_id}:${options.idempotencyKey ?? options.replyReceiptId}`;
314
+ const ackPolicy: PersistedAckPolicy =
315
+ semanticDisposition === "commit"
316
+ ? {
317
+ kind: "telegram_selected_v1",
318
+ commitKey,
319
+ actionId: options.interactionActionId,
320
+ state: "pending",
321
+ updatedAt: new Date().toISOString(),
322
+ }
323
+ : { kind: "none", reason: "semantic_noncommit" };
324
+ try {
325
+ const resolution = await this.#broker.resolve(response, {
326
+ resolutionOrigin: { kind: "telegram_notification", interactionActionId: options.interactionActionId },
327
+ ackPolicy,
328
+ semanticDisposition,
329
+ beforeAdvance: async () => {
330
+ if (ackPolicy.kind === "telegram_selected_v1") {
331
+ this.#broker.updateAckPolicy(response.gate_id, {
332
+ ...ackPolicy,
333
+ state: "attempt_started",
334
+ updatedAt: new Date().toISOString(),
335
+ });
336
+ let outcome: AskSelectedAckOutcome;
337
+ try {
338
+ outcome = await options.requestSelectedAck({
339
+ replyReceiptId: options.replyReceiptId,
340
+ actionId: options.interactionActionId,
341
+ commitKey,
342
+ daemonDeadlineAt: Date.now() + 8_000,
343
+ hostTimeoutMs: 10_000,
344
+ });
345
+ } catch {
346
+ outcome = { status: "unknown", reason: "host_timeout" };
347
+ }
348
+ this.#broker.updateAckPolicy(response.gate_id, {
349
+ ...ackPolicy,
350
+ state: outcome.status,
351
+ outcome,
352
+ updatedAt: new Date().toISOString(),
353
+ });
354
+ }
355
+ resolveClaim();
356
+ },
357
+ });
358
+ if (resolution.status === "accepted") resolveClaim();
359
+ else closeClaimInvalid(resolution.error?.code ?? "invalid_answer");
360
+ return resolution;
361
+ } catch (error) {
362
+ closeClaimInvalid(error instanceof Error ? error.message : "invalid_answer");
363
+ this.#scheduleRecovery();
364
+ throw error;
365
+ }
366
+ }
367
+
368
+ async #completeAccepted(record: PersistedGate): Promise<void> {
369
+ const waiter = this.#waiters.get(record.gate.gate_id);
370
+ if (!waiter) throw new Error(`workflow gate ${record.gate.gate_id} lost its continuation owner`);
371
+ waiter.resolve(record.answer);
372
+ }
373
+
374
+ async #terminalizeAccepted(record: PersistedGate): Promise<WorkflowGateTerminalProof> {
375
+ const controller = this.#terminalController;
376
+ if (!controller) {
377
+ this.quarantineGate(record.gate.gate_id);
378
+ throw new Error(`workflow gate ${record.gate.gate_id} has no terminal controller`);
379
+ }
380
+ try {
381
+ const proof = await controller.completeGateInteractions(record.gate.gate_id);
382
+ if (proof !== "retired" && proof !== "already_terminal" && proof !== "not_published")
383
+ throw new Error(`workflow gate ${record.gate.gate_id} terminalization returned no exact proof`);
384
+ return proof;
385
+ } catch (error) {
386
+ try {
387
+ await controller.cancelGateInteractions(
388
+ record.gate.gate_id,
389
+ "workflow gate presentation terminalization failed",
390
+ );
391
+ } finally {
392
+ this.quarantineGate(record.gate.gate_id);
393
+ }
394
+ throw error;
395
+ }
396
+ }
397
+
398
+ async #finalizeAccepted(record: PersistedGate): Promise<void> {
399
+ const policy = record.ackPolicy;
400
+ if (policy?.kind === "telegram_selected_v1" && policy.state === "pending") {
401
+ let outcome: AskSelectedAckOutcome;
402
+ const participant = this.#recoveryParticipant;
403
+ if (!participant) outcome = { status: "failed", reason: "no_participant" };
404
+ else {
405
+ this.#broker.updateAckPolicy(record.gate.gate_id, {
406
+ ...policy,
407
+ state: "attempt_started",
408
+ updatedAt: new Date().toISOString(),
409
+ });
410
+ try {
411
+ outcome = await participant.requestRecoveredAskSelectedAck({
412
+ sessionId: this.#runId,
413
+ actionId: policy.actionId,
414
+ commitKey: policy.commitKey,
415
+ deadlineAt: Date.now() + 8_000,
416
+ hostTimeoutMs: 10_000,
417
+ });
418
+ } catch {
419
+ outcome = { status: "unknown", reason: "host_timeout" };
420
+ }
421
+ }
422
+ this.#broker.updateAckPolicy(record.gate.gate_id, {
423
+ ...policy,
424
+ state: outcome.status,
425
+ outcome,
426
+ updatedAt: new Date().toISOString(),
427
+ });
428
+ }
429
+ if (policy?.kind === "telegram_selected_v1" && policy.state === "attempt_started") {
430
+ this.#broker.updateAckPolicy(record.gate.gate_id, {
431
+ ...policy,
432
+ state: "unknown",
433
+ outcome: { status: "unknown", reason: "shutdown" },
434
+ updatedAt: new Date().toISOString(),
435
+ });
436
+ }
437
+ }
438
+
439
+ #scheduleRecovery(): void {
440
+ if (this.#recoveryTimer || this.#recoveryAttempts >= 3) return;
441
+ const delay = 50 * 2 ** this.#recoveryAttempts++;
442
+ this.#recoveryTimer = setTimeout(() => {
443
+ this.#recoveryTimer = undefined;
444
+ void this.recoverAcceptedGates().catch(() => {
445
+ this.#scheduleRecovery();
446
+ });
447
+ }, delay);
448
+ }
449
+
450
+ async #startRecoveryOnce(options: { participantGraceMs?: number } = {}): Promise<void> {
451
+ if (this.#fenced) return;
452
+ if (this.#recoveryPromise) return this.#recoveryPromise;
453
+ this.#recoveryPromise = (async () => {
454
+ if (!this.#recoveryParticipant) {
455
+ const grace = Promise.withResolvers<void>();
456
+ this.#recoveryGraceWake = grace.resolve;
457
+ const graceMs = options.participantGraceMs ?? 2_000;
458
+ this.#recoveryGraceTimer = setTimeout(grace.resolve, graceMs);
459
+ await Promise.race([this.#participantReady.promise, grace.promise]);
460
+ if (this.#recoveryGraceTimer) clearTimeout(this.#recoveryGraceTimer);
461
+ this.#recoveryGraceTimer = undefined;
462
+ this.#recoveryGraceWake = undefined;
463
+ }
464
+ if (!this.#fenced) await this.recoverAcceptedGates();
465
+ })();
466
+ return this.#recoveryPromise;
467
+ }
468
+ }
469
+
470
+ const V1_STAGES: readonly WorkflowStage[] = ["deep-interview", "ralplan", "ultragoal"];
55
471
 
56
472
  export interface PersistedGate {
57
- gate: RpcWorkflowGate;
58
- status: "pending" | "accepted";
473
+ gate: WorkflowGate;
474
+ status: "pending" | "accepted" | "quarantined";
475
+ ownerInstanceId?: string;
59
476
  idempotencyKey?: string;
60
477
  responseHash?: string;
61
- /** Raw accepted answer, retained so a crashed advance can be replayed. */
478
+ /** Raw accepted answer, retained so a same-process advance can be replayed. */
62
479
  answer?: unknown;
63
- resolution?: RpcWorkflowGateResolution;
480
+ resolution?: WorkflowGateResolution;
64
481
  advanced: boolean;
482
+ /** Exact presentation terminalization completed before workflow advancement. */
483
+ terminalized?: boolean;
484
+ /** Exact presentation terminalization proof recorded before workflow advancement. */
485
+ terminalProof?: WorkflowGateTerminalProof;
65
486
  semanticDisposition?: PersistedSemanticDisposition;
66
487
  resolutionOrigin?: PersistedResolutionOrigin;
67
488
  ackPolicy?: PersistedAckPolicy;
489
+ lifecycle?: WorkflowGateDiagnostic["lifecycle"];
68
490
  }
69
491
 
70
492
  export interface GateStore {
71
- nextSeq(stage: RpcWorkflowStage): number;
493
+ nextSeq(stage: WorkflowStage): number;
72
494
  put(record: PersistedGate): void;
73
495
  get(gateId: string): PersistedGate | undefined;
74
- /** All persisted gate records (used for crash recovery). */
496
+ /** Atomically quarantines records owned by a prior runtime before exposure. */
497
+ beginRuntimeInstance(instanceId: string): void;
498
+ /** All persisted gate records (used for same-process recovery and Q12). */
75
499
  list(): PersistedGate[];
76
500
  }
77
501
 
78
502
  export class MemoryGateStore implements GateStore {
79
- private counters = new Map<RpcWorkflowStage, number>();
503
+ private counters = new Map<WorkflowStage, number>();
80
504
  private gates = new Map<string, PersistedGate>();
81
-
82
- nextSeq(stage: RpcWorkflowStage): number {
505
+ private runtimeInstanceId: string | undefined;
506
+ nextSeq(stage: WorkflowStage): number {
83
507
  const next = (this.counters.get(stage) ?? 0) + 1;
84
508
  this.counters.set(stage, next);
85
509
  return next;
@@ -91,21 +515,455 @@ export class MemoryGateStore implements GateStore {
91
515
  const r = this.gates.get(gateId);
92
516
  return r ? structuredClone(r) : undefined;
93
517
  }
518
+ beginRuntimeInstance(instanceId: string): void {
519
+ if (this.runtimeInstanceId === instanceId) return;
520
+ const priorInstanceId = this.runtimeInstanceId;
521
+ const now = new Date().toISOString();
522
+ for (const [gateId, record] of this.gates) {
523
+ if (record.ownerInstanceId !== priorInstanceId) continue;
524
+ if (record.status === "pending") {
525
+ this.gates.set(gateId, {
526
+ ...record,
527
+ status: "quarantined",
528
+ lifecycle: { state: "quarantined", reason: "orphaned_after_process_restart", quarantinedAt: now },
529
+ });
530
+ } else if (record.status === "accepted" && !record.advanced) {
531
+ this.gates.set(gateId, {
532
+ ...record,
533
+ status: "quarantined",
534
+ lifecycle: {
535
+ state: "quarantined",
536
+ reason: "accepted_unadvanced_after_process_restart",
537
+ quarantinedAt: now,
538
+ },
539
+ });
540
+ }
541
+ }
542
+ this.runtimeInstanceId = instanceId;
543
+ }
94
544
  list(): PersistedGate[] {
95
545
  return [...this.gates.values()].map(r => structuredClone(r));
96
546
  }
97
547
  }
98
548
 
99
549
  interface FileState {
100
- counters: Record<string, number>;
550
+ version: 1;
551
+ counters: Partial<Record<WorkflowStage, number>>;
101
552
  gates: Record<string, PersistedGate>;
553
+ runtimeInstanceId?: string;
554
+ }
555
+
556
+ const FILE_GATE_STORE_VERSION = 1;
557
+
558
+ function isObject(value: unknown): value is Record<string, unknown> {
559
+ return typeof value === "object" && value !== null && !Array.isArray(value);
560
+ }
561
+
562
+ function isNonEmptyString(value: unknown): value is string {
563
+ return typeof value === "string" && value.length > 0;
564
+ }
565
+
566
+ function isIsoDate(value: unknown): value is string {
567
+ return typeof value === "string" && Number.isFinite(Date.parse(value));
568
+ }
569
+
570
+ function invalidFileState(message: string): never {
571
+ throw new Error(`invalid gate store at ${message}`);
572
+ }
573
+
574
+ function migrateVersionlessV0SemanticDisposition(
575
+ value: unknown,
576
+ filePath: string,
577
+ ): PersistedSemanticDisposition | undefined {
578
+ if (value === undefined) return undefined;
579
+ if (value === "commit" || value === "resolve_without_commit") return value;
580
+ invalidFileState(filePath);
581
+ }
582
+
583
+ function migrateVersionlessV0ResolutionOrigin(value: unknown, filePath: string): PersistedResolutionOrigin | undefined {
584
+ if (value === undefined) return undefined;
585
+ if (!isObject(value)) invalidFileState(filePath);
586
+ if (value.kind === "telegram_notification" && isNonEmptyString(value.interactionActionId))
587
+ return { kind: "telegram_notification", interactionActionId: value.interactionActionId };
588
+ if (value.kind === "legacy_unknown") return { kind: "generic", channel: "other" };
589
+ if (value.kind !== "generic" || typeof value.channel !== "string") invalidFileState(filePath);
590
+ if (value.channel === "sdk" || value.channel === "other") return { kind: "generic", channel: value.channel };
591
+ if (value.channel === "rpc") return { kind: "generic", channel: "sdk" };
592
+ if (value.channel === "bridge") return { kind: "generic", channel: "other" };
593
+ invalidFileState(filePath);
594
+ }
595
+
596
+ function migrateVersionlessV0AckPolicy(value: unknown, filePath: string): PersistedAckPolicy | undefined {
597
+ if (value === undefined) return undefined;
598
+ if (!isObject(value)) invalidFileState(filePath);
599
+ if (
600
+ value.kind === "none" &&
601
+ (value.reason === "non_telegram" || value.reason === "semantic_noncommit" || value.reason === "legacy_unproven")
602
+ )
603
+ return { kind: "none", reason: value.reason };
604
+ if (
605
+ value.kind !== "telegram_selected_v1" ||
606
+ !isNonEmptyString(value.commitKey) ||
607
+ !isNonEmptyString(value.actionId) ||
608
+ !isIsoDate(value.updatedAt) ||
609
+ !(["pending", "attempt_started", "delivered", "failed", "unknown"] as const).includes(value.state as never)
610
+ )
611
+ invalidFileState(filePath);
612
+ if (value.outcome === undefined)
613
+ return {
614
+ kind: "telegram_selected_v1",
615
+ commitKey: value.commitKey,
616
+ actionId: value.actionId,
617
+ state: value.state as "pending" | "attempt_started" | "delivered" | "failed" | "unknown",
618
+ updatedAt: value.updatedAt,
619
+ };
620
+ if (!isObject(value.outcome)) invalidFileState(filePath);
621
+ if (
622
+ value.outcome.status === "delivered" &&
623
+ typeof value.outcome.messageId === "number" &&
624
+ Number.isSafeInteger(value.outcome.messageId)
625
+ )
626
+ return {
627
+ kind: "telegram_selected_v1",
628
+ commitKey: value.commitKey,
629
+ actionId: value.actionId,
630
+ state: value.state as "pending" | "attempt_started" | "delivered" | "failed" | "unknown",
631
+ outcome: { status: "delivered", messageId: value.outcome.messageId },
632
+ updatedAt: value.updatedAt,
633
+ };
634
+ if (
635
+ value.outcome.status === "failed" &&
636
+ (
637
+ [
638
+ "unsupported",
639
+ "no_participant",
640
+ "ambiguous_participant",
641
+ "route_missing",
642
+ "expired",
643
+ "cancelled",
644
+ "telegram_rejected",
645
+ "session_closed",
646
+ ] as const
647
+ ).includes(value.outcome.reason as never)
648
+ )
649
+ return {
650
+ kind: "telegram_selected_v1",
651
+ commitKey: value.commitKey,
652
+ actionId: value.actionId,
653
+ state: value.state as "pending" | "attempt_started" | "delivered" | "failed" | "unknown",
654
+ outcome: {
655
+ status: "failed",
656
+ reason: value.outcome.reason as
657
+ | "unsupported"
658
+ | "no_participant"
659
+ | "ambiguous_participant"
660
+ | "route_missing"
661
+ | "expired"
662
+ | "cancelled"
663
+ | "telegram_rejected"
664
+ | "session_closed",
665
+ },
666
+ updatedAt: value.updatedAt,
667
+ };
668
+ if (
669
+ value.outcome.status === "unknown" &&
670
+ (["transport_ambiguous", "origin_disconnected", "host_timeout", "shutdown"] as const).includes(
671
+ value.outcome.reason as never,
672
+ )
673
+ )
674
+ return {
675
+ kind: "telegram_selected_v1",
676
+ commitKey: value.commitKey,
677
+ actionId: value.actionId,
678
+ state: value.state as "pending" | "attempt_started" | "delivered" | "failed" | "unknown",
679
+ outcome: {
680
+ status: "unknown",
681
+ reason: value.outcome.reason as "transport_ambiguous" | "origin_disconnected" | "host_timeout" | "shutdown",
682
+ },
683
+ updatedAt: value.updatedAt,
684
+ };
685
+ invalidFileState(filePath);
686
+ }
687
+
688
+ function migrateVersionlessV0AcceptedRecord(legacy: Record<string, unknown>, filePath: string): PersistedGate {
689
+ if (typeof legacy.advanced !== "boolean") invalidFileState(filePath);
690
+ const semanticDisposition = migrateVersionlessV0SemanticDisposition(legacy.semanticDisposition, filePath);
691
+ const resolutionOrigin = migrateVersionlessV0ResolutionOrigin(legacy.resolutionOrigin, filePath);
692
+ const ackPolicy = migrateVersionlessV0AckPolicy(legacy.ackPolicy, filePath);
693
+ return {
694
+ ...(legacy as unknown as PersistedGate),
695
+ ...(semanticDisposition === undefined ? {} : { semanticDisposition }),
696
+ ...(resolutionOrigin === undefined ? {} : { resolutionOrigin }),
697
+ ...(ackPolicy === undefined ? {} : { ackPolicy }),
698
+ };
699
+ }
700
+
701
+ function migrateVersionlessV0(value: unknown, filePath: string): FileState {
702
+ if (!isObject(value) || Object.hasOwn(value, "version")) invalidFileState(filePath);
703
+ if (Object.keys(value).some(key => key !== "counters" && key !== "gates" && key !== "runtimeInstanceId"))
704
+ invalidFileState(filePath);
705
+ if (!isObject(value.counters) || !isObject(value.gates)) invalidFileState(filePath);
706
+ const gates: Record<string, PersistedGate> = {};
707
+ for (const [gateId, legacy] of Object.entries(value.gates)) {
708
+ if (!isObject(legacy) || !isObject(legacy.gate) || legacy.ownerInstanceId !== undefined)
709
+ invalidFileState(filePath);
710
+ const status = legacy.status ?? "pending";
711
+ if (status !== "pending" && status !== "accepted") invalidFileState(filePath);
712
+ if (status === "pending") {
713
+ gates[gateId] = {
714
+ gate: legacy.gate as unknown as WorkflowGate,
715
+ status: "quarantined",
716
+ ownerInstanceId: "legacy-v0",
717
+ advanced: false,
718
+ lifecycle: {
719
+ state: "quarantined",
720
+ reason: "orphaned_after_process_restart",
721
+ quarantinedAt: new Date().toISOString(),
722
+ },
723
+ };
724
+ continue;
725
+ }
726
+ const accepted = migrateVersionlessV0AcceptedRecord(legacy, filePath);
727
+ if (accepted.advanced) {
728
+ gates[gateId] = { ...accepted, ownerInstanceId: "legacy-v0" };
729
+ continue;
730
+ }
731
+ gates[gateId] = {
732
+ ...accepted,
733
+ status: "quarantined",
734
+ ownerInstanceId: "legacy-v0",
735
+ advanced: false,
736
+ lifecycle: {
737
+ state: "quarantined",
738
+ reason: "accepted_unadvanced_after_process_restart",
739
+ quarantinedAt: new Date().toISOString(),
740
+ },
741
+ };
742
+ }
743
+ const migrated: FileState = {
744
+ version: FILE_GATE_STORE_VERSION,
745
+ counters: value.counters as Partial<Record<WorkflowStage, number>>,
746
+ gates,
747
+ ...(value.runtimeInstanceId === undefined ? {} : { runtimeInstanceId: value.runtimeInstanceId as string }),
748
+ };
749
+ assertFileState(migrated, filePath);
750
+ return migrated;
751
+ }
752
+
753
+ function assertFileState(value: unknown, filePath: string): asserts value is FileState {
754
+ if (
755
+ !isObject(value) ||
756
+ value.version !== FILE_GATE_STORE_VERSION ||
757
+ !isObject(value.counters) ||
758
+ !isObject(value.gates)
759
+ )
760
+ invalidFileState(filePath);
761
+ for (const [stage, counter] of Object.entries(value.counters)) {
762
+ if (
763
+ !V1_STAGES.includes(stage as WorkflowStage) ||
764
+ typeof counter !== "number" ||
765
+ !Number.isSafeInteger(counter) ||
766
+ counter < 0
767
+ )
768
+ invalidFileState(filePath);
769
+ }
770
+ if (value.runtimeInstanceId !== undefined && !isNonEmptyString(value.runtimeInstanceId)) invalidFileState(filePath);
771
+ for (const [gateId, record] of Object.entries(value.gates))
772
+ assertPersistedGate(record, gateId, value.counters, filePath);
773
+ for (const [gateId, record] of Object.entries(value.gates)) {
774
+ if (
775
+ !isObject(record) ||
776
+ record.status !== "quarantined" ||
777
+ !isObject(record.lifecycle) ||
778
+ !isNonEmptyString(record.lifecycle.supersededByGateId)
779
+ )
780
+ continue;
781
+ if (
782
+ record.lifecycle.supersededByGateId === gateId ||
783
+ !Object.hasOwn(value.gates, record.lifecycle.supersededByGateId)
784
+ )
785
+ invalidFileState(filePath);
786
+ }
787
+ }
788
+
789
+ function assertPersistedGate(
790
+ value: unknown,
791
+ gateId: string,
792
+ counters: Record<string, unknown>,
793
+ filePath: string,
794
+ ): asserts value is PersistedGate {
795
+ if (
796
+ !isObject(value) ||
797
+ !isObject(value.gate) ||
798
+ value.gate.gate_id !== gateId ||
799
+ !isNonEmptyString(value.ownerInstanceId)
800
+ )
801
+ invalidFileState(filePath);
802
+ const gate = value.gate;
803
+ const stage = gate.stage as WorkflowStage;
804
+ const schema = gate.schema as WorkflowGate["schema"];
805
+ if (
806
+ gate.type !== "workflow_gate" ||
807
+ !isNonEmptyString(gate.gate_id) ||
808
+ !V1_STAGES.includes(stage) ||
809
+ !["question", "approval", "execution"].includes(gate.kind as string) ||
810
+ !isObject(gate.schema) ||
811
+ !isNonEmptyString(gate.schema_hash) ||
812
+ !isObject(gate.context) ||
813
+ !isIsoDate(gate.created_at) ||
814
+ gate.required !== true
815
+ )
816
+ invalidFileState(filePath);
817
+ const id = new RegExp(`^wg_[A-Za-z0-9]+_${gate.stage}_(\\d{6,})$`).exec(gate.gate_id);
818
+ if (
819
+ !id ||
820
+ typeof counters[stage] !== "number" ||
821
+ Number(id[1]) > counters[stage] ||
822
+ gate.schema_hash !== schemaHash(schema)
823
+ )
824
+ invalidFileState(filePath);
825
+ try {
826
+ compileGateSchema(schema);
827
+ } catch {
828
+ invalidFileState(filePath);
829
+ }
830
+ if (
831
+ gate.options !== undefined &&
832
+ (!Array.isArray(gate.options) ||
833
+ gate.options.some(option => !isObject(option) || !isNonEmptyString(option.label)))
834
+ )
835
+ invalidFileState(filePath);
836
+ if (value.status === "pending") {
837
+ if (
838
+ value.advanced !== false ||
839
+ value.terminalized !== undefined ||
840
+ value.terminalProof !== undefined ||
841
+ value.lifecycle !== undefined ||
842
+ value.resolution !== undefined ||
843
+ value.answer !== undefined ||
844
+ value.idempotencyKey !== undefined ||
845
+ value.responseHash !== undefined ||
846
+ value.semanticDisposition !== undefined ||
847
+ value.resolutionOrigin !== undefined ||
848
+ value.ackPolicy !== undefined
849
+ )
850
+ invalidFileState(filePath);
851
+ return;
852
+ }
853
+ if (value.status === "accepted") {
854
+ if (
855
+ typeof value.advanced !== "boolean" ||
856
+ (value.terminalized !== undefined && typeof value.terminalized !== "boolean") ||
857
+ (value.terminalProof !== undefined &&
858
+ value.terminalProof !== "retired" &&
859
+ value.terminalProof !== "already_terminal" &&
860
+ value.terminalProof !== "not_published") ||
861
+ (value.terminalized === true && value.terminalProof === undefined) ||
862
+ (value.terminalized !== true && value.terminalProof !== undefined) ||
863
+ !Object.hasOwn(value, "answer") ||
864
+ (value.idempotencyKey !== undefined && !isNonEmptyString(value.idempotencyKey)) ||
865
+ !isNonEmptyString(value.responseHash) ||
866
+ value.responseHash !== answerHashOf({ gate_id: gateId, answer: value.answer }) ||
867
+ !isObject(value.resolution) ||
868
+ value.resolution.gate_id !== gateId ||
869
+ value.resolution.status !== "accepted" ||
870
+ value.resolution.answer_hash !== answerHashOf(value.answer) ||
871
+ !isIsoDate(value.resolution.resolved_at) ||
872
+ value.resolution.error !== undefined ||
873
+ value.lifecycle !== undefined
874
+ )
875
+ invalidFileState(filePath);
876
+ return;
877
+ }
878
+ if (
879
+ value.status !== "quarantined" ||
880
+ value.advanced !== false ||
881
+ !isObject(value.lifecycle) ||
882
+ Object.keys(value.lifecycle).some(
883
+ key => key !== "state" && key !== "reason" && key !== "quarantinedAt" && key !== "supersededByGateId",
884
+ ) ||
885
+ value.lifecycle.state !== "quarantined" ||
886
+ ![
887
+ "orphaned_after_process_restart",
888
+ "accepted_unadvanced_after_process_restart",
889
+ "continuation_owner_lost",
890
+ "opened_without_continuation",
891
+ "finalization_failed",
892
+ "advance_failed",
893
+ ].includes(value.lifecycle.reason as string) ||
894
+ !isIsoDate(value.lifecycle.quarantinedAt) ||
895
+ (value.lifecycle.supersededByGateId !== undefined && !isNonEmptyString(value.lifecycle.supersededByGateId))
896
+ )
897
+ invalidFileState(filePath);
898
+ const hasAcceptedState =
899
+ Object.hasOwn(value, "answer") ||
900
+ value.terminalized !== undefined ||
901
+ value.terminalProof !== undefined ||
902
+ value.idempotencyKey !== undefined ||
903
+ value.responseHash !== undefined ||
904
+ value.resolution !== undefined ||
905
+ value.semanticDisposition !== undefined ||
906
+ value.resolutionOrigin !== undefined ||
907
+ value.ackPolicy !== undefined;
908
+ if (value.lifecycle.reason === "accepted_unadvanced_after_process_restart" || hasAcceptedState) {
909
+ if (
910
+ !Object.hasOwn(value, "answer") ||
911
+ (value.terminalized !== undefined && typeof value.terminalized !== "boolean") ||
912
+ (value.terminalProof !== undefined &&
913
+ value.terminalProof !== "retired" &&
914
+ value.terminalProof !== "already_terminal" &&
915
+ value.terminalProof !== "not_published") ||
916
+ (value.terminalized === true && value.terminalProof === undefined) ||
917
+ (value.terminalized !== true && value.terminalProof !== undefined) ||
918
+ (value.idempotencyKey !== undefined && !isNonEmptyString(value.idempotencyKey)) ||
919
+ !isNonEmptyString(value.responseHash) ||
920
+ value.responseHash !== answerHashOf({ gate_id: gateId, answer: value.answer }) ||
921
+ !isObject(value.resolution) ||
922
+ value.resolution.gate_id !== gateId ||
923
+ value.resolution.status !== "accepted" ||
924
+ value.resolution.answer_hash !== answerHashOf(value.answer) ||
925
+ !isIsoDate(value.resolution.resolved_at) ||
926
+ value.resolution.error !== undefined
927
+ )
928
+ invalidFileState(filePath);
929
+ return;
930
+ }
931
+ if (
932
+ value.lifecycle.reason === "accepted_unadvanced_after_process_restart" ||
933
+ value.lifecycle.reason === "finalization_failed" ||
934
+ value.lifecycle.reason === "advance_failed"
935
+ )
936
+ invalidFileState(filePath);
937
+ }
938
+
939
+ function isUnsupportedWindowsDirectorySyncError(error: unknown): boolean {
940
+ if (process.platform !== "win32") return false;
941
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
942
+ return code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP";
943
+ }
944
+
945
+ /** A write may have reached `rename` but not the directory fsync durability barrier. */
946
+ export class GateStoreWriteError extends Error {
947
+ constructor(
948
+ readonly certainty: "not_committed" | "uncertain",
949
+ cause: unknown,
950
+ ) {
951
+ super(`gate store write ${certainty}: ${cause instanceof Error ? cause.message : String(cause)}`);
952
+ this.name = "GateStoreWriteError";
953
+ }
102
954
  }
103
955
 
104
956
  /** Crash-durable JSON-file backed store. Writes the full state on every mutation. */
105
957
  export class FileGateStore implements GateStore {
106
958
  private state: FileState;
107
- constructor(private readonly filePath: string) {
108
- mkdirSync(path.dirname(filePath), { recursive: true });
959
+ private certaintyUnknown = false;
960
+ constructor(
961
+ private readonly filePath: string,
962
+ private readonly sync: (fd: number) => void = fsyncSync,
963
+ ) {
964
+ // Load eagerly so a corrupt store fails closed at construction, but defer
965
+ // directory creation to the first write so constructing a store under a
966
+ // non-writable cwd (e.g. a session that never emits a gate) never throws.
109
967
  this.state = this.load();
110
968
  }
111
969
  private load(): FileState {
@@ -113,14 +971,26 @@ export class FileGateStore implements GateStore {
113
971
  try {
114
972
  raw = readFileSync(this.filePath, "utf8");
115
973
  } catch (err) {
116
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return { counters: {}, gates: {} };
974
+ if ((err as NodeJS.ErrnoException).code === "ENOENT")
975
+ return { version: FILE_GATE_STORE_VERSION, counters: {}, gates: {} };
117
976
  throw err;
118
977
  }
978
+ let parsed: unknown;
119
979
  try {
120
- return JSON.parse(raw) as FileState;
980
+ parsed = JSON.parse(raw);
981
+ if (isObject(parsed) && !Object.hasOwn(parsed, "version")) {
982
+ const migrated = migrateVersionlessV0(parsed, this.filePath);
983
+ // Persist the v1 envelope before beginRuntimeInstance can quarantine or
984
+ // expose any record. This preserves all v0 records atomically.
985
+ this.flushState(migrated);
986
+ return migrated;
987
+ }
988
+ assertFileState(parsed, this.filePath);
989
+ return parsed;
121
990
  } catch (err) {
122
- // Fail closed: a corrupt state file must not be silently reset (that would
123
- // drop counters/gates and risk gate-id reuse). Quarantine + throw.
991
+ if (err instanceof GateStoreWriteError) throw err;
992
+ // Fail closed: invalid state must not be exposed or mutated because it can
993
+ // drop counters/gates and risk gate-id reuse.
124
994
  const quarantine = `${this.filePath}.corrupt-${Date.now()}`;
125
995
  try {
126
996
  renameSync(this.filePath, quarantine);
@@ -128,43 +998,105 @@ export class FileGateStore implements GateStore {
128
998
  /* best-effort quarantine */
129
999
  }
130
1000
  throw new Error(
131
- `corrupt gate store at ${this.filePath} (quarantined to ${quarantine}): ${(err as Error).message}`,
1001
+ `corrupt gate store at ${this.filePath} (quarantined to ${quarantine}): ${err instanceof Error ? err.message : String(err)}`,
132
1002
  );
133
1003
  }
134
1004
  }
135
- private flush(): void {
136
- // Atomic write: serialize to a temp file, fsync, then rename over the target.
1005
+ private flushState(next: FileState): void {
1006
+ mkdirSync(path.dirname(this.filePath), { recursive: true });
137
1007
  const tmp = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
138
- const fd = openSync(tmp, "w");
1008
+ let renamed = false;
139
1009
  try {
140
- writeFileSync(fd, JSON.stringify(this.state, null, 2));
141
- fsyncSync(fd);
142
- } finally {
143
- closeSync(fd);
1010
+ const fd = openSync(tmp, "w");
1011
+ try {
1012
+ writeFileSync(fd, JSON.stringify(next, null, 2));
1013
+ this.sync(fd);
1014
+ } finally {
1015
+ closeSync(fd);
1016
+ }
1017
+ renameSync(tmp, this.filePath);
1018
+ renamed = true;
1019
+ const parentFd = openSync(path.dirname(this.filePath), "r");
1020
+ try {
1021
+ try {
1022
+ this.sync(parentFd);
1023
+ } catch (error) {
1024
+ if (!isUnsupportedWindowsDirectorySyncError(error)) throw error;
1025
+ }
1026
+ } finally {
1027
+ closeSync(parentFd);
1028
+ }
1029
+ } catch (error) {
1030
+ if (renamed) {
1031
+ this.certaintyUnknown = true;
1032
+ try {
1033
+ this.state = this.load();
1034
+ } catch {
1035
+ // The original uncertain error is the authority; later operations fail closed.
1036
+ }
1037
+ }
1038
+ throw new GateStoreWriteError(renamed ? "uncertain" : "not_committed", error);
144
1039
  }
145
- renameSync(tmp, this.filePath);
146
1040
  }
147
- nextSeq(stage: RpcWorkflowStage): number {
1041
+ private commit(next: FileState): void {
1042
+ this.flushState(next);
1043
+ this.state = next;
1044
+ }
1045
+ private reconcileCertainty(): void {
1046
+ if (!this.certaintyUnknown) return;
1047
+ this.state = this.load();
1048
+ this.certaintyUnknown = false;
1049
+ }
1050
+ nextSeq(stage: WorkflowStage): number {
1051
+ this.reconcileCertainty();
148
1052
  const next = (this.state.counters[stage] ?? 0) + 1;
149
- this.state.counters[stage] = next;
150
- this.flush();
1053
+ const candidate = structuredClone(this.state);
1054
+ candidate.counters[stage] = next;
1055
+ this.commit(candidate);
151
1056
  return next;
152
1057
  }
153
1058
  put(record: PersistedGate): void {
154
- this.state.gates[record.gate.gate_id] = record;
155
- this.flush();
1059
+ this.reconcileCertainty();
1060
+ const candidate = structuredClone(this.state);
1061
+ candidate.gates[record.gate.gate_id] = structuredClone(record);
1062
+ this.commit(candidate);
156
1063
  }
157
1064
  get(gateId: string): PersistedGate | undefined {
1065
+ this.reconcileCertainty();
158
1066
  const r = this.state.gates[gateId];
159
- return r ? (JSON.parse(JSON.stringify(r)) as PersistedGate) : undefined;
1067
+ return r ? structuredClone(r) : undefined;
1068
+ }
1069
+ beginRuntimeInstance(instanceId: string): void {
1070
+ this.reconcileCertainty();
1071
+ if (this.state.runtimeInstanceId === instanceId) return;
1072
+ const priorInstanceId = this.state.runtimeInstanceId;
1073
+ const next = structuredClone(this.state);
1074
+ const now = new Date().toISOString();
1075
+ for (const record of Object.values(next.gates)) {
1076
+ if (record.ownerInstanceId !== priorInstanceId) continue;
1077
+ if (record.status === "pending") {
1078
+ record.status = "quarantined";
1079
+ record.lifecycle = { state: "quarantined", reason: "orphaned_after_process_restart", quarantinedAt: now };
1080
+ } else if (record.status === "accepted" && !record.advanced) {
1081
+ record.status = "quarantined";
1082
+ record.lifecycle = {
1083
+ state: "quarantined",
1084
+ reason: "accepted_unadvanced_after_process_restart",
1085
+ quarantinedAt: now,
1086
+ };
1087
+ }
1088
+ }
1089
+ next.runtimeInstanceId = instanceId;
1090
+ this.commit(next);
160
1091
  }
161
1092
  list(): PersistedGate[] {
162
- return Object.values(this.state.gates).map(r => JSON.parse(JSON.stringify(r)) as PersistedGate);
1093
+ this.reconcileCertainty();
1094
+ return Object.values(this.state.gates).map(r => structuredClone(r));
163
1095
  }
164
1096
  }
165
1097
 
166
1098
  export type GateAuditEvent =
167
- | { event: "gate_emitted"; gate_id: string; stage: RpcWorkflowStage; kind: RpcWorkflowGateKind }
1099
+ | { event: "gate_emitted"; gate_id: string; stage: WorkflowStage; kind: WorkflowGateKind }
168
1100
  | { event: "gate_response_accepted"; gate_id: string; answer_hash: string }
169
1101
  | { event: "gate_response_rejected"; gate_id: string; answer_hash: string }
170
1102
  | { event: "gate_response_idempotent_replay"; gate_id: string }
@@ -175,26 +1107,44 @@ export type GateAuditEvent =
175
1107
 
176
1108
  export interface BrokerHooks {
177
1109
  /** Called once when a pending gate has been persisted and should be emitted. */
178
- emit?(gate: RpcWorkflowGate): void;
1110
+ emit?(gate: WorkflowGate): void;
179
1111
  /**
180
1112
  * Invoked to advance the workflow after an accepted resolution is durably
181
1113
  * committed. MUST be idempotent keyed by `gate.gate_id`: `recover()` replays
182
1114
  * it for any gate left `accepted` but not `advanced` by a crash.
183
1115
  */
184
- advance?(gate: RpcWorkflowGate, answer: unknown): void | Promise<void>;
185
- /** Runs after durable acceptance and before advance, including crash recovery. */
1116
+ advance?(gate: WorkflowGate, answer: unknown): void | Promise<void>;
1117
+ /** Runs after durable acceptance and before presentation terminalization, including crash recovery. */
186
1118
  finalizeAccepted?(record: PersistedGate): Promise<void>;
187
-
1119
+ /**
1120
+ * Terminalizes the exact presentation before `advance` can run and returns
1121
+ * proof for the durable record. `not_published` explicitly proves that no
1122
+ * presentation existed. On failure it MUST revoke authority or throw.
1123
+ */
1124
+ terminalizeAccepted?(record: PersistedGate): WorkflowGateTerminalProof | Promise<WorkflowGateTerminalProof>;
1125
+ /** Runs only after a successful advance has been durably marked advanced. */
1126
+ completeAccepted?(record: PersistedGate): void | Promise<void>;
188
1127
  /** Append-only audit sink. */
189
1128
  audit?(event: GateAuditEvent): void;
190
1129
  }
191
1130
 
1131
+ /** Live in-process continuation authority for one emitted gate. */
1132
+ export interface GateContinuation {
1133
+ activate(gate: WorkflowGate): void;
1134
+ isLive(gateId: string): boolean;
1135
+ release?(gateId: string): void;
1136
+ /** Explicitly declares that this direct continuation owns no presentation. */
1137
+ terminalProof?: "not_published";
1138
+ }
1139
+
192
1140
  export interface OpenGateInput {
193
- stage: RpcWorkflowStage;
194
- kind: RpcWorkflowGateKind;
195
- schema: RpcWorkflowGate["schema"];
196
- options?: RpcWorkflowGateOption[];
197
- context?: RpcWorkflowGateContext;
1141
+ stage: WorkflowStage;
1142
+ kind: WorkflowGateKind;
1143
+ schema: WorkflowGate["schema"];
1144
+ options?: WorkflowGateOption[];
1145
+ context?: WorkflowGateContext;
1146
+ /** Optional diagnostic gate replaced by this newly persisted gate. */
1147
+ supersedesGateId?: string;
198
1148
  }
199
1149
 
200
1150
  export class WorkflowGateBrokerError extends Error {
@@ -210,6 +1160,8 @@ export class WorkflowGateBrokerError extends Error {
210
1160
  export class WorkflowGateBroker {
211
1161
  private readonly recovering = new Set<string>();
212
1162
  private readonly gateLocks = new Map<string, Promise<void>>();
1163
+ private readonly continuations = new Map<string, GateContinuation>();
1164
+ readonly #terminalProofs = new Map<string, WorkflowGateTerminalProof>();
213
1165
 
214
1166
  private async withGateLock<T>(gateId: string, operation: () => Promise<T>): Promise<T> {
215
1167
  const previous = this.gateLocks.get(gateId) ?? Promise.resolve();
@@ -229,14 +1181,17 @@ export class WorkflowGateBroker {
229
1181
  private readonly runId: string,
230
1182
  private readonly store: GateStore,
231
1183
  private readonly hooks: BrokerHooks = {},
232
- ) {}
1184
+ private readonly instanceId = crypto.randomUUID(),
1185
+ ) {
1186
+ this.store.beginRuntimeInstance(this.instanceId);
1187
+ }
233
1188
 
234
1189
  classifyDisposition(gateId: string, answer: unknown): PersistedSemanticDisposition {
235
1190
  const record = this.store.get(gateId);
236
1191
  if (!record) throw new WorkflowGateBrokerError("unknown_gate", `no pending gate ${gateId}`);
237
1192
  return classifyAskGateDisposition(record.gate, answer);
238
1193
  }
239
- validationError(gateId: string, keyword: string, message: string): RpcWorkflowGateValidationError {
1194
+ validationError(gateId: string, keyword: string, message: string): WorkflowGateValidationError {
240
1195
  const record = this.store.get(gateId);
241
1196
  return {
242
1197
  code: "invalid_workflow_gate_answer",
@@ -253,12 +1208,144 @@ export class WorkflowGateBroker {
253
1208
  this.store.put({ ...record, ackPolicy });
254
1209
  }
255
1210
 
1211
+ async #terminalize(record: PersistedGate): Promise<void> {
1212
+ const proof = this.#terminalProofs.get(record.gate.gate_id) ?? (await this.hooks.terminalizeAccepted?.(record));
1213
+ this.#terminalProofs.delete(record.gate.gate_id);
1214
+ if (proof !== "retired" && proof !== "already_terminal" && proof !== "not_published")
1215
+ throw new Error(`workflow gate ${record.gate.gate_id} has no terminalization proof`);
1216
+ const terminalized = this.store.get(record.gate.gate_id);
1217
+ if (terminalized?.status !== "accepted" || terminalized.advanced)
1218
+ throw new WorkflowGateBrokerError(
1219
+ "unknown_gate",
1220
+ `accepted gate ${record.gate.gate_id} disappeared before advance`,
1221
+ );
1222
+ this.store.put({ ...terminalized, terminalized: true, terminalProof: proof });
1223
+ }
1224
+
256
1225
  private runShort(): string {
257
1226
  return this.runId.replace(/[^a-zA-Z0-9]/g, "").slice(-8) || "run";
258
1227
  }
259
1228
 
1229
+ private hasLiveContinuation(gateId: string): boolean {
1230
+ return this.continuations.get(gateId)?.isLive(gateId) === true;
1231
+ }
1232
+
1233
+ hasRecoverableAcceptedGate(): boolean {
1234
+ return this.store
1235
+ .list()
1236
+ .some(
1237
+ record =>
1238
+ record.status === "accepted" &&
1239
+ !record.advanced &&
1240
+ record.ownerInstanceId === this.instanceId &&
1241
+ this.hasLiveContinuation(record.gate.gate_id),
1242
+ );
1243
+ }
1244
+ prepareTerminalization(gateId: string, proof: WorkflowGateTerminalProof): boolean {
1245
+ const record = this.store.get(gateId);
1246
+ if (
1247
+ record?.status !== "pending" ||
1248
+ record.ownerInstanceId !== this.instanceId ||
1249
+ !this.hasLiveContinuation(gateId)
1250
+ )
1251
+ return false;
1252
+ this.#terminalProofs.set(gateId, proof);
1253
+ return true;
1254
+ }
1255
+ clearPreparedTerminalization(gateId: string): void {
1256
+ this.#terminalProofs.delete(gateId);
1257
+ }
1258
+
1259
+ /**
1260
+ * Fences a gate after an uncertain control outcome by deliberately releasing
1261
+ * this runtime's continuation authority. The durable diagnostic is retained
1262
+ * for recovery; it is never republished as a pending gate.
1263
+ */
1264
+ quarantineGate(gateId: string): void {
1265
+ this.loseContinuation(gateId);
1266
+ }
1267
+
1268
+ /** Revoke every live continuation, including accepted-but-unadvanced recovery authority. */
1269
+ fenceContinuations(): void {
1270
+ for (const gateId of [...this.continuations.keys()]) this.loseContinuation(gateId);
1271
+ }
1272
+
1273
+ /** Fence a cancelled/lost continuation before it can be exposed or advanced. */
1274
+ loseContinuation(gateId: string): void {
1275
+ const continuation = this.continuations.get(gateId);
1276
+ continuation?.release?.(gateId);
1277
+ this.continuations.delete(gateId);
1278
+ this.#terminalProofs.delete(gateId);
1279
+ const record = this.store.get(gateId);
1280
+ if (
1281
+ record?.ownerInstanceId !== this.instanceId ||
1282
+ record.status === "quarantined" ||
1283
+ (record.status === "accepted" && record.advanced)
1284
+ )
1285
+ return;
1286
+ this.store.put({
1287
+ ...record,
1288
+ status: "quarantined",
1289
+ advanced: false,
1290
+ lifecycle: {
1291
+ state: "quarantined",
1292
+ reason: "continuation_owner_lost",
1293
+ quarantinedAt: new Date().toISOString(),
1294
+ },
1295
+ });
1296
+ }
1297
+
1298
+ listPendingGates(): WorkflowGate[] {
1299
+ return this.store
1300
+ .list()
1301
+ .filter(
1302
+ record =>
1303
+ record.status === "pending" &&
1304
+ record.ownerInstanceId === this.instanceId &&
1305
+ this.hasLiveContinuation(record.gate.gate_id),
1306
+ )
1307
+ .sort(compareGates)
1308
+ .map(record => record.gate);
1309
+ }
1310
+
1311
+ listGateDiagnostics(): WorkflowGateDiagnostic[] {
1312
+ return this.store
1313
+ .list()
1314
+ .filter(
1315
+ (
1316
+ record,
1317
+ ): record is PersistedGate & { status: "quarantined"; lifecycle: WorkflowGateDiagnostic["lifecycle"] } =>
1318
+ record.status === "quarantined" && record.lifecycle !== undefined,
1319
+ )
1320
+ .sort(
1321
+ (left, right) =>
1322
+ left.lifecycle.quarantinedAt.localeCompare(right.lifecycle.quarantinedAt) ||
1323
+ left.gate.gate_id.localeCompare(right.gate.gate_id),
1324
+ )
1325
+ .map(record => ({
1326
+ ...record.gate,
1327
+ id: `diagnostic:${record.gate.gate_id}`,
1328
+ tag: "quarantined",
1329
+ lifecycle: record.lifecycle,
1330
+ }));
1331
+ }
1332
+
1333
+ listWorkflowGateQueryRecords(): WorkflowGateQueryRecord[] {
1334
+ const pending: WorkflowGateQueryRecord[] = this.store
1335
+ .list()
1336
+ .filter(
1337
+ record =>
1338
+ record.status === "pending" &&
1339
+ record.ownerInstanceId === this.instanceId &&
1340
+ this.hasLiveContinuation(record.gate.gate_id),
1341
+ )
1342
+ .sort(compareGates)
1343
+ .map(record => ({ ...record.gate, id: `pending:${record.gate.gate_id}`, tag: "pending" }));
1344
+ return [...pending, ...this.listGateDiagnostics()];
1345
+ }
1346
+
260
1347
  /** Open and emit a gate. The pending record is persisted BEFORE emission. */
261
- openGate(input: OpenGateInput): RpcWorkflowGate {
1348
+ openGate(input: OpenGateInput, continuation?: GateContinuation): WorkflowGate {
262
1349
  if (RESERVED_WORKFLOW_STAGES.includes(input.stage) || !V1_STAGES.includes(input.stage)) {
263
1350
  throw new WorkflowGateBrokerError(
264
1351
  "invalid_workflow_stage",
@@ -269,7 +1356,7 @@ export class WorkflowGateBroker {
269
1356
  compileGateSchema(input.schema);
270
1357
  const seq = this.store.nextSeq(input.stage).toString().padStart(6, "0");
271
1358
  const gateId = `wg_${this.runShort()}_${input.stage}_${seq}`;
272
- const gate: RpcWorkflowGate = {
1359
+ const gate: WorkflowGate = {
273
1360
  type: "workflow_gate",
274
1361
  gate_id: gateId,
275
1362
  stage: input.stage,
@@ -281,8 +1368,30 @@ export class WorkflowGateBroker {
281
1368
  created_at: new Date().toISOString(),
282
1369
  required: true,
283
1370
  };
284
- // Persist pending BEFORE emit so a crash never loses an emitted gate.
285
- this.store.put({ gate, status: "pending", advanced: false });
1371
+ if (!continuation) {
1372
+ this.store.put({
1373
+ gate,
1374
+ status: "quarantined",
1375
+ ownerInstanceId: this.instanceId,
1376
+ advanced: false,
1377
+ lifecycle: {
1378
+ state: "quarantined",
1379
+ reason: "opened_without_continuation",
1380
+ quarantinedAt: new Date().toISOString(),
1381
+ },
1382
+ });
1383
+ return gate;
1384
+ }
1385
+ this.store.put({ gate, status: "pending", ownerInstanceId: this.instanceId, advanced: false });
1386
+ continuation.activate(gate);
1387
+ if (continuation.terminalProof === "not_published") this.#terminalProofs.set(gateId, "not_published");
1388
+ this.continuations.set(gateId, continuation);
1389
+ if (input.supersedesGateId) {
1390
+ const superseded = this.store.get(input.supersedesGateId);
1391
+ if (superseded?.status === "quarantined" && superseded.lifecycle) {
1392
+ this.store.put({ ...superseded, lifecycle: { ...superseded.lifecycle, supersededByGateId: gateId } });
1393
+ }
1394
+ }
286
1395
  this.hooks.emit?.(gate);
287
1396
  this.hooks.audit?.({ event: "gate_emitted", gate_id: gateId, stage: gate.stage, kind: gate.kind });
288
1397
  return gate;
@@ -290,33 +1399,41 @@ export class WorkflowGateBroker {
290
1399
 
291
1400
  /**
292
1401
  * Resolve a gate with an answer. Validates against the advertised schema.
293
- * On success the resolution is persisted BEFORE `advance` is invoked exactly
294
- * once. Invalid answers leave the gate pending (per #315 acceptance).
1402
+ * Accepted answers are persisted first, then their presentation is terminalized,
1403
+ * then the workflow advances and commits `advanced:true` exactly once. Invalid
1404
+ * answers leave the gate pending (per #315 acceptance).
295
1405
  */
296
- async resolve(
297
- response: RpcWorkflowGateResponse,
298
- options: GateResolutionOptions = {},
299
- ): Promise<RpcWorkflowGateResolution> {
300
- return this.withGateLock(response.gate_id, () => this.resolveUnlocked(response, options));
1406
+ async resolve(response: WorkflowGateResponse, options: GateResolutionOptions = {}): Promise<WorkflowGateResolution> {
1407
+ try {
1408
+ return await this.withGateLock(response.gate_id, () => this.resolveUnlocked(response, options));
1409
+ } catch (error) {
1410
+ if (error instanceof GateStoreWriteError && error.certainty === "uncertain") {
1411
+ try {
1412
+ this.quarantineGate(response.gate_id);
1413
+ } catch {
1414
+ // Continuation authority was still released; preserve the uncertain write error.
1415
+ }
1416
+ }
1417
+ throw error;
1418
+ }
301
1419
  }
302
1420
 
303
1421
  private async resolveUnlocked(
304
- response: RpcWorkflowGateResponse,
1422
+ response: WorkflowGateResponse,
305
1423
  options: GateResolutionOptions = {},
306
- ): Promise<RpcWorkflowGateResolution> {
1424
+ ): Promise<WorkflowGateResolution> {
307
1425
  const record = this.store.get(response.gate_id);
308
- if (!record) {
1426
+ if (!record || record.status === "quarantined") {
309
1427
  this.hooks.audit?.({ event: "gate_response_unknown_gate", gate_id: response.gate_id });
310
- throw new WorkflowGateBrokerError("unknown_gate", `no pending gate ${response.gate_id}`);
1428
+ throw new WorkflowGateBrokerError("unknown_gate", `no live pending gate ${response.gate_id}`);
311
1429
  }
312
1430
  const responseHash = answerHashOf({ gate_id: response.gate_id, answer: response.answer });
313
-
314
1431
  if (record.status === "accepted") {
315
1432
  const sameBody = record.responseHash === responseHash;
316
1433
  const sameKey = record.idempotencyKey === response.idempotency_key;
317
1434
  if (response.idempotency_key !== undefined && sameKey && sameBody) {
318
1435
  this.hooks.audit?.({ event: "gate_response_idempotent_replay", gate_id: response.gate_id });
319
- return record.resolution as RpcWorkflowGateResolution;
1436
+ return record.resolution as WorkflowGateResolution;
320
1437
  }
321
1438
  if (response.idempotency_key !== undefined && sameKey && !sameBody) {
322
1439
  this.hooks.audit?.({ event: "gate_response_idempotency_conflict", gate_id: response.gate_id });
@@ -328,6 +1445,10 @@ export class WorkflowGateBroker {
328
1445
  this.hooks.audit?.({ event: "gate_response_already_resolved", gate_id: response.gate_id });
329
1446
  throw new WorkflowGateBrokerError("already_resolved", `already_resolved: gate ${response.gate_id}`);
330
1447
  }
1448
+ if (record.ownerInstanceId !== this.instanceId || !this.hasLiveContinuation(response.gate_id)) {
1449
+ this.hooks.audit?.({ event: "gate_response_unknown_gate", gate_id: response.gate_id });
1450
+ throw new WorkflowGateBrokerError("unknown_gate", `no live pending gate ${response.gate_id}`);
1451
+ }
331
1452
 
332
1453
  const compiled = compileGateSchema(record.gate.schema);
333
1454
  const validationError = validateGateAnswer(compiled, response.gate_id, response.answer);
@@ -346,12 +1467,12 @@ export class WorkflowGateBroker {
346
1467
 
347
1468
  const semanticDisposition =
348
1469
  options.semanticDisposition ?? classifyAskGateDisposition(record.gate, response.answer);
349
- const resolutionOrigin = options.resolutionOrigin ?? { kind: "generic", channel: "rpc" as const };
1470
+ const resolutionOrigin = options.resolutionOrigin ?? { kind: "generic", channel: "sdk" as const };
350
1471
  const ackPolicy =
351
1472
  options.ackPolicy ??
352
1473
  ({ kind: "none", reason: semanticDisposition === "commit" ? "non_telegram" : "semantic_noncommit" } as const);
353
1474
 
354
- const resolution: RpcWorkflowGateResolution = {
1475
+ const resolution: WorkflowGateResolution = {
355
1476
  gate_id: response.gate_id,
356
1477
  status: "accepted",
357
1478
  answer_hash: answerHash,
@@ -363,11 +1484,13 @@ export class WorkflowGateBroker {
363
1484
  this.store.put({
364
1485
  gate: record.gate,
365
1486
  status: "accepted",
1487
+ ownerInstanceId: record.ownerInstanceId,
366
1488
  idempotencyKey: response.idempotency_key,
367
1489
  responseHash,
368
1490
  answer: response.answer,
369
1491
  resolution,
370
1492
  advanced: false,
1493
+ terminalized: false,
371
1494
  semanticDisposition,
372
1495
  resolutionOrigin,
373
1496
  ackPolicy,
@@ -379,19 +1502,32 @@ export class WorkflowGateBroker {
379
1502
  if (finalized?.status !== "accepted") {
380
1503
  throw new WorkflowGateBrokerError(
381
1504
  "unknown_gate",
382
- `accepted gate ${response.gate_id} disappeared before advance`,
1505
+ `accepted gate ${response.gate_id} disappeared before terminalization`,
383
1506
  );
384
1507
  }
385
1508
  if (finalized.advanced) return finalized.resolution ?? resolution;
386
- await this.hooks.advance?.(finalized.gate, finalized.answer);
387
- const latest = this.store.get(response.gate_id);
388
- if (latest?.status !== "accepted") {
1509
+ if (!finalized.terminalized) await this.#terminalize(finalized);
1510
+ const terminalized = this.store.get(response.gate_id);
1511
+ if (terminalized?.status !== "accepted" || terminalized.advanced) {
389
1512
  throw new WorkflowGateBrokerError(
390
1513
  "unknown_gate",
391
- `accepted gate ${response.gate_id} disappeared after advance`,
1514
+ `accepted gate ${response.gate_id} disappeared before advance`,
392
1515
  );
393
1516
  }
1517
+ if (!this.hooks.advance || !this.hasLiveContinuation(response.gate_id)) {
1518
+ throw new WorkflowGateBrokerError("unknown_gate", `no live continuation for gate ${response.gate_id}`);
1519
+ }
1520
+ await this.hooks.advance(terminalized.gate, terminalized.answer);
1521
+ const latest = this.store.get(response.gate_id);
1522
+ if (latest?.status !== "accepted" || !this.hasLiveContinuation(response.gate_id))
1523
+ throw new WorkflowGateBrokerError(
1524
+ "unknown_gate",
1525
+ `accepted gate ${response.gate_id} lost its continuation before advance completed`,
1526
+ );
394
1527
  this.store.put({ ...latest, advanced: true });
1528
+ await this.hooks.completeAccepted?.(this.store.get(response.gate_id) as PersistedGate);
1529
+ this.continuations.get(response.gate_id)?.release?.(response.gate_id);
1530
+ this.continuations.delete(response.gate_id);
395
1531
  return resolution;
396
1532
  }
397
1533
 
@@ -404,7 +1540,14 @@ export class WorkflowGateBroker {
404
1540
  async recover(): Promise<string[]> {
405
1541
  const recovered: string[] = [];
406
1542
  for (const listed of this.store.list()) {
407
- if (listed.status !== "accepted" || listed.advanced || this.recovering.has(listed.gate.gate_id)) continue;
1543
+ if (
1544
+ listed.status !== "accepted" ||
1545
+ listed.advanced ||
1546
+ listed.ownerInstanceId !== this.instanceId ||
1547
+ !this.hasLiveContinuation(listed.gate.gate_id) ||
1548
+ this.recovering.has(listed.gate.gate_id)
1549
+ )
1550
+ continue;
408
1551
  this.recovering.add(listed.gate.gate_id);
409
1552
  try {
410
1553
  await this.withGateLock(listed.gate.gate_id, async () => {
@@ -412,11 +1555,29 @@ export class WorkflowGateBroker {
412
1555
  if (rec?.status !== "accepted" || rec.advanced) return;
413
1556
  await this.hooks.finalizeAccepted?.(rec);
414
1557
  const finalized = this.store.get(listed.gate.gate_id);
415
- if (finalized?.status !== "accepted" || finalized.advanced) return;
416
- await this.hooks.advance?.(finalized.gate, finalized.answer);
1558
+ if (
1559
+ finalized?.status !== "accepted" ||
1560
+ finalized.advanced ||
1561
+ !this.hasLiveContinuation(listed.gate.gate_id)
1562
+ )
1563
+ return;
1564
+ if (!finalized.terminalized) await this.#terminalize(finalized);
1565
+ const terminalized = this.store.get(listed.gate.gate_id);
1566
+ if (
1567
+ terminalized?.status !== "accepted" ||
1568
+ terminalized.advanced ||
1569
+ !this.hooks.advance ||
1570
+ !this.hasLiveContinuation(listed.gate.gate_id)
1571
+ )
1572
+ return;
1573
+ await this.hooks.advance(terminalized.gate, terminalized.answer);
417
1574
  const latest = this.store.get(listed.gate.gate_id);
418
- if (latest?.status !== "accepted" || latest.advanced) return;
1575
+ if (latest?.status !== "accepted" || latest.advanced || !this.hasLiveContinuation(listed.gate.gate_id))
1576
+ return;
419
1577
  this.store.put({ ...latest, advanced: true });
1578
+ await this.hooks.completeAccepted?.(this.store.get(latest.gate.gate_id) as PersistedGate);
1579
+ this.continuations.get(latest.gate.gate_id)?.release?.(latest.gate.gate_id);
1580
+ this.continuations.delete(latest.gate.gate_id);
420
1581
  this.hooks.audit?.({ event: "gate_advance_recovered", gate_id: latest.gate.gate_id });
421
1582
  recovered.push(latest.gate.gate_id);
422
1583
  });
@@ -432,3 +1593,9 @@ export class WorkflowGateBroker {
432
1593
  return canonicalJson(value);
433
1594
  }
434
1595
  }
1596
+
1597
+ function compareGates(left: PersistedGate, right: PersistedGate): number {
1598
+ return (
1599
+ left.gate.created_at.localeCompare(right.gate.created_at) || left.gate.gate_id.localeCompare(right.gate.gate_id)
1600
+ );
1601
+ }