@openchambery/web 1.19.22-beta.1 → 2.0.0-beta.3
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.
- package/README.md +13 -7
- package/bin/cli-opencode.test.js +208 -0
- package/bin/cli.js +62 -9
- package/bin/lib/cli-startup.js +105 -2
- package/bin/lib/cli-startup.test.js +179 -0
- package/dist/assets/{AgentAvatar-CRaBJXKs.js → AgentAvatar-B_JQoreg.js} +1 -1
- package/dist/assets/AssistantView-ClQAfGOT.js +3 -0
- package/dist/assets/{DiagramView-D1LGK_s2.js → DiagramView-BNnda8qO.js} +1 -1
- package/dist/assets/{FadeInOnReveal-B0kJo9Ii.js → FadeInOnReveal-VYQTSAkr.js} +1 -1
- package/dist/assets/{Icon-BgdfPhNs.js → Icon-BiEQEcih.js} +1 -1
- package/dist/assets/MarkdownRenderer-CJHSsWin.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-DfoCJ1Mq.js → MarkdownRendererImpl-DwrHFGUp.js} +3 -3
- package/dist/assets/{MarkstreamRendererImpl-D2dsW5Lg.js → MarkstreamRendererImpl-CSnbQMu3.js} +1 -1
- package/dist/assets/MobileDetailNavigation-BXr6AGLT.js +1 -0
- package/dist/assets/{MobileShareBridge-2Yjn6wXg.js → MobileShareBridge-D7OE9OP_.js} +2 -2
- package/dist/assets/MobileSurface-D2jUkX9l.js +2 -0
- package/dist/assets/{MultiRunWindow-tTbMFowS.js → MultiRunWindow-DF3iIFCd.js} +1 -1
- package/dist/assets/{OnboardingScreen-CD2KtlF3.js → OnboardingScreen-BjHmlwlw.js} +1 -1
- package/dist/assets/OverlayScrollbar-DLtWO_Gp.js +2 -0
- package/dist/assets/SettingsView-BRxELjow.js +25 -0
- package/dist/assets/{SettingsWindow-zIa76s4t.js → SettingsWindow-BI5swspb.js} +1 -1
- package/dist/assets/{TerminalView-CDdzF4yi.js → TerminalView-BU3x8-fr.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DNqA4uBl.js → ToolOutputDialog-BEWhBcRd.js} +2 -2
- package/dist/assets/appThemeRegistry-4MR0F7Jz.js +1 -0
- package/dist/assets/{button-CI7Q-Zw_.js → button-DKuz3L8l.js} +1 -1
- package/dist/assets/collapsible-DXbDynN4.js +3 -0
- package/dist/assets/context-menu-CqolsLCm.js +1 -0
- package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-CoDkOnyS.js} +1 -1
- package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DDlVKyOE.js} +1 -1
- package/dist/assets/{diagram-DdLO8HZ9.js → diagram-D7T8HQgj.js} +1 -1
- package/dist/assets/{dialog-B6pnIYmp.js → dialog-DQYl3W_o.js} +1 -1
- package/dist/assets/es-CN3E_zXj.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-DNeiqns-.js → fileMentionTouchSelection-BZ6ovBYH.js} +1 -1
- package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-JrpcsOCs.js} +1 -1
- package/dist/assets/fr-9zZvQZD2.js +13 -0
- package/dist/assets/gitApi-CM42qUed.js +10 -0
- package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-CIl0FL10.js} +1 -1
- package/dist/assets/{input-D-__fwRi.js → input-CzQy8UhV.js} +1 -1
- package/dist/assets/insertionBoundaries-CtkZ9720.js +1 -0
- package/dist/assets/ja-Dnjk_82N.js +15 -0
- package/dist/assets/ko-BxCEE99C.js +15 -0
- package/dist/assets/main-BILgBI3B.js +169 -0
- package/dist/assets/main-BeveiW6F.js +2 -0
- package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
- package/dist/assets/{markdownHeightCache-BhxqJOvf.js → markdownHeightCache-Cj1I5NDw.js} +1 -1
- package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
- package/dist/assets/mcpQueries-D5mQymgM.js +1 -0
- package/dist/assets/miniChat-BgrzM3u7.js +2 -0
- package/dist/assets/mobile-DtYoQE8C.js +2 -0
- package/dist/assets/multirun-jy8dEI96.js +4 -0
- package/dist/assets/{persistence-Ce3AnpEr.js → persistence-C9LQpiOS.js} +2 -2
- package/dist/assets/pl-CNNsWnPM.js +15 -0
- package/dist/assets/platform-BQghHRfp.js +6 -0
- package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-BGrrmeME.js +2 -0
- package/dist/assets/renderMobileApp-u3c4GtFC.js +28 -0
- package/dist/assets/{runtimeConfig-CMkwoQ_X.js → runtimeConfig-BIGBRGQI.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-ChOxt-J7.js → runtimeEndpointReset-D2savkuw.js} +12 -12
- package/dist/assets/{sessionChangeSummary-NnxcqCTI.js → sessionChangeSummary-eIbpSfrO.js} +1 -1
- package/dist/assets/{sessionLookup-BrU3rFHJ.js → sessionLookup-myHe5ltu.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-Fnfhf3K9.js → sortable-tabs-strip-BQtmHx9r.js} +1 -1
- package/dist/assets/src-CLN7Z-zx.css +2 -0
- package/dist/assets/store-DLCiZaA6.js +15 -0
- package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-BxcdiduX.js} +1 -1
- package/dist/assets/{textarea-bHYxZtfH.js → textarea-CakKvqFw.js} +1 -1
- package/dist/assets/{tooltip-0T5jv0cu.js → tooltip-Cx5gTbdY.js} +1 -1
- package/dist/assets/uk-rwQrxeYM.js +15 -0
- package/dist/assets/{url-_z7evzdK.js → url-CDHnjlsm.js} +1 -1
- package/dist/assets/{useAppFontEffects-C02YFyAv.js → useAppFontEffects-CNeo0aZs.js} +59 -45
- package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-DBJ56qIo.js +1 -0
- package/dist/assets/useEffectiveDirectory-B4LvR8Gt.js +1 -0
- package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-CUnQ4TVm.js} +1 -1
- package/dist/assets/useMobileNavigationStore-ZHJPxlHt.js +414 -0
- package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
- package/dist/assets/useSessionAutoCleanup-BIVmrnYU.js +2 -0
- package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
- package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-C9z0ML1z.js → useWorkerHighlightedLines-CJZbJDW3.js} +2 -2
- package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
- package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
- package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
- package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
- package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
- package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
- package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
- package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
- package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
- package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
- package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
- package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
- package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
- package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
- package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
- package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
- package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
- package/dist/index.html +13 -13
- package/dist/mini-chat.html +13 -13
- package/dist/mobile.html +13 -13
- package/package.json +2 -2
- package/server/index.js +392 -20
- package/server/lib/assistants/DOCUMENTATION.md +26 -21
- package/server/lib/assistants/assign.js +2 -1
- package/server/lib/assistants/contact-store.js +37 -19
- package/server/lib/assistants/contracts.js +11 -1
- package/server/lib/assistants/projection-history.test.js +454 -0
- package/server/lib/assistants/routes.js +11 -5
- package/server/lib/assistants/service.js +850 -294
- package/server/lib/assistants/service.test.js +588 -274
- package/server/lib/conversations/DOCUMENTATION.md +5 -5
- package/server/lib/conversations/service.js +87 -57
- package/server/lib/conversations/service.test.js +132 -100
- package/server/lib/conversations/validation.js +11 -1
- package/server/lib/event-stream/directory-ws-bridge.js +13 -2
- package/server/lib/event-stream/global-hub.js +51 -5
- package/server/lib/event-stream/runtime.js +57 -20
- package/server/lib/event-stream/runtime.test.js +112 -0
- package/server/lib/llm/DOCUMENTATION.md +95 -84
- package/server/lib/llm/catalog.js +111 -45
- package/server/lib/llm/catalog.test.js +121 -59
- package/server/lib/llm/completions.js +41 -12
- package/server/lib/llm/completions.test.js +45 -4
- package/server/lib/llm/generate.js +516 -453
- package/server/lib/llm/generate.test.js +705 -484
- package/server/lib/llm/routes.js +8 -4
- package/server/lib/message-queue/DOCUMENTATION.md +5 -5
- package/server/lib/message-queue/opencode-adapter.js +191 -104
- package/server/lib/message-queue/opencode-adapter.test.js +328 -58
- package/server/lib/message-queue/runtime.js +15 -2
- package/server/lib/message-queue/runtime.test.js +6 -6
- package/server/lib/message-queue/worker.js +7 -1
- package/server/lib/notifications/runtime.js +5 -0
- package/server/lib/opencode/DOCUMENTATION.md +103 -16
- package/server/lib/opencode/agent-document.d.ts +28 -0
- package/server/lib/opencode/agent-document.js +280 -0
- package/server/lib/opencode/agent-document.test.js +137 -0
- package/server/lib/opencode/agents.js +96 -0
- package/server/lib/opencode/auth-state-runtime.js +5 -0
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/config-entity-routes.js +111 -102
- package/server/lib/opencode/config-entity-routes.test.js +123 -10
- package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
- package/server/lib/opencode/ensure-cli.js +238 -0
- package/server/lib/opencode/ensure-cli.test.js +119 -0
- package/server/lib/opencode/env-runtime.js +208 -111
- package/server/lib/opencode/env-runtime.test.js +211 -14
- package/server/lib/opencode/feature-routes-runtime.js +80 -5
- package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
- package/server/lib/opencode/http-server-connections.js +98 -0
- package/server/lib/opencode/http-server-connections.test.js +321 -0
- package/server/lib/opencode/lifecycle.js +539 -95
- package/server/lib/opencode/lifecycle.test.js +698 -6
- package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
- package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
- package/server/lib/opencode/network-runtime.js +28 -16
- package/server/lib/opencode/network-runtime.test.js +68 -4
- package/server/lib/opencode/openchamber-routes.js +7 -1
- package/server/lib/opencode/opencode2-pin.js +139 -0
- package/server/lib/opencode/opencode2-pin.test.js +88 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +326 -0
- package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
- package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
- package/server/lib/opencode/plugin-compatibility.js +15 -0
- package/server/lib/opencode/plugin-compatibility.test.js +21 -0
- package/server/lib/opencode/plugin-routes.js +3 -18
- package/server/lib/opencode/plugin-routes.test.js +9 -9
- package/server/lib/opencode/plugins.js +4 -3
- package/server/lib/opencode/plugins.test.js +12 -0
- package/server/lib/opencode/provider-catalog.js +58 -12
- package/server/lib/opencode/provider-catalog.test.js +48 -0
- package/server/lib/opencode/proxy.js +368 -28
- package/server/lib/opencode/proxy.test.js +338 -0
- package/server/lib/opencode/routes.js +303 -76
- package/server/lib/opencode/routes.test.js +315 -50
- package/server/lib/opencode/runtime-contract.js +401 -0
- package/server/lib/opencode/runtime-contract.test.js +214 -0
- package/server/lib/opencode/server-opencode-fetch.js +242 -0
- package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
- package/server/lib/opencode/server-utils-runtime.js +35 -0
- package/server/lib/opencode/shared-service.integration.test.js +66 -0
- package/server/lib/opencode/shared-service.js +147 -0
- package/server/lib/opencode/shared-service.test.js +93 -0
- package/server/lib/opencode/shared.js +3 -3
- package/server/lib/opencode/shutdown-runtime.js +15 -6
- package/server/lib/opencode/shutdown-runtime.test.js +49 -1
- package/server/lib/opencode/skill-routes.js +26 -40
- package/server/lib/opencode/skill-routes.test.js +71 -7
- package/server/lib/opencode/skills.test.js +15 -0
- package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
- package/server/lib/opencode/static-routes-runtime.js +3 -1
- package/server/lib/opencode/static-routes-runtime.test.js +33 -0
- package/server/lib/opencode/v1-migration-gate.js +157 -0
- package/server/lib/opencode/v1-migration-gate.test.js +111 -0
- package/server/lib/opencode/v2-client.js +60 -0
- package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
- package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
- package/server/lib/opencode/watcher.js +2 -1
- package/server/lib/opencode/watcher.test.js +1 -1
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
- package/server/lib/permission-auto-accept/runtime.js +14 -7
- package/server/lib/permission-auto-accept/runtime.test.js +27 -12
- package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +79 -39
- package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
- package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
- package/server/lib/scheduled-tasks/routes.js +27 -0
- package/server/lib/scheduled-tasks/routes.test.js +94 -0
- package/server/lib/scheduled-tasks/runtime.js +381 -310
- package/server/lib/scheduled-tasks/runtime.test.js +600 -309
- package/server/lib/session-goal/DOCUMENTATION.md +173 -22
- package/server/lib/session-goal/capability.js +57 -0
- package/server/lib/session-goal/capability.test.js +33 -0
- package/server/lib/session-goal/routes.js +240 -2
- package/server/lib/session-goal/routes.test.js +211 -0
- package/server/lib/session-goal/runtime.js +1617 -113
- package/server/lib/session-goal/runtime.test.js +1500 -70
- package/server/lib/session-index/DOCUMENTATION.md +18 -15
- package/server/lib/session-index/event-ingest.js +89 -16
- package/server/lib/session-index/event-ingest.test.js +73 -0
- package/server/lib/session-index/host-authority-snapshot.js +92 -0
- package/server/lib/session-index/routes.host-archive.test.js +201 -0
- package/server/lib/session-index/routes.js +63 -10
- package/server/lib/session-index/routes.test.js +8 -8
- package/server/lib/session-index/service.js +7 -1
- package/server/lib/session-index/sync-runtime.js +171 -41
- package/server/lib/session-index/sync-runtime.test.js +164 -9
- package/server/lib/session-metadata/DOCUMENTATION.md +86 -0
- package/server/lib/session-metadata/routes.js +193 -0
- package/server/lib/session-metadata/routes.test.js +156 -0
- package/server/lib/session-metadata/session-archive.d.ts +73 -0
- package/server/lib/session-metadata/session-archive.js +654 -0
- package/server/lib/session-metadata/session-archive.test.js +359 -0
- package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
- package/server/lib/session-metadata/session-metadata-store.js +375 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +379 -0
- package/server/lib/session-metadata/session-projection.d.ts +41 -0
- package/server/lib/session-metadata/session-projection.js +255 -0
- package/server/lib/session-metadata/session-projection.test.js +64 -0
- package/server/lib/session-metadata/system-session.js +55 -0
- package/server/lib/session-metadata/system-session.test.js +55 -0
- package/server/lib/session-title/DOCUMENTATION.md +40 -7
- package/server/lib/session-title/native-runtime.test.js +167 -0
- package/server/lib/session-title/runtime.js +67 -42
- package/server/lib/session-title/session-access.js +46 -0
- package/server/lib/session-title/session-access.test.js +91 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +12 -11
- package/server/lib/session-turn-pages/reconcile.service.js +1 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
- package/server/lib/session-turn-pages/routes.js +123 -75
- package/server/lib/session-turn-pages/routes.test.js +566 -0
- package/server/lib/session-turn-pages/service.js +6 -2
- package/server/lib/session-turn-pages/service.test.js +34 -0
- package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
- package/server/lib/session-turn-pages/session-message-projection.js +281 -0
- package/server/lib/small-model/DOCUMENTATION.md +101 -146
- package/server/lib/small-model/catalog.js +84 -54
- package/server/lib/small-model/catalog.test.js +111 -9
- package/server/lib/small-model/custom-api.js +68 -0
- package/server/lib/small-model/custom-api.test.js +64 -0
- package/server/lib/small-model/index.js +125 -241
- package/server/lib/small-model/resolve.js +35 -120
- package/server/lib/small-model/resolve.test.js +20 -110
- package/server/lib/small-model/routes.js +1 -3
- package/server/lib/small-model/summary-settings.test.js +157 -253
- package/server/opencode-proxy.test.js +63 -12
- package/dist/assets/AssistantView-BUveAgc4.js +0 -3
- package/dist/assets/MarkdownRenderer-BbrbDH6-.js +0 -2
- package/dist/assets/MobileDetailNavigation-B0aqKXd7.js +0 -1
- package/dist/assets/MobileSurface-DW7WfPsy.js +0 -2
- package/dist/assets/OverlayScrollbar-DrA0Vsg5.js +0 -2
- package/dist/assets/SettingsView-PkXIavr7.js +0 -25
- package/dist/assets/appThemeRegistry-D5Yxg4YF.js +0 -1
- package/dist/assets/collapsible-D5Phaykh.js +0 -1
- package/dist/assets/context-menu-CubSDW2g.js +0 -1
- package/dist/assets/es-DA3MqOo0.js +0 -15
- package/dist/assets/fr-Dbz0CdIA.js +0 -13
- package/dist/assets/gitApi-DXyC4MiV.js +0 -7
- package/dist/assets/insertionBoundaries-KlOVrHHt.js +0 -1
- package/dist/assets/ja-DumanVQA.js +0 -15
- package/dist/assets/ko-C0IkiUjQ.js +0 -15
- package/dist/assets/main-BPS5ylib.js +0 -2
- package/dist/assets/main-Cpd9VWYs.js +0 -169
- package/dist/assets/markdown-worker-CptoICmq.js +0 -2
- package/dist/assets/miniChat-B8_KLWiY.js +0 -2
- package/dist/assets/mobile-CtktKrXn.js +0 -2
- package/dist/assets/multirun-CYfP9Cfm.js +0 -4
- package/dist/assets/pl-B9No6MQp.js +0 -15
- package/dist/assets/platform-0w_Q9_ji.js +0 -2
- package/dist/assets/projectMeta-S1hJ7oBp.js +0 -3
- package/dist/assets/pt-BR-DyQ0WTDh.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-iK14LWm8.js +0 -2
- package/dist/assets/renderMobileApp-DORLPWnb.js +0 -28
- package/dist/assets/src-rS3zCcsC.css +0 -2
- package/dist/assets/store-5B_LDnLM.js +0 -15
- package/dist/assets/uk-C_1KkqyP.js +0 -15
- package/dist/assets/useDesktopWindowControlsLayout-Dnr-jX0C.js +0 -1
- package/dist/assets/useEffectiveDirectory-CZLLyarc.js +0 -1
- package/dist/assets/useMobileNavigationStore-CoCiqNgm.js +0 -413
- package/dist/assets/useSessionAutoCleanup-xQyETQkG.js +0 -2
- package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
- package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
- package/dist/assets/zh-CN-uWILgBdt.js +0 -15
- package/dist/assets/zh-TW-MYfdvhbD.js +0 -15
- package/server/lib/small-model/call.js +0 -427
- package/server/lib/small-model/call.test.js +0 -382
- package/server/lib/small-model/opencode-session.js +0 -339
- package/server/lib/small-model/opencode-session.test.js +0 -177
|
@@ -18,6 +18,7 @@ import fs from 'fs';
|
|
|
18
18
|
import os from 'os';
|
|
19
19
|
import path from 'path';
|
|
20
20
|
|
|
21
|
+
import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
|
|
21
22
|
import { GOAL_OBJECTIVE_CHAR_LIMIT, readObjective } from './objectives.js';
|
|
22
23
|
|
|
23
24
|
const OPENCHAMBER_SETTINGS_FILE = path.join(
|
|
@@ -56,9 +57,62 @@ const MAX_AUTO_TURNS = 20;
|
|
|
56
57
|
// goal settles as blocked — a one-off snag must not end the goal.
|
|
57
58
|
const BLOCKED_STREAK_LIMIT = 3;
|
|
58
59
|
const GOAL_STATUSES = ['active', 'paused', 'blocked', 'budgetLimited', 'complete'];
|
|
60
|
+
/** Durable continuation admission phases on goal.pendingContinuation. */
|
|
61
|
+
const PENDING_CONTINUATION_PHASES = ['reserved', 'transport', 'uncertain', 'accepted'];
|
|
62
|
+
/** User-visible reason when upstream reconcile cannot decide delivery. */
|
|
63
|
+
const CONTINUATION_UNCERTAIN_REASON = 'continuation delivery uncertain — paused auto-continue';
|
|
59
64
|
|
|
60
65
|
const clampText = (value, limit) => String(value ?? '').trim().slice(0, limit);
|
|
61
66
|
|
|
67
|
+
/** Durable execution generation on a goal record (missing → 0). */
|
|
68
|
+
export const readGoalExecutionGeneration = (goal) => (
|
|
69
|
+
Number.isFinite(goal?.executionGeneration) && goal.executionGeneration >= 0
|
|
70
|
+
? Math.floor(goal.executionGeneration)
|
|
71
|
+
: 0
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
export const nextGoalExecutionGeneration = (goal) => readGoalExecutionGeneration(goal) + 1;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* User-facing transitions that invalidate in-flight audit/dispatch work.
|
|
78
|
+
* Settle paths (active → complete/blocked/budgetLimited) keep the generation.
|
|
79
|
+
*/
|
|
80
|
+
export const shouldAdvanceGoalExecutionGeneration = (previous, next) => {
|
|
81
|
+
if (!previous || !next || typeof previous !== 'object' || typeof next !== 'object') return false;
|
|
82
|
+
if (previous.id !== next.id) return false;
|
|
83
|
+
if (next.status === 'active' && previous.status !== 'active') return true;
|
|
84
|
+
if (next.status === 'paused' && previous.status === 'active') return true;
|
|
85
|
+
if (previous.tokenBudget !== next.tokenBudget) return true;
|
|
86
|
+
if ((previous.objective || '') !== (next.objective || '')) return true;
|
|
87
|
+
if (Boolean(previous.objectiveFile) !== Boolean(next.objectiveFile)) return true;
|
|
88
|
+
return false;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Ensure a goal write carries a monotonic executionGeneration when the
|
|
93
|
+
* transition requires a new execution permit (pause / resume / conditions).
|
|
94
|
+
*/
|
|
95
|
+
export const withGoalExecutionGeneration = (previous, next) => {
|
|
96
|
+
const goal = next && typeof next === 'object' ? { ...next } : next;
|
|
97
|
+
if (!goal || typeof goal !== 'object') return goal;
|
|
98
|
+
if (!previous || previous.id !== goal.id) {
|
|
99
|
+
if (!Number.isFinite(goal.executionGeneration) || goal.executionGeneration < 0) {
|
|
100
|
+
goal.executionGeneration = 0;
|
|
101
|
+
} else {
|
|
102
|
+
goal.executionGeneration = Math.floor(goal.executionGeneration);
|
|
103
|
+
}
|
|
104
|
+
return goal;
|
|
105
|
+
}
|
|
106
|
+
const prevGen = readGoalExecutionGeneration(previous);
|
|
107
|
+
const nextGen = readGoalExecutionGeneration(goal);
|
|
108
|
+
if (shouldAdvanceGoalExecutionGeneration(previous, goal)) {
|
|
109
|
+
goal.executionGeneration = Math.max(nextGen, prevGen + 1);
|
|
110
|
+
} else {
|
|
111
|
+
goal.executionGeneration = Math.max(nextGen, prevGen);
|
|
112
|
+
}
|
|
113
|
+
return goal;
|
|
114
|
+
};
|
|
115
|
+
|
|
62
116
|
const escapeXmlText = (value) => String(value ?? '')
|
|
63
117
|
.replace(/&/g, '&')
|
|
64
118
|
.replace(/</g, '<')
|
|
@@ -158,15 +212,33 @@ const extractSessionStatus = (payload) => {
|
|
|
158
212
|
return { sessionId, type, directory };
|
|
159
213
|
};
|
|
160
214
|
|
|
161
|
-
// A user abort lands as an assistant message carrying MessageAbortedError.
|
|
162
|
-
const extractAbortedAssistant = (payload) => {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
};
|
|
215
|
+
// A user abort lands as an assistant message carrying MessageAbortedError.
|
|
216
|
+
const extractAbortedAssistant = (payload) => {
|
|
217
|
+
if (!payload || payload.type !== 'message.updated') return null;
|
|
218
|
+
const info = payload.properties?.info ?? payload.data?.info;
|
|
219
|
+
if (!info || typeof info !== 'object' || (info.role !== 'assistant' && info.type !== 'assistant')) return null;
|
|
220
|
+
if (info.error?.name !== 'MessageAbortedError') return null;
|
|
221
|
+
if (typeof info.sessionID !== 'string' || !info.sessionID) return null;
|
|
222
|
+
return { sessionId: info.sessionID };
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Official v2 `session.execution.interrupted` reason:
|
|
227
|
+
* user | shutdown | superseded | inactivity.
|
|
228
|
+
* Shutdown keeps the execution claim for restart recovery — do not settle or auto-continue.
|
|
229
|
+
*/
|
|
230
|
+
const extractExecutionInterrupted = (payload) => {
|
|
231
|
+
if (!payload || payload.type !== 'session.execution.interrupted') return null;
|
|
232
|
+
const body = (payload.properties && typeof payload.properties === 'object')
|
|
233
|
+
? payload.properties
|
|
234
|
+
: ((payload.data && typeof payload.data === 'object') ? payload.data : null);
|
|
235
|
+
if (!body) return null;
|
|
236
|
+
const sessionId = typeof body.sessionID === 'string' ? body.sessionID.trim() : '';
|
|
237
|
+
if (!sessionId) return null;
|
|
238
|
+
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
|
|
239
|
+
return { sessionId, reason };
|
|
240
|
+
};
|
|
241
|
+
|
|
170
242
|
|
|
171
243
|
// QuestionRequest shape: properties.sessionID, optional properties.directory.
|
|
172
244
|
const extractQuestionAsked = (payload, directoryHint = '') => {
|
|
@@ -221,18 +293,213 @@ const parseGoalMetadata = (session) => {
|
|
|
221
293
|
note: typeof goal.note === 'string' ? goal.note.slice(0, NOTE_CHAR_LIMIT) : '',
|
|
222
294
|
statusReason: typeof goal.statusReason === 'string' ? goal.statusReason.slice(0, REASON_CHAR_LIMIT) : '',
|
|
223
295
|
lastAccountedMessageID: typeof goal.lastAccountedMessageID === 'string' ? goal.lastAccountedMessageID : '',
|
|
296
|
+
executionGeneration: readGoalExecutionGeneration(goal),
|
|
297
|
+
pendingContinuation: parsePendingContinuation(goal.pendingContinuation),
|
|
224
298
|
createdAt: Number.isFinite(goal.createdAt) ? goal.createdAt : 0,
|
|
225
299
|
updatedAt: Number.isFinite(goal.updatedAt) ? goal.updatedAt : 0,
|
|
226
300
|
};
|
|
227
301
|
};
|
|
228
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Official v2 `SessionMessagesResponse` is `{ data: SessionMessageInfo[], cursor }`.
|
|
305
|
+
* Legacy bare arrays and projected `{ info, parts }` still appear in tests/fixtures.
|
|
306
|
+
* Never treat a failed/malformed payload as authoritative empty success.
|
|
307
|
+
*/
|
|
308
|
+
export const unwrapSessionMessages = (payload) => {
|
|
309
|
+
if (Array.isArray(payload)) return payload;
|
|
310
|
+
if (payload && typeof payload === 'object') {
|
|
311
|
+
if (Array.isArray(payload.data)) return payload.data;
|
|
312
|
+
if (Array.isArray(payload.data?.items)) return payload.data.items;
|
|
313
|
+
if (Array.isArray(payload.messages)) return payload.messages;
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** Project one list entry to `{ info, parts }` for goal accounting / audit text. */
|
|
319
|
+
export const projectGoalMessage = (entry, sessionID = '') => {
|
|
320
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
321
|
+
if (entry.info && typeof entry.info === 'object') {
|
|
322
|
+
return {
|
|
323
|
+
info: entry.info,
|
|
324
|
+
parts: Array.isArray(entry.parts) ? entry.parts : [],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const id = typeof entry.id === 'string' ? entry.id : '';
|
|
328
|
+
if (!id) return null;
|
|
329
|
+
const role = entry.type === 'user' || entry.type === 'assistant' || entry.type === 'compaction'
|
|
330
|
+
? (entry.type === 'compaction' ? 'assistant' : entry.type)
|
|
331
|
+
: (typeof entry.role === 'string' ? entry.role : entry.type);
|
|
332
|
+
const parts = [];
|
|
333
|
+
if (typeof entry.text === 'string' && entry.text) {
|
|
334
|
+
parts.push({ id: `${id}:text`, sessionID, messageID: id, type: 'text', text: entry.text });
|
|
335
|
+
}
|
|
336
|
+
if (Array.isArray(entry.content)) {
|
|
337
|
+
entry.content.forEach((item, index) => {
|
|
338
|
+
const partID = item?.id || `${id}:content:${index}`;
|
|
339
|
+
if (item?.type === 'text' && typeof item.text === 'string') {
|
|
340
|
+
parts.push({ id: partID, sessionID, messageID: id, type: 'text', text: item.text });
|
|
341
|
+
} else if (item?.type === 'reasoning' && typeof item.text === 'string') {
|
|
342
|
+
parts.push({ id: partID, sessionID, messageID: id, type: 'reasoning', text: item.text });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (entry.type === 'compaction' && typeof entry.summary === 'string' && entry.summary) {
|
|
347
|
+
parts.push({ id: `${id}:summary`, sessionID, messageID: id, type: 'text', text: entry.summary });
|
|
348
|
+
}
|
|
349
|
+
const info = {
|
|
350
|
+
...entry,
|
|
351
|
+
id,
|
|
352
|
+
role: role || entry.role,
|
|
353
|
+
// Compaction is the v2 summary turn; keep summary flag for existing tick rules.
|
|
354
|
+
...(entry.type === 'compaction' ? { summary: true } : {}),
|
|
355
|
+
};
|
|
356
|
+
return { info, parts };
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
export const projectGoalMessages = (payload, sessionID = '') => {
|
|
360
|
+
const raw = unwrapSessionMessages(payload);
|
|
361
|
+
if (!raw) return null;
|
|
362
|
+
const projected = [];
|
|
363
|
+
for (const entry of raw) {
|
|
364
|
+
const message = projectGoalMessage(entry, sessionID);
|
|
365
|
+
if (message) projected.push(message);
|
|
366
|
+
}
|
|
367
|
+
return projected;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const messageRole = (info) => {
|
|
371
|
+
if (!info || typeof info !== 'object') return '';
|
|
372
|
+
if (typeof info.role === 'string' && info.role) return info.role;
|
|
373
|
+
if (info.type === 'user' || info.type === 'assistant') return info.type;
|
|
374
|
+
if (info.type === 'compaction') return 'assistant';
|
|
375
|
+
return typeof info.type === 'string' ? info.type : '';
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const isSummaryMessage = (info) => (
|
|
379
|
+
info?.summary === true || info?.type === 'compaction'
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Resolve provider/model for switchModel + continuation from last assistant.
|
|
384
|
+
* v2 uses `model: { id, providerID, variant? }`; legacy fixtures use flat fields.
|
|
385
|
+
*/
|
|
386
|
+
export const extractExecutionModel = (info) => {
|
|
387
|
+
if (!info || typeof info !== 'object') {
|
|
388
|
+
return { providerID: '', modelID: '', variant: '', agent: '' };
|
|
389
|
+
}
|
|
390
|
+
const model = info.model && typeof info.model === 'object' ? info.model : null;
|
|
391
|
+
const providerID = typeof model?.providerID === 'string' && model.providerID
|
|
392
|
+
? model.providerID
|
|
393
|
+
: (typeof info.providerID === 'string' ? info.providerID : '');
|
|
394
|
+
const modelID = typeof model?.id === 'string' && model.id
|
|
395
|
+
? model.id
|
|
396
|
+
: (typeof model?.modelID === 'string' && model.modelID
|
|
397
|
+
? model.modelID
|
|
398
|
+
: (typeof info.modelID === 'string' ? info.modelID : ''));
|
|
399
|
+
const variant = typeof model?.variant === 'string' && model.variant
|
|
400
|
+
? model.variant
|
|
401
|
+
: (typeof info.variant === 'string' ? info.variant : '');
|
|
402
|
+
const agent = typeof info.agent === 'string' && info.agent
|
|
403
|
+
? info.agent
|
|
404
|
+
: (typeof info.mode === 'string' ? info.mode : '');
|
|
405
|
+
return { providerID, modelID, variant, agent };
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Stable continuation message id for v2 `session.prompt` payload `id`.
|
|
410
|
+
* Official SessionMessage.ID requires `startsWith('msg_')`; suffix stays
|
|
411
|
+
* deterministic on goalId + generation + turnsUsed for idempotent re-dispatch.
|
|
412
|
+
*/
|
|
413
|
+
export const buildContinuationMessageID = ({ goalId, generation, turnsUsed }) => {
|
|
414
|
+
const safeGoal = String(goalId || 'goal')
|
|
415
|
+
.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
416
|
+
.slice(0, 48) || 'goal';
|
|
417
|
+
const gen = Number.isFinite(generation) ? Math.floor(generation) : 0;
|
|
418
|
+
const turns = Number.isFinite(turnsUsed) ? Math.floor(turnsUsed) : 0;
|
|
419
|
+
return `msg_goalc_${safeGoal}_${gen}_${turns}`;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Parse durable continuation admission from goal metadata (ticket 10).
|
|
424
|
+
* Null when absent or malformed — never invent an empty success admission.
|
|
425
|
+
*/
|
|
426
|
+
export const parsePendingContinuation = (raw) => {
|
|
427
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
428
|
+
const messageID = typeof raw.messageID === 'string' ? raw.messageID.trim() : '';
|
|
429
|
+
const goalId = typeof raw.goalId === 'string' ? raw.goalId.trim() : '';
|
|
430
|
+
const phase = PENDING_CONTINUATION_PHASES.includes(raw.phase) ? raw.phase : '';
|
|
431
|
+
if (!messageID || !goalId || !phase) return null;
|
|
432
|
+
const generation = Number.isFinite(raw.generation) && raw.generation >= 0
|
|
433
|
+
? Math.floor(raw.generation)
|
|
434
|
+
: 0;
|
|
435
|
+
const turnsUsed = Number.isFinite(raw.turnsUsed) && raw.turnsUsed > 0
|
|
436
|
+
? Math.floor(raw.turnsUsed)
|
|
437
|
+
: 0;
|
|
438
|
+
if (!turnsUsed) return null;
|
|
439
|
+
const text = typeof raw.text === 'string' ? raw.text : '';
|
|
440
|
+
const providerID = typeof raw.providerID === 'string' ? raw.providerID : '';
|
|
441
|
+
const modelID = typeof raw.modelID === 'string' ? raw.modelID : '';
|
|
442
|
+
if (!text || !providerID || !modelID) return null;
|
|
443
|
+
return {
|
|
444
|
+
messageID,
|
|
445
|
+
goalId,
|
|
446
|
+
generation,
|
|
447
|
+
turnsUsed,
|
|
448
|
+
phase,
|
|
449
|
+
text,
|
|
450
|
+
providerID,
|
|
451
|
+
modelID,
|
|
452
|
+
variant: typeof raw.variant === 'string' ? raw.variant : '',
|
|
453
|
+
agent: typeof raw.agent === 'string' ? raw.agent : '',
|
|
454
|
+
error: typeof raw.error === 'string' ? raw.error.slice(0, REASON_CHAR_LIMIT) : '',
|
|
455
|
+
at: Number.isFinite(raw.at) ? raw.at : 0,
|
|
456
|
+
};
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/** Build a durable admission record for conditional metadata writes. */
|
|
460
|
+
export const buildPendingContinuation = ({
|
|
461
|
+
messageID,
|
|
462
|
+
goalId,
|
|
463
|
+
generation,
|
|
464
|
+
turnsUsed,
|
|
465
|
+
phase,
|
|
466
|
+
text,
|
|
467
|
+
providerID,
|
|
468
|
+
modelID,
|
|
469
|
+
variant = '',
|
|
470
|
+
agent = '',
|
|
471
|
+
error = '',
|
|
472
|
+
at = Date.now(),
|
|
473
|
+
}) => ({
|
|
474
|
+
messageID: String(messageID || ''),
|
|
475
|
+
goalId: String(goalId || ''),
|
|
476
|
+
generation: Number.isFinite(generation) ? Math.floor(generation) : 0,
|
|
477
|
+
turnsUsed: Number.isFinite(turnsUsed) ? Math.floor(turnsUsed) : 0,
|
|
478
|
+
phase: PENDING_CONTINUATION_PHASES.includes(phase) ? phase : 'reserved',
|
|
479
|
+
text: String(text || ''),
|
|
480
|
+
providerID: String(providerID || ''),
|
|
481
|
+
modelID: String(modelID || ''),
|
|
482
|
+
variant: typeof variant === 'string' ? variant : '',
|
|
483
|
+
agent: typeof agent === 'string' ? agent : '',
|
|
484
|
+
...(error ? { error: clampText(error, REASON_CHAR_LIMIT) } : {}),
|
|
485
|
+
at: Number.isFinite(at) ? at : Date.now(),
|
|
486
|
+
});
|
|
487
|
+
|
|
229
488
|
const messagePartsToText = (message) => {
|
|
230
489
|
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
231
|
-
|
|
490
|
+
const fromParts = parts
|
|
232
491
|
.map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
|
|
233
492
|
.filter(Boolean)
|
|
234
|
-
.join('\n')
|
|
235
|
-
|
|
493
|
+
.join('\n');
|
|
494
|
+
if (fromParts) return fromParts.slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
|
|
495
|
+
// Raw v2 assistant/user may carry top-level text when parts were not projected.
|
|
496
|
+
if (typeof message?.info?.text === 'string' && message.info.text) {
|
|
497
|
+
return message.info.text.slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
|
|
498
|
+
}
|
|
499
|
+
if (typeof message?.text === 'string' && message.text) {
|
|
500
|
+
return message.text.slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
|
|
501
|
+
}
|
|
502
|
+
return '';
|
|
236
503
|
};
|
|
237
504
|
|
|
238
505
|
// Per-turn spend for goal accounting. Sum these across turns completed after
|
|
@@ -262,8 +529,8 @@ export const accountGoalTokenSpend = ({ messages, goal }) => {
|
|
|
262
529
|
const createdAt = Number.isFinite(goal?.createdAt) ? goal.createdAt : 0;
|
|
263
530
|
let addedSpend = 0;
|
|
264
531
|
for (const message of messages || []) {
|
|
265
|
-
const info = message?.info;
|
|
266
|
-
if (info
|
|
532
|
+
const info = message?.info ?? message;
|
|
533
|
+
if (messageRole(info) !== 'assistant' || typeof info?.id !== 'string') continue;
|
|
267
534
|
if (!(info.time?.completed > 0)) continue;
|
|
268
535
|
// First tick with an empty cursor: only charge turns that finished after
|
|
269
536
|
// the goal was created so mid-session goals skip prior history.
|
|
@@ -273,7 +540,7 @@ export const accountGoalTokenSpend = ({ messages, goal }) => {
|
|
|
273
540
|
if (lastAccountedMessageID && info.id <= lastAccountedMessageID) continue;
|
|
274
541
|
// Summary turns report 0 tokens from OpenCode — skip without advancing
|
|
275
542
|
// the counter, but still move the cursor so we do not re-scan them.
|
|
276
|
-
if (info
|
|
543
|
+
if (!isSummaryMessage(info)) {
|
|
277
544
|
addedSpend += messageTokenSpend(info);
|
|
278
545
|
}
|
|
279
546
|
if (!lastAccountedMessageID || info.id > lastAccountedMessageID) {
|
|
@@ -301,6 +568,8 @@ const contactStatusFromGoal = (status) => {
|
|
|
301
568
|
export const createSessionGoalRuntime = ({
|
|
302
569
|
buildOpenCodeUrl,
|
|
303
570
|
getOpenCodeAuthHeaders,
|
|
571
|
+
/** Ticket 11: Host write admission shared with proxy (optional; falls back to raw fetch). */
|
|
572
|
+
serverOpenCodeFetch = null,
|
|
304
573
|
getSmallModelService,
|
|
305
574
|
emitGoalNotification,
|
|
306
575
|
idleQuietMs = IDLE_QUIET_MS,
|
|
@@ -319,11 +588,67 @@ export const createSessionGoalRuntime = ({
|
|
|
319
588
|
* timers for the session tree (root + descendants).
|
|
320
589
|
*/
|
|
321
590
|
onGoalPaused,
|
|
591
|
+
/**
|
|
592
|
+
* OpenChamber session-metadata store seams. When both are functions, goal
|
|
593
|
+
* state is read/written only through the Host store (OpenCode 2.x has no
|
|
594
|
+
* session-metadata PATCH). When either is missing, keep the legacy OpenCode
|
|
595
|
+
* PATCH path so existing tests and unwired servers still work.
|
|
596
|
+
*
|
|
597
|
+
* `mutateSessionMetadata` (optional) is the preferred commit seam: decide runs
|
|
598
|
+
* on the store's exclusive serial boundary so generation/status checks and
|
|
599
|
+
* persist share one authority lock.
|
|
600
|
+
*/
|
|
601
|
+
persistSessionGoal = null,
|
|
602
|
+
readSessionMetadata = null,
|
|
603
|
+
mutateSessionMetadata = null,
|
|
322
604
|
}) => {
|
|
323
605
|
const timers = new Map();
|
|
324
606
|
const inflight = new Set();
|
|
607
|
+
/** sessionId → AbortController for the in-flight audit (cancellable work). */
|
|
608
|
+
const auditControllers = new Map();
|
|
609
|
+
/**
|
|
610
|
+
* sessionId → monotonic dispatch epoch. Bumped synchronously on pause/cancel
|
|
611
|
+
* so a late audit cannot start a continuation after pause is confirmed in-process,
|
|
612
|
+
* even before the durable write lands.
|
|
613
|
+
*/
|
|
614
|
+
const dispatchEpoch = new Map();
|
|
615
|
+
/**
|
|
616
|
+
* sessionId → last continuation dispatch identity that entered transport.
|
|
617
|
+
* Uncertain delivery is reconciled by identity; we never claim full revoke.
|
|
618
|
+
*/
|
|
619
|
+
const dispatchedContinuations = new Map();
|
|
620
|
+
/**
|
|
621
|
+
* sessionId → shutdown recovery pending (ticket 07). Local runtime gate only —
|
|
622
|
+
* not a parallel durable store. Cleared on authoritative idle / non-shutdown terminal.
|
|
623
|
+
*/
|
|
624
|
+
const shutdownRecoveryPending = new Map();
|
|
325
625
|
let stopped = false;
|
|
326
626
|
|
|
627
|
+
const openCodeClient = () => makeOpenCodeV2Client({
|
|
628
|
+
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
|
629
|
+
authHeaders: getOpenCodeAuthHeaders(),
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const directoryRequestOptions = (directory, signal) => {
|
|
633
|
+
const headers = {};
|
|
634
|
+
if (typeof directory === 'string' && directory) {
|
|
635
|
+
headers['x-opencode-directory'] = encodeURIComponent(directory);
|
|
636
|
+
headers['x-opencode-directory-encoding'] = 'uri';
|
|
637
|
+
}
|
|
638
|
+
const options = {};
|
|
639
|
+
if (Object.keys(headers).length > 0) options.headers = headers;
|
|
640
|
+
if (signal) options.signal = signal;
|
|
641
|
+
return Object.keys(options).length > 0 ? options : undefined;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const isMetadataStoreWired = () => (
|
|
645
|
+
typeof readSessionMetadata === 'function'
|
|
646
|
+
&& (
|
|
647
|
+
typeof mutateSessionMetadata === 'function'
|
|
648
|
+
|| typeof persistSessionGoal === 'function'
|
|
649
|
+
)
|
|
650
|
+
);
|
|
651
|
+
|
|
327
652
|
const clearTimer = (sessionId) => {
|
|
328
653
|
const existing = timers.get(sessionId);
|
|
329
654
|
if (existing) {
|
|
@@ -332,7 +657,39 @@ export const createSessionGoalRuntime = ({
|
|
|
332
657
|
}
|
|
333
658
|
};
|
|
334
659
|
|
|
660
|
+
const bumpDispatchEpoch = (sessionId) => {
|
|
661
|
+
const next = (dispatchEpoch.get(sessionId) || 0) + 1;
|
|
662
|
+
dispatchEpoch.set(sessionId, next);
|
|
663
|
+
return next;
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const currentDispatchEpoch = (sessionId) => dispatchEpoch.get(sessionId) || 0;
|
|
667
|
+
|
|
668
|
+
/** Cancel timers + in-flight audit; bump dispatch epoch. Does not claim upstream revoke. */
|
|
669
|
+
const revokeLocalExecutionPermit = (sessionId) => {
|
|
670
|
+
clearTimer(sessionId);
|
|
671
|
+
bumpDispatchEpoch(sessionId);
|
|
672
|
+
const controller = auditControllers.get(sessionId);
|
|
673
|
+
if (controller) {
|
|
674
|
+
try {
|
|
675
|
+
controller.abort();
|
|
676
|
+
} catch {
|
|
677
|
+
// ignore
|
|
678
|
+
}
|
|
679
|
+
auditControllers.delete(sessionId);
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
|
|
335
683
|
const openCodeFetch = async (fetchPath, { directory, method = 'GET', body, query } = {}) => {
|
|
684
|
+
if (typeof serverOpenCodeFetch === 'function') {
|
|
685
|
+
return serverOpenCodeFetch(fetchPath, {
|
|
686
|
+
directory,
|
|
687
|
+
method,
|
|
688
|
+
body,
|
|
689
|
+
query,
|
|
690
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
336
693
|
const base = buildOpenCodeUrl(fetchPath, '');
|
|
337
694
|
const params = new URLSearchParams(query || {});
|
|
338
695
|
if (directory) params.set('directory', directory);
|
|
@@ -354,12 +711,110 @@ export const createSessionGoalRuntime = ({
|
|
|
354
711
|
return response.json().catch(() => null);
|
|
355
712
|
};
|
|
356
713
|
|
|
714
|
+
/**
|
|
715
|
+
* Latest-side message page via official v2 `message.list`
|
|
716
|
+
* (`SessionMessagesResponse` = `{ data, cursor }`). Projects raw
|
|
717
|
+
* `SessionMessageInfo` into `{ info, parts }` for the rest of the tick.
|
|
718
|
+
* Fetch failure returns null (never empty success).
|
|
719
|
+
*/
|
|
357
720
|
const fetchRecentMessages = async (sessionId, directory) => {
|
|
358
|
-
|
|
721
|
+
try {
|
|
722
|
+
const client = openCodeClient();
|
|
723
|
+
if (typeof client?.message?.list === 'function') {
|
|
724
|
+
const listed = await client.message.list(
|
|
725
|
+
{ sessionID: sessionId, limit: MESSAGE_FETCH_LIMIT, order: 'asc' },
|
|
726
|
+
directoryRequestOptions(directory),
|
|
727
|
+
);
|
|
728
|
+
const projected = projectGoalMessages(listed, sessionId);
|
|
729
|
+
if (projected) return projected;
|
|
730
|
+
}
|
|
731
|
+
} catch {
|
|
732
|
+
// Fall through to controlled HTTP seam for tests / older fixtures.
|
|
733
|
+
}
|
|
734
|
+
const raw = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, {
|
|
359
735
|
directory,
|
|
360
736
|
query: { limit: String(MESSAGE_FETCH_LIMIT) },
|
|
361
737
|
}).catch(() => null);
|
|
362
|
-
return
|
|
738
|
+
return projectGoalMessages(raw, sessionId);
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Reconcile a fixed continuation message id via official inbox.list then
|
|
743
|
+
* session.message.get (or projected message list). Failures are unavailable
|
|
744
|
+
* — never treat transport/API failure as authoritative empty.
|
|
745
|
+
*
|
|
746
|
+
* Individual SDK/HTTP seams may be missing in tests or older serves; each
|
|
747
|
+
* seam falls through. Only when the authoritative projected list itself
|
|
748
|
+
* cannot be read do we return `unavailable` (fail ≠ empty).
|
|
749
|
+
*
|
|
750
|
+
* @returns {{ status: 'found' | 'absent' | 'unavailable' }}
|
|
751
|
+
*/
|
|
752
|
+
const findContinuationMessage = async (sessionId, directory, messageID) => {
|
|
753
|
+
if (!messageID) return { status: 'unavailable' };
|
|
754
|
+
const requestOptions = directoryRequestOptions(directory);
|
|
755
|
+
const identityOf = (item) => {
|
|
756
|
+
if (!item || typeof item !== 'object') return '';
|
|
757
|
+
if (typeof item.id === 'string' && item.id) return item.id;
|
|
758
|
+
if (typeof item.messageID === 'string' && item.messageID) return item.messageID;
|
|
759
|
+
if (item.info && typeof item.info.id === 'string') return item.info.id;
|
|
760
|
+
return '';
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
const client = openCodeClient();
|
|
765
|
+
if (typeof client?.session?.inbox?.list === 'function') {
|
|
766
|
+
try {
|
|
767
|
+
const listed = await client.session.inbox.list({ sessionID: sessionId }, requestOptions);
|
|
768
|
+
const items = Array.isArray(listed)
|
|
769
|
+
? listed
|
|
770
|
+
: (Array.isArray(listed?.data) ? listed.data : null);
|
|
771
|
+
// Malformed inbox is not authoritative empty — fall through.
|
|
772
|
+
if (items && items.some((item) => identityOf(item) === messageID)) {
|
|
773
|
+
return { status: 'found' };
|
|
774
|
+
}
|
|
775
|
+
} catch {
|
|
776
|
+
// Missing inbox / transient — try exact message next.
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (typeof client?.session?.message === 'function') {
|
|
780
|
+
try {
|
|
781
|
+
const record = await client.session.message(
|
|
782
|
+
{ sessionID: sessionId, messageID },
|
|
783
|
+
requestOptions,
|
|
784
|
+
);
|
|
785
|
+
if (identityOf(record) === messageID) {
|
|
786
|
+
return { status: 'found' };
|
|
787
|
+
}
|
|
788
|
+
} catch {
|
|
789
|
+
// Not-found or unsupported — fall through to HTTP / projection.
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
} catch {
|
|
793
|
+
// Fall through to HTTP seams.
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Controlled HTTP exact message: only accept exact id match.
|
|
797
|
+
const exact = await openCodeFetch(
|
|
798
|
+
`/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageID)}`,
|
|
799
|
+
{ directory },
|
|
800
|
+
).catch((error) => {
|
|
801
|
+
const status = Number(error?.status ?? error?.statusCode);
|
|
802
|
+
const message = String(error?.message || '');
|
|
803
|
+
if (message.includes('404') || status === 404) return { __absent: true };
|
|
804
|
+
return { __try_projection: true };
|
|
805
|
+
});
|
|
806
|
+
if (exact && !exact.__absent && !exact.__try_projection) {
|
|
807
|
+
if (identityOf(exact) === messageID) return { status: 'found' };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Authoritative projection of recent messages. Fetch failure → unavailable
|
|
811
|
+
// (never invent absent). Successful empty/miss → absent.
|
|
812
|
+
const projected = await fetchRecentMessages(sessionId, directory);
|
|
813
|
+
if (!projected) return { status: 'unavailable' };
|
|
814
|
+
if (projected.some((entry) => identityOf(entry) === messageID || entry?.info?.id === messageID)) {
|
|
815
|
+
return { status: 'found' };
|
|
816
|
+
}
|
|
817
|
+
return { status: 'absent' };
|
|
363
818
|
};
|
|
364
819
|
|
|
365
820
|
// Live session status is authoritative for whether the session is actually
|
|
@@ -372,6 +827,17 @@ export const createSessionGoalRuntime = ({
|
|
|
372
827
|
// 'busy' | 'retry' | null (null when the status cannot be read — callers
|
|
373
828
|
// fall back to the message-tail heuristic).
|
|
374
829
|
const fetchSessionLiveStatus = async (sessionId, directory) => {
|
|
830
|
+
try {
|
|
831
|
+
const client = openCodeClient();
|
|
832
|
+
if (typeof client?.session?.active === 'function') {
|
|
833
|
+
const activeMap = await client.session.active(directoryRequestOptions(directory));
|
|
834
|
+
if (activeMap && typeof activeMap === 'object' && !Array.isArray(activeMap)) {
|
|
835
|
+
return Object.prototype.hasOwnProperty.call(activeMap, sessionId) ? 'busy' : 'idle';
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
} catch {
|
|
839
|
+
// Fall through.
|
|
840
|
+
}
|
|
375
841
|
const statusMap = await openCodeFetch('/session/status', { directory }).catch(() => null);
|
|
376
842
|
const status = statusMap?.[sessionId];
|
|
377
843
|
const type = typeof status?.type === 'string' ? status.type.trim() : '';
|
|
@@ -383,11 +849,104 @@ export const createSessionGoalRuntime = ({
|
|
|
383
849
|
// metadata writes (assist payloads, dismissals, UI goal edits) survive.
|
|
384
850
|
// Returns the written goal, or null when the stored goal no longer matches
|
|
385
851
|
// the expected id (user replaced/cleared it while we worked).
|
|
386
|
-
|
|
852
|
+
/**
|
|
853
|
+
* When the Host metadata store is wired, the goal lives there — OpenCode 2.x
|
|
854
|
+
* accepts session metadata only at create time. Re-read before every write so
|
|
855
|
+
* a concurrent edit (the user pausing from the UI) is not overwritten.
|
|
856
|
+
*/
|
|
857
|
+
const readGoal = async (sessionId, directory) => {
|
|
858
|
+
if (isMetadataStoreWired()) {
|
|
859
|
+
return parseGoalMetadata({ metadata: await readSessionMetadata(sessionId) });
|
|
860
|
+
}
|
|
861
|
+
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
|
|
862
|
+
return parseGoalMetadata(session);
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Conditional goal commit. Validates goal id, optional execution generation,
|
|
867
|
+
* and optional allowed statuses on the same serial boundary when the Host
|
|
868
|
+
* mutate seam is wired. Returns the written goal or null when preconditions fail.
|
|
869
|
+
*
|
|
870
|
+
* @param {object} args
|
|
871
|
+
* @param {string} args.sessionId
|
|
872
|
+
* @param {string} args.directory
|
|
873
|
+
* @param {string} args.expectedGoalId
|
|
874
|
+
* @param {number} [args.expectedGeneration] when set, must match committed generation
|
|
875
|
+
* @param {string[]} [args.expectedStatuses] when set, status must be one of these
|
|
876
|
+
* @param {boolean} [args.advanceGeneration] bump executionGeneration on success
|
|
877
|
+
* @param {(current: object) => object} args.mutate partial fields to merge
|
|
878
|
+
*/
|
|
879
|
+
const writeGoal = async (sessionId, directory, expectedGoalIdOrOptions, maybeMutate) => {
|
|
880
|
+
// Back-compat: writeGoal(sessionId, directory, goalId, mutate)
|
|
881
|
+
const options = typeof expectedGoalIdOrOptions === 'object' && expectedGoalIdOrOptions
|
|
882
|
+
? expectedGoalIdOrOptions
|
|
883
|
+
: {
|
|
884
|
+
expectedGoalId: expectedGoalIdOrOptions,
|
|
885
|
+
mutate: maybeMutate,
|
|
886
|
+
};
|
|
887
|
+
const {
|
|
888
|
+
expectedGoalId,
|
|
889
|
+
expectedGeneration,
|
|
890
|
+
expectedStatuses = null,
|
|
891
|
+
advanceGeneration = false,
|
|
892
|
+
mutate,
|
|
893
|
+
} = options;
|
|
894
|
+
if (typeof mutate !== 'function' || typeof expectedGoalId !== 'string' || !expectedGoalId) {
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const buildNextGoal = (currentGoal) => {
|
|
899
|
+
if (!currentGoal || currentGoal.id !== expectedGoalId) return null;
|
|
900
|
+
if (
|
|
901
|
+
expectedGeneration !== undefined
|
|
902
|
+
&& readGoalExecutionGeneration(currentGoal) !== expectedGeneration
|
|
903
|
+
) {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
if (Array.isArray(expectedStatuses) && !expectedStatuses.includes(currentGoal.status)) {
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
const patch = mutate(currentGoal) || {};
|
|
910
|
+
let nextGoal = {
|
|
911
|
+
...currentGoal,
|
|
912
|
+
...patch,
|
|
913
|
+
updatedAt: Date.now(),
|
|
914
|
+
};
|
|
915
|
+
if (advanceGeneration) {
|
|
916
|
+
nextGoal.executionGeneration = nextGoalExecutionGeneration(currentGoal);
|
|
917
|
+
} else if (!Number.isFinite(nextGoal.executionGeneration)) {
|
|
918
|
+
nextGoal.executionGeneration = readGoalExecutionGeneration(currentGoal);
|
|
919
|
+
} else {
|
|
920
|
+
nextGoal.executionGeneration = readGoalExecutionGeneration(nextGoal);
|
|
921
|
+
}
|
|
922
|
+
return nextGoal;
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
if (typeof mutateSessionMetadata === 'function') {
|
|
926
|
+
const result = await mutateSessionMetadata(sessionId, (metadata) => {
|
|
927
|
+
const currentGoal = parseGoalMetadata({ metadata });
|
|
928
|
+
const nextGoal = buildNextGoal(currentGoal);
|
|
929
|
+
if (!nextGoal) {
|
|
930
|
+
return { ok: false, reason: 'goal_precondition_failed' };
|
|
931
|
+
}
|
|
932
|
+
return { ok: true, patch: { openchamber: { goal: nextGoal } } };
|
|
933
|
+
});
|
|
934
|
+
if (!result?.committed) return null;
|
|
935
|
+
return parseGoalMetadata({ metadata: result.metadata });
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (isMetadataStoreWired()) {
|
|
939
|
+
const currentGoal = await readGoal(sessionId, directory);
|
|
940
|
+
const nextGoal = buildNextGoal(currentGoal);
|
|
941
|
+
if (!nextGoal) return null;
|
|
942
|
+
await persistSessionGoal(sessionId, directory, nextGoal);
|
|
943
|
+
return nextGoal;
|
|
944
|
+
}
|
|
945
|
+
|
|
387
946
|
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
|
|
388
947
|
const currentGoal = parseGoalMetadata(session);
|
|
389
|
-
|
|
390
|
-
|
|
948
|
+
const nextGoal = buildNextGoal(currentGoal);
|
|
949
|
+
if (!nextGoal) return null;
|
|
391
950
|
const currentMetadata = session?.metadata && typeof session.metadata === 'object' ? session.metadata : {};
|
|
392
951
|
const currentNamespace = currentMetadata.openchamber && typeof currentMetadata.openchamber === 'object'
|
|
393
952
|
? currentMetadata.openchamber
|
|
@@ -405,18 +964,30 @@ export const createSessionGoalRuntime = ({
|
|
|
405
964
|
return nextGoal;
|
|
406
965
|
};
|
|
407
966
|
|
|
408
|
-
const settleGoal = async ({ sessionId, directory, goal, status, statusReason, note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID }) => {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
967
|
+
const settleGoal = async ({ sessionId, directory, goal, status, statusReason, note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration, epochAtStart }) => {
|
|
968
|
+
if (epochAtStart !== undefined && currentDispatchEpoch(sessionId) !== epochAtStart) {
|
|
969
|
+
console.log(`[session-goal] ${sessionId} execution permit revoked before settle`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
const generation = expectedGeneration !== undefined
|
|
973
|
+
? expectedGeneration
|
|
974
|
+
: readGoalExecutionGeneration(goal);
|
|
975
|
+
const written = await writeGoal(sessionId, directory, {
|
|
976
|
+
expectedGoalId: goal.id,
|
|
977
|
+
expectedGeneration: generation,
|
|
978
|
+
expectedStatuses: ['active'],
|
|
979
|
+
mutate: (current) => ({
|
|
980
|
+
status,
|
|
981
|
+
statusReason: clampText(statusReason, REASON_CHAR_LIMIT),
|
|
982
|
+
note: note !== undefined ? clampText(note, NOTE_CHAR_LIMIT) : current.note,
|
|
983
|
+
blockedStreak: 0,
|
|
984
|
+
auditFailStreak: 0,
|
|
985
|
+
...(tokensUsed !== undefined ? { tokensUsed } : {}),
|
|
986
|
+
...(tokensBaseline !== undefined ? { tokensBaseline } : {}),
|
|
987
|
+
...(tokensCommitted !== undefined ? { tokensCommitted } : {}),
|
|
988
|
+
...(lastAccountedMessageID ? { lastAccountedMessageID } : {}),
|
|
989
|
+
}),
|
|
990
|
+
});
|
|
420
991
|
if (!written) return;
|
|
421
992
|
console.log(`[session-goal] ${sessionId} settled as ${status}${statusReason ? ` (${statusReason})` : ''}`);
|
|
422
993
|
if (typeof emitGoalNotification === 'function') {
|
|
@@ -436,13 +1007,15 @@ export const createSessionGoalRuntime = ({
|
|
|
436
1007
|
}
|
|
437
1008
|
};
|
|
438
1009
|
|
|
439
|
-
const runAudit = async ({ goal, assistantText, directory, lastAssistantInfo }) => {
|
|
1010
|
+
const runAudit = async ({ sessionId, goal, assistantText, directory, lastAssistantInfo, signal }) => {
|
|
1011
|
+
if (signal?.aborted) return null;
|
|
440
1012
|
let service;
|
|
441
1013
|
try {
|
|
442
1014
|
service = await getSmallModelService();
|
|
443
1015
|
} catch {
|
|
444
1016
|
return null;
|
|
445
1017
|
}
|
|
1018
|
+
if (signal?.aborted) return null;
|
|
446
1019
|
const preferredProviderID = typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined;
|
|
447
1020
|
const preferredModelID = typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined;
|
|
448
1021
|
const prompt = `The goal objective:\n\n<objective>\n${goal.objective}\n</objective>\n\nThe agent's latest turn:\n\n${assistantText}\n\nReturn the verdict JSON. Write the note in the SAME language as this sample from the objective: "${goal.objective.slice(0, 200).replace(/\s+/g, ' ').trim()}"`;
|
|
@@ -458,65 +1031,499 @@ export const createSessionGoalRuntime = ({
|
|
|
458
1031
|
}
|
|
459
1032
|
return { verdict, note };
|
|
460
1033
|
};
|
|
1034
|
+
const withAbort = async (work) => {
|
|
1035
|
+
if (!signal) return work();
|
|
1036
|
+
if (signal.aborted) {
|
|
1037
|
+
const abortError = new Error('goal audit aborted');
|
|
1038
|
+
abortError.name = 'AbortError';
|
|
1039
|
+
throw abortError;
|
|
1040
|
+
}
|
|
1041
|
+
return new Promise((resolve, reject) => {
|
|
1042
|
+
const onAbort = () => {
|
|
1043
|
+
const abortError = new Error('goal audit aborted');
|
|
1044
|
+
abortError.name = 'AbortError';
|
|
1045
|
+
reject(abortError);
|
|
1046
|
+
};
|
|
1047
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1048
|
+
work().then(
|
|
1049
|
+
(value) => {
|
|
1050
|
+
signal.removeEventListener('abort', onAbort);
|
|
1051
|
+
resolve(value);
|
|
1052
|
+
},
|
|
1053
|
+
(error) => {
|
|
1054
|
+
signal.removeEventListener('abort', onAbort);
|
|
1055
|
+
reject(error);
|
|
1056
|
+
},
|
|
1057
|
+
);
|
|
1058
|
+
});
|
|
1059
|
+
};
|
|
461
1060
|
try {
|
|
462
1061
|
// Prefer staying on the session provider. If that provider has no
|
|
463
1062
|
// suitable small model (404), fall back to any authenticated small
|
|
464
1063
|
// model so simple goals can still settle instead of stranding as
|
|
465
1064
|
// "evaluating" then "blocked".
|
|
466
1065
|
try {
|
|
467
|
-
const generated = await service.generateSmallModelText({
|
|
1066
|
+
const generated = await withAbort(() => service.generateSmallModelText({
|
|
468
1067
|
restrictToPreferredProvider: true,
|
|
469
1068
|
prompt,
|
|
470
1069
|
system,
|
|
471
1070
|
directory,
|
|
472
1071
|
preferredProviderID,
|
|
473
1072
|
preferredModelID,
|
|
474
|
-
});
|
|
1073
|
+
}));
|
|
1074
|
+
if (signal?.aborted) return null;
|
|
475
1075
|
return parseAudit(generated);
|
|
476
1076
|
} catch (restrictedError) {
|
|
1077
|
+
if (restrictedError?.name === 'AbortError' || signal?.aborted) return null;
|
|
477
1078
|
if (Number(restrictedError?.statusCode) !== 404) throw restrictedError;
|
|
478
|
-
const generated = await service.generateSmallModelText({
|
|
1079
|
+
const generated = await withAbort(() => service.generateSmallModelText({
|
|
479
1080
|
restrictToPreferredProvider: false,
|
|
480
1081
|
prompt,
|
|
481
1082
|
system,
|
|
482
1083
|
directory,
|
|
483
1084
|
preferredProviderID,
|
|
484
1085
|
preferredModelID,
|
|
485
|
-
});
|
|
1086
|
+
}));
|
|
1087
|
+
if (signal?.aborted) return null;
|
|
486
1088
|
return parseAudit(generated);
|
|
487
1089
|
}
|
|
488
1090
|
} catch (error) {
|
|
1091
|
+
if (error?.name === 'AbortError' || signal?.aborted) return null;
|
|
489
1092
|
// No authenticated small model (404) or a transient failure — the loop
|
|
490
1093
|
// still terminates via budget and the turn cap.
|
|
491
1094
|
if (Number(error?.statusCode) !== 404) {
|
|
492
1095
|
console.warn('[session-goal] audit failed:', error?.message || error);
|
|
493
1096
|
}
|
|
494
1097
|
return null;
|
|
1098
|
+
} finally {
|
|
1099
|
+
if (sessionId && auditControllers.get(sessionId)?.signal === signal) {
|
|
1100
|
+
auditControllers.delete(sessionId);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Local dispatch still permitted for this captured epoch?
|
|
1107
|
+
* Checked after every await and immediately before prompt leaves process.
|
|
1108
|
+
*/
|
|
1109
|
+
const isContinuationDispatchLive = (sessionId, epoch) => {
|
|
1110
|
+
if (stopped) return false;
|
|
1111
|
+
if (currentDispatchEpoch(sessionId) !== epoch) return false;
|
|
1112
|
+
if (shutdownRecoveryPending.has(sessionId)) return false;
|
|
1113
|
+
return true;
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Drop when epoch/shutdown/stopped moved (definite local revoke — not uncertain).
|
|
1118
|
+
* @returns {{ ok: false, identity: *, uncertain: false } | null}
|
|
1119
|
+
*/
|
|
1120
|
+
const dropContinuationIfRevoked = (sessionId, epoch, dispatchIdentity, reason) => {
|
|
1121
|
+
if (isContinuationDispatchLive(sessionId, epoch)) return null;
|
|
1122
|
+
console.log(`[session-goal] ${reason}, dropping continuation`);
|
|
1123
|
+
return { ok: false, identity: dispatchIdentity, uncertain: false };
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Durable generation/status still matches the committed dispatch identity.
|
|
1128
|
+
* Runs once at the prompt boundary after selection awaits.
|
|
1129
|
+
*/
|
|
1130
|
+
const dispatchIdentityStillActive = async (sessionId, directory, dispatchIdentity) => {
|
|
1131
|
+
if (!dispatchIdentity) return true;
|
|
1132
|
+
try {
|
|
1133
|
+
const live = await readGoal(sessionId, directory);
|
|
1134
|
+
if (!live || live.id !== dispatchIdentity.goalId) return false;
|
|
1135
|
+
if (live.status !== 'active') return false;
|
|
1136
|
+
if (readGoalExecutionGeneration(live) !== dispatchIdentity.generation) return false;
|
|
1137
|
+
return true;
|
|
1138
|
+
} catch {
|
|
1139
|
+
// Metadata flake: fall back to epoch/shutdown only (caller re-checks).
|
|
1140
|
+
return true;
|
|
495
1141
|
}
|
|
496
1142
|
};
|
|
497
1143
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
1144
|
+
/**
|
|
1145
|
+
* Dispatch a continuation only while the captured dispatch epoch still matches.
|
|
1146
|
+
* Official v2 path: switchAgent/switchModel then `session.prompt` with stable
|
|
1147
|
+
* message `id` (msg_ + goalId + generation + turnsUsed). Selection awaits can
|
|
1148
|
+
* race with pause/shutdown — re-validate after each await and only mark
|
|
1149
|
+
* `phase: transport` at the actual prompt boundary. Selection failures must
|
|
1150
|
+
* not be reported as prompt-uncertain. Never claims revoke of in-flight prompt.
|
|
1151
|
+
*
|
|
1152
|
+
* `pending` carries the durable fixed payload (text + selection + messageID).
|
|
1153
|
+
* turnsUsed is NOT committed here — the caller commits turns + clears pending
|
|
1154
|
+
* only after accept (or after reconcile proves receipt).
|
|
1155
|
+
*/
|
|
1156
|
+
const sendContinuation = async ({
|
|
1157
|
+
sessionId,
|
|
1158
|
+
directory,
|
|
1159
|
+
epoch,
|
|
1160
|
+
dispatchIdentity,
|
|
1161
|
+
pending,
|
|
1162
|
+
persistPendingPhase,
|
|
1163
|
+
}) => {
|
|
1164
|
+
const revoked = dropContinuationIfRevoked(sessionId, epoch, dispatchIdentity, 'dispatch epoch/shutdown at entry');
|
|
1165
|
+
if (revoked) return { ...revoked, phase: 'dropped' };
|
|
1166
|
+
|
|
1167
|
+
const providerID = pending?.providerID || '';
|
|
1168
|
+
const modelID = pending?.modelID || '';
|
|
1169
|
+
const variant = pending?.variant || '';
|
|
1170
|
+
const agent = pending?.agent || '';
|
|
501
1171
|
if (!providerID || !modelID) {
|
|
502
1172
|
throw new Error('cannot continue goal: last assistant message has no provider/model');
|
|
503
1173
|
}
|
|
504
|
-
const
|
|
505
|
-
?
|
|
506
|
-
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1174
|
+
const messageID = pending?.messageID
|
|
1175
|
+
|| (dispatchIdentity ? buildContinuationMessageID(dispatchIdentity) : undefined);
|
|
1176
|
+
const requestOptions = directoryRequestOptions(directory);
|
|
1177
|
+
const promptText = pending?.text || '';
|
|
1178
|
+
if (!promptText) {
|
|
1179
|
+
throw new Error('cannot continue goal: missing fixed continuation payload');
|
|
1180
|
+
}
|
|
1181
|
+
const promptBody = {
|
|
1182
|
+
...(messageID ? { id: messageID } : {}),
|
|
1183
|
+
text: promptText,
|
|
1184
|
+
delivery: 'steer',
|
|
1185
|
+
metadata: {
|
|
1186
|
+
openchamber: {
|
|
1187
|
+
goalContinuation: true,
|
|
1188
|
+
// User-boundary prompt (raw type remains user); synthetic is metadata only.
|
|
1189
|
+
synthetic: true,
|
|
1190
|
+
...(dispatchIdentity ? {
|
|
1191
|
+
goalId: dispatchIdentity.goalId,
|
|
1192
|
+
generation: dispatchIdentity.generation,
|
|
1193
|
+
turnsUsed: dispatchIdentity.turnsUsed,
|
|
1194
|
+
} : {}),
|
|
1195
|
+
},
|
|
518
1196
|
},
|
|
519
|
-
}
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
/** True only after identity is recorded at the prompt transport boundary. */
|
|
1200
|
+
let enteredPromptTransport = false;
|
|
1201
|
+
const markLocal = (phase, error) => {
|
|
1202
|
+
if (!dispatchIdentity) return;
|
|
1203
|
+
dispatchedContinuations.set(sessionId, {
|
|
1204
|
+
...dispatchIdentity,
|
|
1205
|
+
messageID: messageID || null,
|
|
1206
|
+
phase,
|
|
1207
|
+
...(error ? { error: error?.message || String(error) } : {}),
|
|
1208
|
+
at: Date.now(),
|
|
1209
|
+
});
|
|
1210
|
+
};
|
|
1211
|
+
const markPromptTransport = async () => {
|
|
1212
|
+
markLocal('transport');
|
|
1213
|
+
enteredPromptTransport = true;
|
|
1214
|
+
if (typeof persistPendingPhase === 'function') {
|
|
1215
|
+
await persistPendingPhase('transport');
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
const markPromptAccepted = () => {
|
|
1219
|
+
markLocal('accepted');
|
|
1220
|
+
};
|
|
1221
|
+
const markPromptUncertain = async (error) => {
|
|
1222
|
+
if (!dispatchIdentity || !enteredPromptTransport) return;
|
|
1223
|
+
markLocal('uncertain', error);
|
|
1224
|
+
if (typeof persistPendingPhase === 'function') {
|
|
1225
|
+
await persistPendingPhase('uncertain', error?.message || String(error));
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
try {
|
|
1230
|
+
const client = openCodeClient();
|
|
1231
|
+
if (typeof client?.session?.prompt === 'function') {
|
|
1232
|
+
if (typeof client.session.switchAgent === 'function' && agent) {
|
|
1233
|
+
await client.session.switchAgent({ sessionID: sessionId, agent }, requestOptions);
|
|
1234
|
+
const afterAgent = dropContinuationIfRevoked(
|
|
1235
|
+
sessionId, epoch, dispatchIdentity, 'dispatch revoked after switchAgent',
|
|
1236
|
+
);
|
|
1237
|
+
if (afterAgent) return { ...afterAgent, phase: 'dropped' };
|
|
1238
|
+
}
|
|
1239
|
+
if (typeof client.session.switchModel === 'function') {
|
|
1240
|
+
await client.session.switchModel({
|
|
1241
|
+
sessionID: sessionId,
|
|
1242
|
+
model: {
|
|
1243
|
+
id: modelID,
|
|
1244
|
+
providerID,
|
|
1245
|
+
...(variant ? { variant } : {}),
|
|
1246
|
+
},
|
|
1247
|
+
}, requestOptions);
|
|
1248
|
+
const afterModel = dropContinuationIfRevoked(
|
|
1249
|
+
sessionId, epoch, dispatchIdentity, 'dispatch revoked after switchModel',
|
|
1250
|
+
);
|
|
1251
|
+
if (afterModel) return { ...afterModel, phase: 'dropped' };
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Generation/status may have moved while selection awaited.
|
|
1255
|
+
if (!(await dispatchIdentityStillActive(sessionId, directory, dispatchIdentity))) {
|
|
1256
|
+
console.log('[session-goal] generation/status moved before prompt, dropping continuation');
|
|
1257
|
+
return { ok: false, identity: dispatchIdentity, uncertain: false, phase: 'dropped' };
|
|
1258
|
+
}
|
|
1259
|
+
const beforePrompt = dropContinuationIfRevoked(
|
|
1260
|
+
sessionId, epoch, dispatchIdentity, 'dispatch revoked before prompt',
|
|
1261
|
+
);
|
|
1262
|
+
if (beforePrompt) return { ...beforePrompt, phase: 'dropped' };
|
|
1263
|
+
|
|
1264
|
+
// Record identity only at the actual prompt boundary so selection
|
|
1265
|
+
// failure cannot look like prompt-uncertain, and pause during
|
|
1266
|
+
// switchAgent/switchModel does not claim transport began.
|
|
1267
|
+
await markPromptTransport();
|
|
1268
|
+
await client.session.prompt({
|
|
1269
|
+
sessionID: sessionId,
|
|
1270
|
+
...promptBody,
|
|
1271
|
+
}, requestOptions);
|
|
1272
|
+
} else {
|
|
1273
|
+
// Controlled HTTP seam (tests / clients without SDK prompt): POST /prompt
|
|
1274
|
+
// with the same v2 body shape so id enters the payload.
|
|
1275
|
+
if (!(await dispatchIdentityStillActive(sessionId, directory, dispatchIdentity))) {
|
|
1276
|
+
console.log('[session-goal] generation/status moved before prompt, dropping continuation');
|
|
1277
|
+
return { ok: false, identity: dispatchIdentity, uncertain: false, phase: 'dropped' };
|
|
1278
|
+
}
|
|
1279
|
+
const beforePrompt = dropContinuationIfRevoked(
|
|
1280
|
+
sessionId, epoch, dispatchIdentity, 'dispatch revoked before prompt',
|
|
1281
|
+
);
|
|
1282
|
+
if (beforePrompt) return { ...beforePrompt, phase: 'dropped' };
|
|
1283
|
+
|
|
1284
|
+
await markPromptTransport();
|
|
1285
|
+
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt`, {
|
|
1286
|
+
directory,
|
|
1287
|
+
method: 'POST',
|
|
1288
|
+
body: promptBody,
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
markPromptAccepted();
|
|
1292
|
+
return { ok: true, identity: dispatchIdentity, uncertain: false, phase: 'accepted' };
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
// Only prompt-boundary failures are uncertain. switchAgent/switchModel
|
|
1295
|
+
// (selection) errors must not pretend a prompt may have been accepted.
|
|
1296
|
+
await markPromptUncertain(error);
|
|
1297
|
+
if (enteredPromptTransport) {
|
|
1298
|
+
return {
|
|
1299
|
+
ok: false,
|
|
1300
|
+
identity: dispatchIdentity,
|
|
1301
|
+
uncertain: true,
|
|
1302
|
+
phase: 'uncertain',
|
|
1303
|
+
error,
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
throw error;
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Commit turnsUsed + clear pendingContinuation under the same conditional write
|
|
1312
|
+
* after upstream accept (or reconcile found). Returns written goal or null.
|
|
1313
|
+
*/
|
|
1314
|
+
const commitContinuationAccepted = async ({
|
|
1315
|
+
sessionId,
|
|
1316
|
+
directory,
|
|
1317
|
+
expectedGoalId,
|
|
1318
|
+
expectedGeneration,
|
|
1319
|
+
expectedStatuses = ['active'],
|
|
1320
|
+
pendingTurnsUsed,
|
|
1321
|
+
tokensUsed,
|
|
1322
|
+
tokensBaseline,
|
|
1323
|
+
tokensCommitted,
|
|
1324
|
+
lastAccountedMessageID,
|
|
1325
|
+
blockedStreak,
|
|
1326
|
+
auditFailStreak,
|
|
1327
|
+
note,
|
|
1328
|
+
extraMutate,
|
|
1329
|
+
}) => writeGoal(sessionId, directory, {
|
|
1330
|
+
expectedGoalId,
|
|
1331
|
+
expectedGeneration,
|
|
1332
|
+
expectedStatuses,
|
|
1333
|
+
mutate: (current) => {
|
|
1334
|
+
const nextTurns = Math.max(
|
|
1335
|
+
Number.isFinite(current.turnsUsed) ? current.turnsUsed : 0,
|
|
1336
|
+
Number.isFinite(pendingTurnsUsed) ? pendingTurnsUsed : 0,
|
|
1337
|
+
);
|
|
1338
|
+
return {
|
|
1339
|
+
...(tokensUsed !== undefined ? { tokensUsed } : {}),
|
|
1340
|
+
...(tokensBaseline !== undefined ? { tokensBaseline } : {}),
|
|
1341
|
+
...(tokensCommitted !== undefined ? { tokensCommitted } : {}),
|
|
1342
|
+
...(lastAccountedMessageID ? { lastAccountedMessageID } : {}),
|
|
1343
|
+
...(blockedStreak !== undefined ? { blockedStreak } : {}),
|
|
1344
|
+
...(auditFailStreak !== undefined ? { auditFailStreak } : {}),
|
|
1345
|
+
...(note !== undefined ? { note: clampText(note, NOTE_CHAR_LIMIT) } : {}),
|
|
1346
|
+
turnsUsed: nextTurns,
|
|
1347
|
+
pendingContinuation: null,
|
|
1348
|
+
statusReason: '',
|
|
1349
|
+
...(typeof extraMutate === 'function' ? (extraMutate(current) || {}) : {}),
|
|
1350
|
+
};
|
|
1351
|
+
},
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Clear a reserved (pre-transport) admission without counting a turn.
|
|
1356
|
+
* Used when selection fails or pause lands before prompt leaves process.
|
|
1357
|
+
* Generation is intentionally not required — pause may have advanced it.
|
|
1358
|
+
*/
|
|
1359
|
+
const clearReservedContinuation = async ({
|
|
1360
|
+
sessionId,
|
|
1361
|
+
directory,
|
|
1362
|
+
expectedGoalId,
|
|
1363
|
+
messageID,
|
|
1364
|
+
}) => writeGoal(sessionId, directory, {
|
|
1365
|
+
expectedGoalId,
|
|
1366
|
+
// Only clear when pending still matches this reserved message id.
|
|
1367
|
+
mutate: (current) => {
|
|
1368
|
+
const pending = parsePendingContinuation(current.pendingContinuation);
|
|
1369
|
+
if (!pending || pending.messageID !== messageID) return {};
|
|
1370
|
+
if (pending.phase !== 'reserved') return {};
|
|
1371
|
+
return { pendingContinuation: null };
|
|
1372
|
+
},
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Update durable pending phase (transport / uncertain) without bumping turns.
|
|
1377
|
+
*/
|
|
1378
|
+
const updatePendingContinuationPhase = async ({
|
|
1379
|
+
sessionId,
|
|
1380
|
+
directory,
|
|
1381
|
+
expectedGoalId,
|
|
1382
|
+
expectedGeneration,
|
|
1383
|
+
messageID,
|
|
1384
|
+
phase,
|
|
1385
|
+
error = '',
|
|
1386
|
+
}) => writeGoal(sessionId, directory, {
|
|
1387
|
+
expectedGoalId,
|
|
1388
|
+
expectedGeneration,
|
|
1389
|
+
expectedStatuses: ['active'],
|
|
1390
|
+
mutate: (current) => {
|
|
1391
|
+
const pending = parsePendingContinuation(current.pendingContinuation);
|
|
1392
|
+
if (!pending || pending.messageID !== messageID) return {};
|
|
1393
|
+
return {
|
|
1394
|
+
pendingContinuation: buildPendingContinuation({
|
|
1395
|
+
...pending,
|
|
1396
|
+
phase,
|
|
1397
|
+
error,
|
|
1398
|
+
at: Date.now(),
|
|
1399
|
+
}),
|
|
1400
|
+
};
|
|
1401
|
+
},
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* Ticket 10: reconcile durable pendingContinuation before any new dispatch.
|
|
1406
|
+
* found → count once + clear pending; absent + live gen → may re-dispatch;
|
|
1407
|
+
* unavailable → pause auto-continue with stable identity retained.
|
|
1408
|
+
*
|
|
1409
|
+
* @returns {'clear' | 'dispatch' | 'pause' | 'skip'}
|
|
1410
|
+
*/
|
|
1411
|
+
const reconcilePendingContinuation = async ({
|
|
1412
|
+
sessionId,
|
|
1413
|
+
directory,
|
|
1414
|
+
goal,
|
|
1415
|
+
epochAtStart,
|
|
1416
|
+
}) => {
|
|
1417
|
+
const pending = parsePendingContinuation(goal.pendingContinuation);
|
|
1418
|
+
if (!pending) return 'clear';
|
|
1419
|
+
|
|
1420
|
+
// Stale admission from a prior goal id or superseded generation: drop without
|
|
1421
|
+
// counting. Never let old goal pending overwrite newer user operations.
|
|
1422
|
+
if (pending.goalId !== goal.id) {
|
|
1423
|
+
await writeGoal(sessionId, directory, {
|
|
1424
|
+
expectedGoalId: goal.id,
|
|
1425
|
+
expectedGeneration: readGoalExecutionGeneration(goal),
|
|
1426
|
+
mutate: () => ({ pendingContinuation: null }),
|
|
1427
|
+
});
|
|
1428
|
+
return 'clear';
|
|
1429
|
+
}
|
|
1430
|
+
if (pending.generation !== readGoalExecutionGeneration(goal)) {
|
|
1431
|
+
// Generation advanced (pause/resume/replace). If upstream already has the
|
|
1432
|
+
// message, count once under the current goal without re-dispatching.
|
|
1433
|
+
const lookup = await findContinuationMessage(sessionId, directory, pending.messageID);
|
|
1434
|
+
if (lookup.status === 'found') {
|
|
1435
|
+
await commitContinuationAccepted({
|
|
1436
|
+
sessionId,
|
|
1437
|
+
directory,
|
|
1438
|
+
expectedGoalId: goal.id,
|
|
1439
|
+
expectedGeneration: readGoalExecutionGeneration(goal),
|
|
1440
|
+
expectedStatuses: ['active', 'paused', 'blocked', 'budgetLimited', 'complete'],
|
|
1441
|
+
pendingTurnsUsed: pending.turnsUsed,
|
|
1442
|
+
});
|
|
1443
|
+
console.log(`[session-goal] ${sessionId} stale-gen pending found upstream — counted once`);
|
|
1444
|
+
return 'clear';
|
|
1445
|
+
}
|
|
1446
|
+
if (lookup.status === 'unavailable') {
|
|
1447
|
+
// Keep identity; do not invent absent. Pause only when still active.
|
|
1448
|
+
if (goal.status === 'active') {
|
|
1449
|
+
await writeGoal(sessionId, directory, {
|
|
1450
|
+
expectedGoalId: goal.id,
|
|
1451
|
+
expectedGeneration: readGoalExecutionGeneration(goal),
|
|
1452
|
+
expectedStatuses: ['active'],
|
|
1453
|
+
advanceGeneration: true,
|
|
1454
|
+
mutate: () => ({
|
|
1455
|
+
status: 'paused',
|
|
1456
|
+
statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
|
|
1457
|
+
}),
|
|
1458
|
+
});
|
|
1459
|
+
revokeLocalExecutionPermit(sessionId);
|
|
1460
|
+
console.log(`[session-goal] ${sessionId} pending reconcile unavailable — paused`);
|
|
1461
|
+
return 'pause';
|
|
1462
|
+
}
|
|
1463
|
+
return 'skip';
|
|
1464
|
+
}
|
|
1465
|
+
// Absent under new generation: clear stale reserved work, never re-dispatch.
|
|
1466
|
+
await writeGoal(sessionId, directory, {
|
|
1467
|
+
expectedGoalId: goal.id,
|
|
1468
|
+
expectedGeneration: readGoalExecutionGeneration(goal),
|
|
1469
|
+
mutate: () => ({ pendingContinuation: null }),
|
|
1470
|
+
});
|
|
1471
|
+
return 'clear';
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const lookup = await findContinuationMessage(sessionId, directory, pending.messageID);
|
|
1475
|
+
if (lookup.status === 'found') {
|
|
1476
|
+
await commitContinuationAccepted({
|
|
1477
|
+
sessionId,
|
|
1478
|
+
directory,
|
|
1479
|
+
expectedGoalId: goal.id,
|
|
1480
|
+
expectedGeneration: readGoalExecutionGeneration(goal),
|
|
1481
|
+
expectedStatuses: goal.status === 'active'
|
|
1482
|
+
? ['active']
|
|
1483
|
+
: ['active', 'paused', 'blocked', 'budgetLimited', 'complete'],
|
|
1484
|
+
pendingTurnsUsed: pending.turnsUsed,
|
|
1485
|
+
});
|
|
1486
|
+
console.log(`[session-goal] ${sessionId} pending continuation found — counted once`);
|
|
1487
|
+
return 'clear';
|
|
1488
|
+
}
|
|
1489
|
+
if (lookup.status === 'unavailable') {
|
|
1490
|
+
if (goal.status === 'active' && currentDispatchEpoch(sessionId) === epochAtStart) {
|
|
1491
|
+
await writeGoal(sessionId, directory, {
|
|
1492
|
+
expectedGoalId: goal.id,
|
|
1493
|
+
expectedGeneration: readGoalExecutionGeneration(goal),
|
|
1494
|
+
expectedStatuses: ['active'],
|
|
1495
|
+
advanceGeneration: true,
|
|
1496
|
+
mutate: () => ({
|
|
1497
|
+
status: 'paused',
|
|
1498
|
+
statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
|
|
1499
|
+
// Keep pendingContinuation so reboot can retry reconcile with stable id.
|
|
1500
|
+
}),
|
|
1501
|
+
});
|
|
1502
|
+
revokeLocalExecutionPermit(sessionId);
|
|
1503
|
+
console.log(`[session-goal] ${sessionId} pending reconcile unavailable — paused with identity`);
|
|
1504
|
+
return 'pause';
|
|
1505
|
+
}
|
|
1506
|
+
return 'skip';
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// Absent: reserved/transport/uncertain may legally re-dispatch same id + payload.
|
|
1510
|
+
if (goal.status !== 'active') return 'skip';
|
|
1511
|
+
if (pending.phase === 'accepted') {
|
|
1512
|
+
// Accepted locally but not yet visible — treat as unavailable scope, not empty.
|
|
1513
|
+
await writeGoal(sessionId, directory, {
|
|
1514
|
+
expectedGoalId: goal.id,
|
|
1515
|
+
expectedGeneration: readGoalExecutionGeneration(goal),
|
|
1516
|
+
expectedStatuses: ['active'],
|
|
1517
|
+
advanceGeneration: true,
|
|
1518
|
+
mutate: () => ({
|
|
1519
|
+
status: 'paused',
|
|
1520
|
+
statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
|
|
1521
|
+
}),
|
|
1522
|
+
});
|
|
1523
|
+
revokeLocalExecutionPermit(sessionId);
|
|
1524
|
+
return 'pause';
|
|
1525
|
+
}
|
|
1526
|
+
return 'dispatch';
|
|
520
1527
|
};
|
|
521
1528
|
|
|
522
1529
|
const tick = async (sessionId, directory) => {
|
|
@@ -528,6 +1535,10 @@ export const createSessionGoalRuntime = ({
|
|
|
528
1535
|
return;
|
|
529
1536
|
}
|
|
530
1537
|
|
|
1538
|
+
// Capture dispatch epoch before any await so a concurrent pause cannot
|
|
1539
|
+
// slip a continuation through after local permit revoke.
|
|
1540
|
+
const epochAtStart = currentDispatchEpoch(sessionId);
|
|
1541
|
+
|
|
531
1542
|
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
|
|
532
1543
|
.catch((error) => {
|
|
533
1544
|
console.warn(`[session-goal] session fetch failed: ${error?.message || error}`);
|
|
@@ -537,8 +1548,131 @@ export const createSessionGoalRuntime = ({
|
|
|
537
1548
|
// Sub-agent/task sessions never carry user goals — skip them.
|
|
538
1549
|
if (typeof session.parentID === 'string' && session.parentID) return;
|
|
539
1550
|
|
|
540
|
-
|
|
1551
|
+
let goal = isMetadataStoreWired()
|
|
1552
|
+
? await readGoal(sessionId, directory)
|
|
1553
|
+
: parseGoalMetadata(session);
|
|
541
1554
|
if (!goal || goal.status !== 'active') return;
|
|
1555
|
+
if (currentDispatchEpoch(sessionId) !== epochAtStart) return;
|
|
1556
|
+
|
|
1557
|
+
// Shutdown recovery (ticket 07): keep auto-continue gated until an
|
|
1558
|
+
// authoritative idle/terminal clears the local recovery marker.
|
|
1559
|
+
if (shutdownRecoveryPending.has(sessionId)) {
|
|
1560
|
+
console.log(`[session-goal] ${sessionId} shutdown recovery pending, skip tick`);
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// Ticket 10: durable pending admission — reconcile before any new work.
|
|
1565
|
+
// found → turns counted once; unavailable → pause with stable identity;
|
|
1566
|
+
// dispatch → re-send same id + fixed payload (legal SDK idempotency).
|
|
1567
|
+
// Any pending handled this tick ends the tick (do not immediately open a
|
|
1568
|
+
// new admission slot after counting/re-dispatching the prior one).
|
|
1569
|
+
if (goal.pendingContinuation) {
|
|
1570
|
+
const pendingAction = await reconcilePendingContinuation({
|
|
1571
|
+
sessionId,
|
|
1572
|
+
directory,
|
|
1573
|
+
goal,
|
|
1574
|
+
epochAtStart,
|
|
1575
|
+
});
|
|
1576
|
+
if (pendingAction === 'pause' || pendingAction === 'skip' || pendingAction === 'clear') {
|
|
1577
|
+
// clear = counted once or dropped stale identity — wait for next idle.
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
// Re-read after reconcile may have mutated pending phase only.
|
|
1581
|
+
goal = isMetadataStoreWired()
|
|
1582
|
+
? await readGoal(sessionId, directory)
|
|
1583
|
+
: parseGoalMetadata(session);
|
|
1584
|
+
if (!goal || goal.status !== 'active') return;
|
|
1585
|
+
if (currentDispatchEpoch(sessionId) !== epochAtStart) return;
|
|
1586
|
+
|
|
1587
|
+
if (pendingAction === 'dispatch' && goal.pendingContinuation) {
|
|
1588
|
+
const pending = goal.pendingContinuation;
|
|
1589
|
+
const dispatchIdentity = {
|
|
1590
|
+
goalId: pending.goalId,
|
|
1591
|
+
generation: pending.generation,
|
|
1592
|
+
turnsUsed: pending.turnsUsed,
|
|
1593
|
+
};
|
|
1594
|
+
const goalGeneration = readGoalExecutionGeneration(goal);
|
|
1595
|
+
const persistPendingPhase = async (phase, error) => {
|
|
1596
|
+
await updatePendingContinuationPhase({
|
|
1597
|
+
sessionId,
|
|
1598
|
+
directory,
|
|
1599
|
+
expectedGoalId: goal.id,
|
|
1600
|
+
expectedGeneration: goalGeneration,
|
|
1601
|
+
messageID: pending.messageID,
|
|
1602
|
+
phase,
|
|
1603
|
+
error,
|
|
1604
|
+
});
|
|
1605
|
+
};
|
|
1606
|
+
console.log(`[session-goal] ${sessionId} re-dispatching pending continuation ${pending.messageID}`);
|
|
1607
|
+
const result = await sendContinuation({
|
|
1608
|
+
sessionId,
|
|
1609
|
+
directory,
|
|
1610
|
+
epoch: epochAtStart,
|
|
1611
|
+
dispatchIdentity,
|
|
1612
|
+
pending,
|
|
1613
|
+
persistPendingPhase,
|
|
1614
|
+
});
|
|
1615
|
+
if (result?.ok) {
|
|
1616
|
+
const committed = await commitContinuationAccepted({
|
|
1617
|
+
sessionId,
|
|
1618
|
+
directory,
|
|
1619
|
+
expectedGoalId: goal.id,
|
|
1620
|
+
expectedGeneration: goalGeneration,
|
|
1621
|
+
pendingTurnsUsed: pending.turnsUsed,
|
|
1622
|
+
});
|
|
1623
|
+
if (!committed) {
|
|
1624
|
+
// Accept landed but durable commit failed — keep pending as accepted
|
|
1625
|
+
// so reboot reconcile can count once without blind new id.
|
|
1626
|
+
await writeGoal(sessionId, directory, {
|
|
1627
|
+
expectedGoalId: goal.id,
|
|
1628
|
+
expectedGeneration: goalGeneration,
|
|
1629
|
+
expectedStatuses: ['active'],
|
|
1630
|
+
mutate: (current) => {
|
|
1631
|
+
const live = parsePendingContinuation(current.pendingContinuation);
|
|
1632
|
+
if (!live || live.messageID !== pending.messageID) return {};
|
|
1633
|
+
return {
|
|
1634
|
+
pendingContinuation: buildPendingContinuation({
|
|
1635
|
+
...live,
|
|
1636
|
+
phase: 'accepted',
|
|
1637
|
+
at: Date.now(),
|
|
1638
|
+
}),
|
|
1639
|
+
};
|
|
1640
|
+
},
|
|
1641
|
+
}).catch(() => null);
|
|
1642
|
+
console.warn(`[session-goal] ${sessionId} persist failure after accept — pending kept`);
|
|
1643
|
+
}
|
|
1644
|
+
} else if (result?.phase === 'dropped') {
|
|
1645
|
+
await clearReservedContinuation({
|
|
1646
|
+
sessionId,
|
|
1647
|
+
directory,
|
|
1648
|
+
expectedGoalId: goal.id,
|
|
1649
|
+
messageID: pending.messageID,
|
|
1650
|
+
});
|
|
1651
|
+
} else if (result?.uncertain) {
|
|
1652
|
+
// Durable phase already updated via persistPendingPhase; pause auto-continue.
|
|
1653
|
+
await writeGoal(sessionId, directory, {
|
|
1654
|
+
expectedGoalId: goal.id,
|
|
1655
|
+
expectedGeneration: goalGeneration,
|
|
1656
|
+
expectedStatuses: ['active'],
|
|
1657
|
+
advanceGeneration: true,
|
|
1658
|
+
mutate: () => ({
|
|
1659
|
+
status: 'paused',
|
|
1660
|
+
statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
|
|
1661
|
+
}),
|
|
1662
|
+
});
|
|
1663
|
+
revokeLocalExecutionPermit(sessionId);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const goalGeneration = readGoalExecutionGeneration(goal);
|
|
1670
|
+
const commitActive = (mutate) => writeGoal(sessionId, directory, {
|
|
1671
|
+
expectedGoalId: goal.id,
|
|
1672
|
+
expectedGeneration: goalGeneration,
|
|
1673
|
+
expectedStatuses: ['active'],
|
|
1674
|
+
mutate,
|
|
1675
|
+
});
|
|
542
1676
|
|
|
543
1677
|
// File-backed objectives: the metadata carries only a flag; the objective
|
|
544
1678
|
// TEXT lives under the OpenChamber data dir keyed by session id and is
|
|
@@ -563,7 +1697,7 @@ export const createSessionGoalRuntime = ({
|
|
|
563
1697
|
|
|
564
1698
|
let lastAssistant = null;
|
|
565
1699
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
566
|
-
if (messages[i]?.info
|
|
1700
|
+
if (messageRole(messages[i]?.info) === 'assistant') {
|
|
567
1701
|
lastAssistant = messages[i];
|
|
568
1702
|
break;
|
|
569
1703
|
}
|
|
@@ -578,7 +1712,7 @@ export const createSessionGoalRuntime = ({
|
|
|
578
1712
|
let executionInfo = null;
|
|
579
1713
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
580
1714
|
const info = messages[i]?.info;
|
|
581
|
-
if (info
|
|
1715
|
+
if (messageRole(info) === 'assistant' && !isSummaryMessage(info)) {
|
|
582
1716
|
executionInfo = info;
|
|
583
1717
|
break;
|
|
584
1718
|
}
|
|
@@ -596,7 +1730,7 @@ export const createSessionGoalRuntime = ({
|
|
|
596
1730
|
// goal on "evaluating". Corroborate against the live session status before
|
|
597
1731
|
// bailing — if the session is really idle, treat the orphan as a dead tail
|
|
598
1732
|
// and let the loop resume; only a genuinely busy session bails here.
|
|
599
|
-
if (lastMessageInfo
|
|
1733
|
+
if (messageRole(lastMessageInfo) === 'user') return;
|
|
600
1734
|
if (lastAssistantInfo && !(lastAssistantInfo.time?.completed > 0) && !lastAssistantInfo.error) {
|
|
601
1735
|
const live = await fetchSessionLiveStatus(sessionId, directory);
|
|
602
1736
|
// live === 'idle' → restart orphan: resume past it. live === null →
|
|
@@ -634,14 +1768,21 @@ export const createSessionGoalRuntime = ({
|
|
|
634
1768
|
// aborted reply is not evidence of anything).
|
|
635
1769
|
const abortedTail = lastAssistantInfo.error?.name === 'MessageAbortedError';
|
|
636
1770
|
if (abortedTail && goal.statusReason !== 'resumed') {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
1771
|
+
revokeLocalExecutionPermit(sessionId);
|
|
1772
|
+
await writeGoal(sessionId, directory, {
|
|
1773
|
+
expectedGoalId: goal.id,
|
|
1774
|
+
expectedGeneration: goalGeneration,
|
|
1775
|
+
expectedStatuses: ['active'],
|
|
1776
|
+
advanceGeneration: true,
|
|
1777
|
+
mutate: () => ({
|
|
1778
|
+
status: 'paused',
|
|
1779
|
+
statusReason: 'paused after abort',
|
|
1780
|
+
tokensUsed,
|
|
1781
|
+
tokensBaseline,
|
|
1782
|
+
tokensCommitted,
|
|
1783
|
+
lastAccountedMessageID,
|
|
1784
|
+
}),
|
|
1785
|
+
});
|
|
645
1786
|
console.log(`[session-goal] ${sessionId} paused after user abort`);
|
|
646
1787
|
return;
|
|
647
1788
|
}
|
|
@@ -652,7 +1793,7 @@ export const createSessionGoalRuntime = ({
|
|
|
652
1793
|
? lastAssistantInfo.error.name
|
|
653
1794
|
: 'assistant turn failed';
|
|
654
1795
|
await settleGoal({
|
|
655
|
-
sessionId, directory, goal, status: 'blocked', statusReason: reason, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
|
|
1796
|
+
sessionId, directory, goal, status: 'blocked', statusReason: reason, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration: goalGeneration, epochAtStart,
|
|
656
1797
|
});
|
|
657
1798
|
return;
|
|
658
1799
|
}
|
|
@@ -660,7 +1801,7 @@ export const createSessionGoalRuntime = ({
|
|
|
660
1801
|
// Token budget crossed → budgetLimited.
|
|
661
1802
|
if (typeof goal.tokenBudget === 'number' && tokensUsed >= goal.tokenBudget) {
|
|
662
1803
|
await settleGoal({
|
|
663
|
-
sessionId, directory, goal, status: 'budgetLimited', statusReason: 'token budget reached', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
|
|
1804
|
+
sessionId, directory, goal, status: 'budgetLimited', statusReason: 'token budget reached', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration: goalGeneration, epochAtStart,
|
|
664
1805
|
});
|
|
665
1806
|
return;
|
|
666
1807
|
}
|
|
@@ -668,7 +1809,7 @@ export const createSessionGoalRuntime = ({
|
|
|
668
1809
|
// Auto-continuation safety cap → blocked.
|
|
669
1810
|
if (goal.turnsUsed >= maxAutoTurns) {
|
|
670
1811
|
await settleGoal({
|
|
671
|
-
sessionId, directory, goal, status: 'blocked', statusReason: 'auto-continuation limit reached', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
|
|
1812
|
+
sessionId, directory, goal, status: 'blocked', statusReason: 'auto-continuation limit reached', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration: goalGeneration, epochAtStart,
|
|
672
1813
|
});
|
|
673
1814
|
return;
|
|
674
1815
|
}
|
|
@@ -683,16 +1824,42 @@ export const createSessionGoalRuntime = ({
|
|
|
683
1824
|
let audit = null;
|
|
684
1825
|
let blockedStreak = 0;
|
|
685
1826
|
let auditFailStreak = goal.auditFailStreak;
|
|
686
|
-
if (lastAssistantInfo
|
|
1827
|
+
if (isSummaryMessage(lastAssistantInfo) || abortedTail) {
|
|
687
1828
|
blockedStreak = goal.blockedStreak;
|
|
688
1829
|
} else {
|
|
689
|
-
|
|
1830
|
+
const auditController = new AbortController();
|
|
1831
|
+
auditControllers.set(sessionId, auditController);
|
|
1832
|
+
try {
|
|
1833
|
+
audit = await runAudit({
|
|
1834
|
+
sessionId,
|
|
1835
|
+
goal: { ...goal, objective: effectiveObjective },
|
|
1836
|
+
assistantText,
|
|
1837
|
+
directory,
|
|
1838
|
+
lastAssistantInfo: executionInfo ?? lastAssistantInfo,
|
|
1839
|
+
signal: auditController.signal,
|
|
1840
|
+
});
|
|
1841
|
+
} finally {
|
|
1842
|
+
if (auditControllers.get(sessionId) === auditController) {
|
|
1843
|
+
auditControllers.delete(sessionId);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// Pause/cancel during audit: drop late results — generation/epoch checks
|
|
1848
|
+
// below are the durable authority; this is the fast local path.
|
|
1849
|
+
if (currentDispatchEpoch(sessionId) !== epochAtStart) {
|
|
1850
|
+
console.log(`[session-goal] ${sessionId} execution permit revoked during audit, dropping result`);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
690
1853
|
|
|
691
1854
|
// Audit unavailable: keep going under the hard turn/budget caps rather
|
|
692
1855
|
// than flipping to "blocked" after a couple of 404s — that left simple
|
|
693
1856
|
// completed goals stranded as evaluating→blocked when no small model
|
|
694
1857
|
// was available on the session provider. Resume still re-audits.
|
|
695
1858
|
if (!audit) {
|
|
1859
|
+
if (auditController.signal.aborted) {
|
|
1860
|
+
console.log(`[session-goal] ${sessionId} audit cancelled`);
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
696
1863
|
auditFailStreak += 1;
|
|
697
1864
|
console.warn(`[session-goal] ${sessionId} audit unavailable, continuing under hard caps (${auditFailStreak} consecutive)`);
|
|
698
1865
|
} else {
|
|
@@ -701,7 +1868,7 @@ export const createSessionGoalRuntime = ({
|
|
|
701
1868
|
|
|
702
1869
|
if (audit?.verdict === 'complete') {
|
|
703
1870
|
await settleGoal({
|
|
704
|
-
sessionId, directory, goal, status: 'complete', statusReason: 'verified by audit', note: audit.note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
|
|
1871
|
+
sessionId, directory, goal, status: 'complete', statusReason: 'verified by audit', note: audit.note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration: goalGeneration, epochAtStart,
|
|
705
1872
|
});
|
|
706
1873
|
return;
|
|
707
1874
|
}
|
|
@@ -710,28 +1877,144 @@ export const createSessionGoalRuntime = ({
|
|
|
710
1877
|
blockedStreak = goal.blockedStreak + 1;
|
|
711
1878
|
if (blockedStreak >= BLOCKED_STREAK_LIMIT) {
|
|
712
1879
|
await settleGoal({
|
|
713
|
-
sessionId, directory, goal, status: 'blocked', statusReason: audit.note || 'blocked per audit', note: audit.note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
|
|
1880
|
+
sessionId, directory, goal, status: 'blocked', statusReason: audit.note || 'blocked per audit', note: audit.note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration: goalGeneration, epochAtStart,
|
|
714
1881
|
});
|
|
715
1882
|
return;
|
|
716
1883
|
}
|
|
717
1884
|
}
|
|
718
1885
|
}
|
|
719
1886
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
1887
|
+
if (currentDispatchEpoch(sessionId) !== epochAtStart) {
|
|
1888
|
+
console.log(`[session-goal] ${sessionId} execution permit revoked before continue commit`);
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// --- Continue (ticket 10 durable admission) ---
|
|
1893
|
+
// 1. Persist accounting + reserved pendingContinuation (fixed msg_id + payload)
|
|
1894
|
+
// WITHOUT bumping turnsUsed.
|
|
1895
|
+
// 2. Selection (switchAgent/switchModel); pause/fail → clear reserved, 0 count.
|
|
1896
|
+
// 3. Prompt boundary → phase transport; accept → turnsUsed + clear pending
|
|
1897
|
+
// in the same conditional commit; uncertain → durable phase + pause.
|
|
1898
|
+
const nextTurnsUsed = goal.turnsUsed + 1;
|
|
1899
|
+
const dispatchIdentity = {
|
|
1900
|
+
goalId: goal.id,
|
|
1901
|
+
generation: goalGeneration,
|
|
1902
|
+
turnsUsed: nextTurnsUsed,
|
|
1903
|
+
};
|
|
1904
|
+
const messageID = buildContinuationMessageID(dispatchIdentity);
|
|
1905
|
+
const execution = extractExecutionModel(executionInfo ?? lastAssistantInfo);
|
|
1906
|
+
if (!execution.providerID || !execution.modelID) {
|
|
1907
|
+
console.warn('[session-goal] cannot continue: no provider/model on last assistant');
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// Upstream already holds this fixed id (prior accept / reboot) → count once.
|
|
1912
|
+
const priorLookup = await findContinuationMessage(sessionId, directory, messageID);
|
|
1913
|
+
if (priorLookup.status === 'found') {
|
|
1914
|
+
await commitContinuationAccepted({
|
|
1915
|
+
sessionId,
|
|
1916
|
+
directory,
|
|
1917
|
+
expectedGoalId: goal.id,
|
|
1918
|
+
expectedGeneration: goalGeneration,
|
|
1919
|
+
pendingTurnsUsed: nextTurnsUsed,
|
|
1920
|
+
tokensUsed,
|
|
1921
|
+
tokensBaseline,
|
|
1922
|
+
tokensCommitted,
|
|
1923
|
+
lastAccountedMessageID,
|
|
1924
|
+
blockedStreak,
|
|
1925
|
+
auditFailStreak,
|
|
1926
|
+
note: audit?.note,
|
|
1927
|
+
});
|
|
1928
|
+
console.log(`[session-goal] ${sessionId} continuation ${messageID} already upstream — counted once`);
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
if (priorLookup.status === 'unavailable') {
|
|
1932
|
+
// Do not invent empty absence; pause with stable identity for later reconcile.
|
|
1933
|
+
const pendingOnly = buildPendingContinuation({
|
|
1934
|
+
messageID,
|
|
1935
|
+
goalId: goal.id,
|
|
1936
|
+
generation: goalGeneration,
|
|
1937
|
+
turnsUsed: nextTurnsUsed,
|
|
1938
|
+
phase: 'uncertain',
|
|
1939
|
+
text: buildContinuationPrompt({
|
|
1940
|
+
...goal,
|
|
1941
|
+
objective: effectiveObjective,
|
|
1942
|
+
turnsUsed: nextTurnsUsed,
|
|
1943
|
+
}),
|
|
1944
|
+
providerID: execution.providerID,
|
|
1945
|
+
modelID: execution.modelID,
|
|
1946
|
+
variant: execution.variant,
|
|
1947
|
+
agent: execution.agent,
|
|
1948
|
+
error: 'pre-dispatch reconcile unavailable',
|
|
1949
|
+
});
|
|
1950
|
+
await writeGoal(sessionId, directory, {
|
|
1951
|
+
expectedGoalId: goal.id,
|
|
1952
|
+
expectedGeneration: goalGeneration,
|
|
1953
|
+
expectedStatuses: ['active'],
|
|
1954
|
+
advanceGeneration: true,
|
|
1955
|
+
mutate: () => ({
|
|
1956
|
+
status: 'paused',
|
|
1957
|
+
statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
|
|
1958
|
+
tokensUsed,
|
|
1959
|
+
tokensBaseline,
|
|
1960
|
+
tokensCommitted,
|
|
1961
|
+
lastAccountedMessageID,
|
|
1962
|
+
blockedStreak,
|
|
1963
|
+
auditFailStreak,
|
|
1964
|
+
...(audit?.note ? { note: clampText(audit.note, NOTE_CHAR_LIMIT) } : {}),
|
|
1965
|
+
pendingContinuation: pendingOnly,
|
|
1966
|
+
}),
|
|
1967
|
+
});
|
|
1968
|
+
revokeLocalExecutionPermit(sessionId);
|
|
1969
|
+
console.log(`[session-goal] ${sessionId} pre-dispatch reconcile unavailable — paused`);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
const priorDispatch = dispatchedContinuations.get(sessionId);
|
|
1974
|
+
if (
|
|
1975
|
+
priorDispatch
|
|
1976
|
+
&& priorDispatch.goalId === dispatchIdentity.goalId
|
|
1977
|
+
&& priorDispatch.generation === dispatchIdentity.generation
|
|
1978
|
+
&& priorDispatch.turnsUsed === dispatchIdentity.turnsUsed
|
|
1979
|
+
&& (priorDispatch.phase === 'transport' || priorDispatch.phase === 'accepted' || priorDispatch.phase === 'uncertain')
|
|
1980
|
+
) {
|
|
1981
|
+
console.log('[session-goal] continuation identity already dispatched, skipping duplicate');
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
const promptText = buildContinuationPrompt({
|
|
1986
|
+
...goal,
|
|
1987
|
+
objective: effectiveObjective,
|
|
1988
|
+
turnsUsed: nextTurnsUsed,
|
|
1989
|
+
});
|
|
1990
|
+
const pending = buildPendingContinuation({
|
|
1991
|
+
messageID,
|
|
1992
|
+
goalId: goal.id,
|
|
1993
|
+
generation: goalGeneration,
|
|
1994
|
+
turnsUsed: nextTurnsUsed,
|
|
1995
|
+
phase: 'reserved',
|
|
1996
|
+
text: promptText,
|
|
1997
|
+
providerID: execution.providerID,
|
|
1998
|
+
modelID: execution.modelID,
|
|
1999
|
+
variant: execution.variant,
|
|
2000
|
+
agent: execution.agent,
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
// Reserve durable identity before selection; turnsUsed stays at prior value.
|
|
2004
|
+
const reserved = await commitActive((current) => ({
|
|
724
2005
|
tokensUsed,
|
|
725
2006
|
tokensBaseline,
|
|
726
2007
|
tokensCommitted,
|
|
727
2008
|
lastAccountedMessageID,
|
|
728
|
-
turnsUsed: current.turnsUsed + 1,
|
|
729
2009
|
blockedStreak,
|
|
730
2010
|
auditFailStreak,
|
|
731
2011
|
statusReason: '',
|
|
732
2012
|
...(audit?.note ? { note: audit.note } : {}),
|
|
2013
|
+
pendingContinuation: pending,
|
|
2014
|
+
// Explicit: do not bump turnsUsed until accept/reconcile.
|
|
2015
|
+
turnsUsed: current.turnsUsed,
|
|
733
2016
|
}));
|
|
734
|
-
if (!
|
|
2017
|
+
if (!reserved) {
|
|
735
2018
|
console.log('[session-goal] goal changed during tick, dropping continuation');
|
|
736
2019
|
return;
|
|
737
2020
|
}
|
|
@@ -741,18 +2024,132 @@ export const createSessionGoalRuntime = ({
|
|
|
741
2024
|
const latest = await fetchRecentMessages(sessionId, directory);
|
|
742
2025
|
const latestLastInfo = latest && latest.length > 0 ? latest[latest.length - 1]?.info : null;
|
|
743
2026
|
if (!latestLastInfo || latestLastInfo.id !== lastMessageInfo?.id) {
|
|
744
|
-
console.log('[session-goal] tail moved on,
|
|
2027
|
+
console.log('[session-goal] tail moved on, clearing reserved continuation');
|
|
2028
|
+
await clearReservedContinuation({
|
|
2029
|
+
sessionId,
|
|
2030
|
+
directory,
|
|
2031
|
+
expectedGoalId: goal.id,
|
|
2032
|
+
messageID,
|
|
2033
|
+
});
|
|
745
2034
|
return;
|
|
746
2035
|
}
|
|
747
2036
|
|
|
748
|
-
// Re-check question blockers immediately before dispatch
|
|
2037
|
+
// Re-check question blockers immediately before dispatch.
|
|
749
2038
|
if (typeof isQuestionBlockingGoal === 'function' && isQuestionBlockingGoal(sessionId) === true) {
|
|
750
|
-
console.log('[session-goal] question still blocking,
|
|
2039
|
+
console.log('[session-goal] question still blocking, clearing reserved continuation');
|
|
2040
|
+
await clearReservedContinuation({
|
|
2041
|
+
sessionId,
|
|
2042
|
+
directory,
|
|
2043
|
+
expectedGoalId: goal.id,
|
|
2044
|
+
messageID,
|
|
2045
|
+
});
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
// Pause confirmation (local epoch) after reserve must still block new delivery.
|
|
2050
|
+
if (currentDispatchEpoch(sessionId) !== epochAtStart) {
|
|
2051
|
+
console.log('[session-goal] paused after reserve, not dispatching (0 count)');
|
|
2052
|
+
await clearReservedContinuation({
|
|
2053
|
+
sessionId,
|
|
2054
|
+
directory,
|
|
2055
|
+
expectedGoalId: goal.id,
|
|
2056
|
+
messageID,
|
|
2057
|
+
});
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
console.log(`[session-goal] continuing ${sessionId} (pending turn ${nextTurnsUsed}/${maxAutoTurns}, tokens ${reserved.tokensUsed}${reserved.tokenBudget ? `/${reserved.tokenBudget}` : ''}, gen ${goalGeneration})`);
|
|
2062
|
+
|
|
2063
|
+
const persistPendingPhase = async (phase, error) => {
|
|
2064
|
+
await updatePendingContinuationPhase({
|
|
2065
|
+
sessionId,
|
|
2066
|
+
directory,
|
|
2067
|
+
expectedGoalId: goal.id,
|
|
2068
|
+
messageID,
|
|
2069
|
+
phase,
|
|
2070
|
+
error,
|
|
2071
|
+
});
|
|
2072
|
+
};
|
|
2073
|
+
|
|
2074
|
+
let result;
|
|
2075
|
+
try {
|
|
2076
|
+
result = await sendContinuation({
|
|
2077
|
+
sessionId,
|
|
2078
|
+
directory,
|
|
2079
|
+
epoch: epochAtStart,
|
|
2080
|
+
dispatchIdentity,
|
|
2081
|
+
pending,
|
|
2082
|
+
persistPendingPhase,
|
|
2083
|
+
});
|
|
2084
|
+
} catch (error) {
|
|
2085
|
+
// Selection failure (pre-transport): clear reserved, 0 count.
|
|
2086
|
+
await clearReservedContinuation({
|
|
2087
|
+
sessionId,
|
|
2088
|
+
directory,
|
|
2089
|
+
expectedGoalId: goal.id,
|
|
2090
|
+
messageID,
|
|
2091
|
+
});
|
|
2092
|
+
console.warn('[session-goal] continuation selection failed:', error?.message || error);
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
if (result?.ok) {
|
|
2097
|
+
const committed = await commitContinuationAccepted({
|
|
2098
|
+
sessionId,
|
|
2099
|
+
directory,
|
|
2100
|
+
expectedGoalId: goal.id,
|
|
2101
|
+
expectedGeneration: goalGeneration,
|
|
2102
|
+
pendingTurnsUsed: nextTurnsUsed,
|
|
2103
|
+
});
|
|
2104
|
+
if (!committed) {
|
|
2105
|
+
// Persist failure after accept: keep phase=accepted so reboot can count once.
|
|
2106
|
+
await writeGoal(sessionId, directory, {
|
|
2107
|
+
expectedGoalId: goal.id,
|
|
2108
|
+
expectedGeneration: goalGeneration,
|
|
2109
|
+
expectedStatuses: ['active'],
|
|
2110
|
+
mutate: (current) => {
|
|
2111
|
+
const live = parsePendingContinuation(current.pendingContinuation);
|
|
2112
|
+
if (!live || live.messageID !== messageID) return {};
|
|
2113
|
+
return {
|
|
2114
|
+
pendingContinuation: buildPendingContinuation({
|
|
2115
|
+
...live,
|
|
2116
|
+
phase: 'accepted',
|
|
2117
|
+
at: Date.now(),
|
|
2118
|
+
}),
|
|
2119
|
+
};
|
|
2120
|
+
},
|
|
2121
|
+
}).catch(() => null);
|
|
2122
|
+
console.warn(`[session-goal] ${sessionId} persist failure after accept — pending kept as accepted`);
|
|
2123
|
+
}
|
|
751
2124
|
return;
|
|
752
2125
|
}
|
|
753
2126
|
|
|
754
|
-
|
|
755
|
-
|
|
2127
|
+
if (result?.phase === 'dropped') {
|
|
2128
|
+
// Selection-time pause/revoke: 0 count, clear reserved only.
|
|
2129
|
+
await clearReservedContinuation({
|
|
2130
|
+
sessionId,
|
|
2131
|
+
directory,
|
|
2132
|
+
expectedGoalId: goal.id,
|
|
2133
|
+
messageID,
|
|
2134
|
+
});
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
if (result?.uncertain) {
|
|
2139
|
+
// Transport left process; durable uncertain phase already persisted.
|
|
2140
|
+
// Pause auto-continue with user-visible reason; keep stable identity.
|
|
2141
|
+
await writeGoal(sessionId, directory, {
|
|
2142
|
+
expectedGoalId: goal.id,
|
|
2143
|
+
expectedGeneration: goalGeneration,
|
|
2144
|
+
expectedStatuses: ['active'],
|
|
2145
|
+
advanceGeneration: true,
|
|
2146
|
+
mutate: () => ({
|
|
2147
|
+
status: 'paused',
|
|
2148
|
+
statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
|
|
2149
|
+
}),
|
|
2150
|
+
});
|
|
2151
|
+
revokeLocalExecutionPermit(sessionId);
|
|
2152
|
+
}
|
|
756
2153
|
};
|
|
757
2154
|
|
|
758
2155
|
const armTimer = (sessionId, directory, quietMs) => {
|
|
@@ -833,23 +2230,70 @@ export const createSessionGoalRuntime = ({
|
|
|
833
2230
|
// UI races: metadata may land as paused before the abort event. When the goal
|
|
834
2231
|
// is already paused for a non-question reason, still notify so question
|
|
835
2232
|
// auto-delegate timers are cancelled (abort path used to no-op on non-active).
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
2233
|
+
/**
|
|
2234
|
+
* Durable pause with generation advance + local permit revoke.
|
|
2235
|
+
* Shared by abort, question, and explicit user pause seams.
|
|
2236
|
+
*/
|
|
2237
|
+
const pauseGoal = async (sessionId, directory, statusReason = 'paused by user', {
|
|
2238
|
+
notify = true,
|
|
2239
|
+
resolveOwner = true,
|
|
2240
|
+
} = {}) => {
|
|
2241
|
+
const owner = resolveOwner
|
|
2242
|
+
? await resolveGoalOwner(sessionId, directory)
|
|
2243
|
+
: { sessionId, directory, session: null };
|
|
2244
|
+
if (!owner.sessionId) return null;
|
|
2245
|
+
|
|
2246
|
+
// Synchronous local revoke before any await on the durable write so a
|
|
2247
|
+
// concurrent tick cannot dispatch after pause is requested in-process.
|
|
2248
|
+
revokeLocalExecutionPermit(owner.sessionId);
|
|
2249
|
+
|
|
2250
|
+
const goal = isMetadataStoreWired()
|
|
2251
|
+
? await readGoal(owner.sessionId, owner.directory)
|
|
2252
|
+
: parseGoalMetadata(owner.session ?? await openCodeFetch(
|
|
2253
|
+
`/session/${encodeURIComponent(owner.sessionId)}`,
|
|
2254
|
+
{ directory: owner.directory },
|
|
2255
|
+
).catch(() => null));
|
|
2256
|
+
|
|
2257
|
+
if (!goal) return null;
|
|
840
2258
|
if (goal.status === 'paused') {
|
|
841
|
-
if (!isQuestionDrivenPause(goal)) {
|
|
2259
|
+
if (notify && !isQuestionDrivenPause(goal)) {
|
|
842
2260
|
notifyGoalPaused(owner.sessionId, owner.directory);
|
|
843
2261
|
}
|
|
844
|
-
return;
|
|
2262
|
+
return goal;
|
|
845
2263
|
}
|
|
846
|
-
if (goal.status !== 'active') return;
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
2264
|
+
if (goal.status !== 'active') return null;
|
|
2265
|
+
|
|
2266
|
+
const written = await writeGoal(owner.sessionId, owner.directory, {
|
|
2267
|
+
expectedGoalId: goal.id,
|
|
2268
|
+
expectedGeneration: readGoalExecutionGeneration(goal),
|
|
2269
|
+
expectedStatuses: ['active'],
|
|
2270
|
+
advanceGeneration: true,
|
|
2271
|
+
mutate: (current) => {
|
|
2272
|
+
// Reserved (pre-transport) pending is not yet upstream — drop it so
|
|
2273
|
+
// pause→resume cannot re-dispatch old work. Transport/uncertain/accepted
|
|
2274
|
+
// keep stable identity for reconcile (do not claim upstream revoke).
|
|
2275
|
+
const pending = parsePendingContinuation(current.pendingContinuation);
|
|
2276
|
+
const clearReserved = pending && pending.phase === 'reserved'
|
|
2277
|
+
? { pendingContinuation: null }
|
|
2278
|
+
: {};
|
|
2279
|
+
return {
|
|
2280
|
+
status: 'paused',
|
|
2281
|
+
statusReason: clampText(statusReason, REASON_CHAR_LIMIT),
|
|
2282
|
+
...clearReserved,
|
|
2283
|
+
};
|
|
2284
|
+
},
|
|
2285
|
+
});
|
|
2286
|
+
if (written) {
|
|
2287
|
+
console.log(`[session-goal] ${owner.sessionId} paused (${statusReason}) gen=${written.executionGeneration}`);
|
|
2288
|
+
if (notify && !isQuestionDrivenPause(written)) {
|
|
2289
|
+
notifyGoalPaused(owner.sessionId, owner.directory);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
return written;
|
|
2293
|
+
};
|
|
2294
|
+
|
|
2295
|
+
const pauseAfterAbort = async (sessionId, directory) => {
|
|
2296
|
+
await pauseGoal(sessionId, directory, 'paused after abort', { notify: true });
|
|
853
2297
|
};
|
|
854
2298
|
|
|
855
2299
|
// Pause the active goal when the agent asks a question — without aborting
|
|
@@ -857,24 +2301,54 @@ export const createSessionGoalRuntime = ({
|
|
|
857
2301
|
// from a child/sub-agent (any depth) pauses the root owner session's goal.
|
|
858
2302
|
const pauseForQuestion = async (sessionId, directory) => {
|
|
859
2303
|
const owner = await resolveGoalOwner(sessionId, directory);
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
if (
|
|
864
|
-
await
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
})
|
|
868
|
-
console.log(`[session-goal] ${owner.sessionId} paused for question`);
|
|
2304
|
+
// When the Host store is wired, goal state does not live on the OpenCode
|
|
2305
|
+
// record — owner.session may be null only if the session itself is missing.
|
|
2306
|
+
if (!isMetadataStoreWired() && !owner.session) return;
|
|
2307
|
+
if (isMetadataStoreWired() && !owner.sessionId) return;
|
|
2308
|
+
await pauseGoal(owner.sessionId, owner.directory, 'paused for question', {
|
|
2309
|
+
notify: false,
|
|
2310
|
+
resolveOwner: false,
|
|
2311
|
+
});
|
|
869
2312
|
// Takeover path already pauses questions; still notify for abort-style symmetry.
|
|
870
2313
|
};
|
|
871
2314
|
|
|
872
2315
|
const processPayload = (payload, directoryHint = '') => {
|
|
873
2316
|
if (stopped) return;
|
|
874
2317
|
|
|
2318
|
+
const interrupted = extractExecutionInterrupted(payload);
|
|
2319
|
+
if (interrupted) {
|
|
2320
|
+
if (interrupted.reason === 'shutdown') {
|
|
2321
|
+
// Keep goal active but close the auto-continue gate until recovery.
|
|
2322
|
+
clearTimer(interrupted.sessionId);
|
|
2323
|
+
shutdownRecoveryPending.set(interrupted.sessionId, { at: Date.now() });
|
|
2324
|
+
console.log(`[session-goal] ${interrupted.sessionId} shutdown interrupt — recovery pending`);
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
// user / superseded / inactivity: claim released upstream — clear recovery.
|
|
2328
|
+
shutdownRecoveryPending.delete(interrupted.sessionId);
|
|
2329
|
+
if (interrupted.reason === 'user') {
|
|
2330
|
+
clearTimer(interrupted.sessionId);
|
|
2331
|
+
// MessageAbortedError path usually pauses; do not double-settle here.
|
|
2332
|
+
}
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
if (
|
|
2337
|
+
payload?.type === 'session.execution.succeeded'
|
|
2338
|
+
|| payload?.type === 'session.execution.failed'
|
|
2339
|
+
|| payload?.type === 'session.execution.started'
|
|
2340
|
+
) {
|
|
2341
|
+
const body = (payload.properties && typeof payload.properties === 'object')
|
|
2342
|
+
? payload.properties
|
|
2343
|
+
: ((payload.data && typeof payload.data === 'object') ? payload.data : null);
|
|
2344
|
+
const sessionId = typeof body?.sessionID === 'string' ? body.sessionID.trim() : '';
|
|
2345
|
+
if (sessionId) shutdownRecoveryPending.delete(sessionId);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
875
2348
|
const aborted = extractAbortedAssistant(payload);
|
|
876
2349
|
if (aborted) {
|
|
877
2350
|
clearTimer(aborted.sessionId);
|
|
2351
|
+
shutdownRecoveryPending.delete(aborted.sessionId);
|
|
878
2352
|
if (!inflight.has(aborted.sessionId)) {
|
|
879
2353
|
inflight.add(aborted.sessionId);
|
|
880
2354
|
pauseAfterAbort(aborted.sessionId, directoryHint)
|
|
@@ -912,6 +2386,8 @@ export const createSessionGoalRuntime = ({
|
|
|
912
2386
|
const status = extractSessionStatus(payload);
|
|
913
2387
|
if (status) {
|
|
914
2388
|
if (status.type === 'idle') {
|
|
2389
|
+
// Authoritative idle after shutdown recovery reopens the continue gate.
|
|
2390
|
+
shutdownRecoveryPending.delete(status.sessionId);
|
|
915
2391
|
armTimer(status.sessionId, status.directory || directoryHint, idleQuietMs);
|
|
916
2392
|
} else {
|
|
917
2393
|
clearTimer(status.sessionId);
|
|
@@ -922,11 +2398,12 @@ export const createSessionGoalRuntime = ({
|
|
|
922
2398
|
// session.updated carries goal create/resume/pause without a status event.
|
|
923
2399
|
const update = extractSessionUpdate(payload);
|
|
924
2400
|
if (update && !update.parentID && update.goal) {
|
|
925
|
-
// Explicit UI/user pause (not question-driven):
|
|
926
|
-
//
|
|
927
|
-
// keep auto-delegate counting so the agent can continue
|
|
2401
|
+
// Explicit UI/user pause (not question-driven): revoke local execution
|
|
2402
|
+
// permit immediately so in-flight audits cannot dispatch, then notify.
|
|
2403
|
+
// Question pauses keep auto-delegate counting so the agent can continue
|
|
2404
|
+
// after auto-reply.
|
|
928
2405
|
if (update.goal.status === 'paused') {
|
|
929
|
-
|
|
2406
|
+
revokeLocalExecutionPermit(update.sessionId);
|
|
930
2407
|
if (!isQuestionDrivenPause(update.goal)) {
|
|
931
2408
|
notifyGoalPaused(update.sessionId, update.directory || directoryHint);
|
|
932
2409
|
}
|
|
@@ -936,6 +2413,8 @@ export const createSessionGoalRuntime = ({
|
|
|
936
2413
|
// Kickoff path: a goal set (or resumed — the UI stamps statusReason
|
|
937
2414
|
// 'resumed') while the session is already idle. Arm a short timer; the
|
|
938
2415
|
// tick's quiescence check keeps this safe if the session is actually busy.
|
|
2416
|
+
// Resume carries a new executionGeneration from the writer; old audits
|
|
2417
|
+
// fail the generation precondition on commit.
|
|
939
2418
|
if (
|
|
940
2419
|
update.goal.status === 'active'
|
|
941
2420
|
&& (update.goal.turnsUsed === 0 || update.goal.statusReason === 'resumed')
|
|
@@ -950,10 +2429,18 @@ export const createSessionGoalRuntime = ({
|
|
|
950
2429
|
|
|
951
2430
|
const stop = () => {
|
|
952
2431
|
stopped = true;
|
|
953
|
-
|
|
954
|
-
|
|
2432
|
+
const sessionIds = new Set([
|
|
2433
|
+
...timers.keys(),
|
|
2434
|
+
...auditControllers.keys(),
|
|
2435
|
+
...dispatchEpoch.keys(),
|
|
2436
|
+
...shutdownRecoveryPending.keys(),
|
|
2437
|
+
]);
|
|
2438
|
+
for (const sessionId of sessionIds) {
|
|
2439
|
+
revokeLocalExecutionPermit(sessionId);
|
|
955
2440
|
}
|
|
956
2441
|
timers.clear();
|
|
2442
|
+
auditControllers.clear();
|
|
2443
|
+
shutdownRecoveryPending.clear();
|
|
957
2444
|
};
|
|
958
2445
|
|
|
959
2446
|
return {
|
|
@@ -961,5 +2448,22 @@ export const createSessionGoalRuntime = ({
|
|
|
961
2448
|
stop,
|
|
962
2449
|
/** Used by question auto-delegate user takeover / disable paths. */
|
|
963
2450
|
pauseForQuestion: (sessionId, directory) => pauseForQuestion(sessionId, directory),
|
|
2451
|
+
/**
|
|
2452
|
+
* Public pause seam (user pause / tests): durable status=paused + generation
|
|
2453
|
+
* advance + local dispatch revoke. Does not abort upstream turns — callers
|
|
2454
|
+
* use the existing stop operation for that.
|
|
2455
|
+
*/
|
|
2456
|
+
pauseGoal: (sessionId, directory, statusReason = 'paused by user') => (
|
|
2457
|
+
pauseGoal(sessionId, directory, statusReason, { notify: true })
|
|
2458
|
+
),
|
|
2459
|
+
/**
|
|
2460
|
+
* Public tick seam for tests and controlled async reproduction.
|
|
2461
|
+
* Runs one goal evaluation cycle immediately (no idle timer).
|
|
2462
|
+
*/
|
|
2463
|
+
runTick: (sessionId, directory) => tick(sessionId, directory),
|
|
2464
|
+
/** Latest continuation dispatch identity for a session (test/reconcile). */
|
|
2465
|
+
getDispatchedContinuation: (sessionId) => dispatchedContinuations.get(sessionId) || null,
|
|
2466
|
+
/** Current local dispatch epoch (test seam). */
|
|
2467
|
+
getDispatchEpoch: (sessionId) => currentDispatchEpoch(sessionId),
|
|
964
2468
|
};
|
|
965
2469
|
};
|