@openchambery/web 2.0.0-beta.1 → 2.0.0-beta.4
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 +15 -11
- package/bin/cli-opencode.test.js +4 -4
- package/bin/cli.js +1 -1
- package/bin/lib/cli-startup.js +1 -1
- package/bin/lib/cli-startup.test.js +18 -4
- package/dist/assets/{AgentAvatar-CLWWNu_Q.js → AgentAvatar-Q1SmFRx2.js} +1 -1
- package/dist/assets/AssistantView-CwXkiCAd.js +3 -0
- package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-BW92schO.js} +1 -1
- package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-BZmwl-zE.js} +1 -1
- package/dist/assets/{Icon-DD-BuMfZ.js → Icon-AQPvxMiU.js} +1 -1
- package/dist/assets/{MarkdownRenderer-bc_p1H7l.js → MarkdownRenderer-Bv05iDZZ.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-C81xqYib.js} +3 -3
- package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +3 -0
- package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-BMgIu7zc.js} +1 -1
- package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-BOXGNHmC.js} +2 -2
- package/dist/assets/MobileSurface-xuc_z0_G.js +2 -0
- package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-B5At2Z95.js} +1 -1
- package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-bsPLgz4r.js} +1 -1
- package/dist/assets/OverlayScrollbar-4wp4migh.js +2 -0
- package/dist/assets/SettingsView-CJ6tcH5_.js +25 -0
- package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CMtX2bMl.js} +1 -1
- package/dist/assets/{TerminalView-Dh9MWCpS.js → TerminalView-Dvr-ysTS.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-uH1ZZDSS.js} +2 -2
- package/dist/assets/appThemeRegistry-DK7q2juD.js +1 -0
- package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-DphJleHr.js} +1 -1
- package/dist/assets/{button-D-rATN7w.js → button-CcNJUxUd.js} +1 -1
- package/dist/assets/collapsible-ZmZ6rLWs.js +3 -0
- package/dist/assets/context-menu-B2LXJgXL.js +1 -0
- package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-C6HrP0jq.js} +1 -1
- package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DdVBp45U.js} +1 -1
- package/dist/assets/{diagram-DQf7Y_uR.js → diagram-CxTs9hCQ.js} +1 -1
- package/dist/assets/{dialog-bfbvR4VH.js → dialog-KMVL9QKF.js} +1 -1
- package/dist/assets/es-CN3E_zXj.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-BvrNCroG.js → fileMentionTouchSelection-7RIwlpkB.js} +1 -1
- package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-CjdBRy8y.js} +1 -1
- package/dist/assets/fr-9zZvQZD2.js +13 -0
- package/dist/assets/gitApi-yuWg9BN4.js +10 -0
- package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-9vQBq3zp.js} +1 -1
- package/dist/assets/{input-8r6F_Fza.js → input-CrIvk3HZ.js} +1 -1
- package/dist/assets/insertionBoundaries-BiUCSkx-.js +1 -0
- package/dist/assets/ja-Dnjk_82N.js +15 -0
- package/dist/assets/ko-BxCEE99C.js +15 -0
- package/dist/assets/{main-CCE_4y5F.js → main-C9mIBo77.js} +2 -2
- package/dist/assets/main-DiL6w6KF.js +169 -0
- package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
- package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-BmXVY_gc.js} +1 -1
- package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
- package/dist/assets/mcpQueries-C7jknHV8.js +1 -0
- package/dist/assets/miniChat-CwtUaElU.js +2 -0
- package/dist/assets/mobile-DB-6--ws.js +2 -0
- package/dist/assets/multirun-TAsxFI8e.js +4 -0
- package/dist/assets/{persistence-Ce3AnpEr.js → persistence-56kq8Z4V.js} +2 -2
- package/dist/assets/pl-CNNsWnPM.js +15 -0
- package/dist/assets/platform-C13fMVEs.js +6 -0
- package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
- package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +2 -0
- package/dist/assets/renderMobileApp-CGMMqbsO.js +28 -0
- package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
- package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-BEANkvOd.js} +1 -1
- package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +147 -0
- package/dist/assets/sessionChangeSummary-qOLrP9fw.js +2 -0
- package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-34XFmP9C.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-DyEV0Heg.js} +1 -1
- package/dist/assets/src-ubCWXZhR.css +2 -0
- package/dist/assets/store-DLCiZaA6.js +15 -0
- package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-CXaBEoa_.js} +1 -1
- package/dist/assets/textarea-BBOlRI9O.js +1 -0
- package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CPI4fr1a.js} +1 -1
- package/dist/assets/uk-rwQrxeYM.js +15 -0
- package/dist/assets/{url-_z7evzdK.js → url-DRDQXjyX.js} +1 -1
- package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-CiDe42I5.js} +57 -43
- package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +1 -0
- package/dist/assets/useEffectiveDirectory-C_CouL5v.js +1 -0
- package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-B0oUZAiB.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DfPClaW-.js +414 -0
- package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
- package/dist/assets/useSessionAutoCleanup-DtrfbxAp.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-Bqn4AiBL.js → useWorkerHighlightedLines-CPNYz6Wf.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-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-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 +1 -1
- package/server/index.js +327 -62
- package/server/lib/assistants/DOCUMENTATION.md +14 -9
- package/server/lib/assistants/contracts.js +11 -1
- package/server/lib/assistants/projection-history.test.js +265 -18
- package/server/lib/assistants/routes.js +6 -0
- package/server/lib/assistants/service.js +417 -231
- package/server/lib/assistants/service.test.js +392 -148
- package/server/lib/conversations/DOCUMENTATION.md +1 -1
- package/server/lib/conversations/service.js +1 -0
- package/server/lib/conversations/service.test.js +16 -0
- package/server/lib/conversations/validation.js +11 -1
- package/server/lib/event-stream/DOCUMENTATION.md +3 -1
- package/server/lib/event-stream/directory-ws-bridge.js +13 -2
- package/server/lib/event-stream/global-hub.js +49 -4
- package/server/lib/event-stream/reasoning-projection.js +16 -1
- package/server/lib/event-stream/reasoning-projection.test.js +15 -0
- 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 +33 -9
- package/server/lib/llm/catalog.js +1 -1
- package/server/lib/llm/completions.js +5 -1
- package/server/lib/llm/completions.test.js +3 -3
- package/server/lib/llm/generate.js +237 -145
- package/server/lib/llm/generate.test.js +201 -46
- package/server/lib/llm/routes.js +2 -0
- package/server/lib/message-queue/runtime.js +15 -2
- package/server/lib/notifications/runtime.js +23 -5
- package/server/lib/notifications/runtime.test.js +11 -0
- package/server/lib/opencode/DOCUMENTATION.md +85 -17
- 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 +42 -99
- package/server/lib/opencode/config-entity-routes.test.js +107 -0
- package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
- package/server/lib/opencode/ensure-cli.js +1 -2
- package/server/lib/opencode/env-runtime.js +40 -26
- package/server/lib/opencode/env-runtime.test.js +10 -17
- package/server/lib/opencode/feature-routes-runtime.js +43 -4
- 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 +349 -39
- package/server/lib/opencode/lifecycle.test.js +322 -0
- 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/openchamber-routes.js +7 -1
- package/server/lib/opencode/opencode2-pin.js +4 -4
- package/server/lib/opencode/opencode2-pin.test.js +1 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +127 -4
- 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-routes.js +3 -18
- package/server/lib/opencode/plugin-routes.test.js +9 -9
- 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 +252 -46
- package/server/lib/opencode/proxy.test.js +235 -2
- package/server/lib/opencode/routes.js +296 -62
- package/server/lib/opencode/routes.test.js +278 -12
- 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 +33 -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 +20 -33
- package/server/lib/opencode/skill-routes.test.js +35 -2
- 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/v2-client.js +14 -3
- 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/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 +8 -0
- package/server/lib/scheduled-tasks/runtime.js +52 -4
- package/server/lib/scheduled-tasks/runtime.test.js +80 -1
- package/server/lib/session-goal/DOCUMENTATION.md +113 -13
- package/server/lib/session-goal/routes.js +9 -0
- package/server/lib/session-goal/runtime.js +1579 -117
- package/server/lib/session-goal/runtime.test.js +1453 -84
- 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 +161 -34
- package/server/lib/session-index/sync-runtime.test.js +155 -1
- package/server/lib/session-metadata/DOCUMENTATION.md +61 -16
- package/server/lib/session-metadata/routes.js +108 -9
- package/server/lib/session-metadata/routes.test.js +47 -2
- 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 +212 -72
- package/server/lib/session-metadata/session-metadata-store.test.js +205 -10
- 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 +9 -8
- package/server/lib/session-turn-pages/routes.js +17 -102
- package/server/lib/session-turn-pages/routes.test.js +281 -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 -147
- package/server/lib/small-model/catalog.js +31 -51
- package/server/lib/small-model/catalog.test.js +65 -10
- 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 +124 -240
- 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 +2 -0
- package/dist/assets/AssistantView-BTCkm8yk.js +0 -3
- package/dist/assets/MarkstreamRendererImpl-CnkodGJW.js +0 -3
- package/dist/assets/MobileSurface-BXLeiZ4d.js +0 -2
- package/dist/assets/OverlayScrollbar-pMwxaCca.js +0 -2
- package/dist/assets/SettingsView-B5I0KDTZ.js +0 -25
- package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
- package/dist/assets/collapsible-BXRd5_Hr.js +0 -1
- package/dist/assets/context-menu-jGtH6fR9.js +0 -1
- package/dist/assets/es-c52LTHHe.js +0 -15
- package/dist/assets/fr-PisFbUE8.js +0 -13
- package/dist/assets/gitApi-ButwzQ0N.js +0 -9
- package/dist/assets/insertionBoundaries-DGs7S5zv.js +0 -1
- package/dist/assets/ja-C8Ikun3Z.js +0 -15
- package/dist/assets/ko-ClFys5o2.js +0 -15
- package/dist/assets/main-Dn9rpsgl.js +0 -169
- package/dist/assets/markdown-worker-CptoICmq.js +0 -2
- package/dist/assets/miniChat-BpjxVqgg.js +0 -2
- package/dist/assets/mobile-nMJCaNq1.js +0 -2
- package/dist/assets/multirun-BvSG94mW.js +0 -4
- package/dist/assets/pl-CnaKWN5F.js +0 -15
- package/dist/assets/platform-CWTIp02q.js +0 -4
- package/dist/assets/projectMeta-DOe49dM_.js +0 -3
- package/dist/assets/pt-BR-s8OpfRI6.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +0 -2
- package/dist/assets/renderMobileApp-y-S8y8v_.js +0 -28
- package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
- package/dist/assets/runtimeEndpointReset-B6BxhvR0.js +0 -147
- package/dist/assets/sessionChangeSummary-VVBlEXuh.js +0 -2
- package/dist/assets/src-BzX5UI-c.css +0 -2
- package/dist/assets/store-DJczmczv.js +0 -15
- package/dist/assets/textarea-3XR8sUfy.js +0 -1
- package/dist/assets/uk-CaX0iqkB.js +0 -15
- package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +0 -1
- package/dist/assets/useEffectiveDirectory-CN6JoCFw.js +0 -1
- package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +0 -414
- package/dist/assets/useSessionAutoCleanup-BLjymaFt.js +0 -2
- package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
- package/dist/assets/zh-CN-20MYEjha.js +0 -15
- package/dist/assets/zh-TW-DiN04qTw.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 -183
- package/server/lib/small-model/opencode-session.test.js +0 -147
|
@@ -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,
|
|
@@ -324,16 +593,60 @@ export const createSessionGoalRuntime = ({
|
|
|
324
593
|
* state is read/written only through the Host store (OpenCode 2.x has no
|
|
325
594
|
* session-metadata PATCH). When either is missing, keep the legacy OpenCode
|
|
326
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.
|
|
327
600
|
*/
|
|
328
601
|
persistSessionGoal = null,
|
|
329
602
|
readSessionMetadata = null,
|
|
603
|
+
mutateSessionMetadata = null,
|
|
330
604
|
}) => {
|
|
331
605
|
const timers = new Map();
|
|
332
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();
|
|
333
625
|
let stopped = false;
|
|
334
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
|
+
|
|
335
644
|
const isMetadataStoreWired = () => (
|
|
336
|
-
typeof
|
|
645
|
+
typeof readSessionMetadata === 'function'
|
|
646
|
+
&& (
|
|
647
|
+
typeof mutateSessionMetadata === 'function'
|
|
648
|
+
|| typeof persistSessionGoal === 'function'
|
|
649
|
+
)
|
|
337
650
|
);
|
|
338
651
|
|
|
339
652
|
const clearTimer = (sessionId) => {
|
|
@@ -344,7 +657,39 @@ export const createSessionGoalRuntime = ({
|
|
|
344
657
|
}
|
|
345
658
|
};
|
|
346
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
|
+
|
|
347
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
|
+
}
|
|
348
693
|
const base = buildOpenCodeUrl(fetchPath, '');
|
|
349
694
|
const params = new URLSearchParams(query || {});
|
|
350
695
|
if (directory) params.set('directory', directory);
|
|
@@ -366,12 +711,110 @@ export const createSessionGoalRuntime = ({
|
|
|
366
711
|
return response.json().catch(() => null);
|
|
367
712
|
};
|
|
368
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
|
+
*/
|
|
369
720
|
const fetchRecentMessages = async (sessionId, directory) => {
|
|
370
|
-
|
|
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`, {
|
|
371
735
|
directory,
|
|
372
736
|
query: { limit: String(MESSAGE_FETCH_LIMIT) },
|
|
373
737
|
}).catch(() => null);
|
|
374
|
-
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' };
|
|
375
818
|
};
|
|
376
819
|
|
|
377
820
|
// Live session status is authoritative for whether the session is actually
|
|
@@ -384,6 +827,17 @@ export const createSessionGoalRuntime = ({
|
|
|
384
827
|
// 'busy' | 'retry' | null (null when the status cannot be read — callers
|
|
385
828
|
// fall back to the message-tail heuristic).
|
|
386
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
|
+
}
|
|
387
841
|
const statusMap = await openCodeFetch('/session/status', { directory }).catch(() => null);
|
|
388
842
|
const status = statusMap?.[sessionId];
|
|
389
843
|
const type = typeof status?.type === 'string' ? status.type.trim() : '';
|
|
@@ -408,19 +862,91 @@ export const createSessionGoalRuntime = ({
|
|
|
408
862
|
return parseGoalMetadata(session);
|
|
409
863
|
};
|
|
410
864
|
|
|
411
|
-
|
|
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
|
+
|
|
412
938
|
if (isMetadataStoreWired()) {
|
|
413
939
|
const currentGoal = await readGoal(sessionId, directory);
|
|
414
|
-
|
|
415
|
-
|
|
940
|
+
const nextGoal = buildNextGoal(currentGoal);
|
|
941
|
+
if (!nextGoal) return null;
|
|
416
942
|
await persistSessionGoal(sessionId, directory, nextGoal);
|
|
417
943
|
return nextGoal;
|
|
418
944
|
}
|
|
419
945
|
|
|
420
946
|
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
|
|
421
947
|
const currentGoal = parseGoalMetadata(session);
|
|
422
|
-
|
|
423
|
-
|
|
948
|
+
const nextGoal = buildNextGoal(currentGoal);
|
|
949
|
+
if (!nextGoal) return null;
|
|
424
950
|
const currentMetadata = session?.metadata && typeof session.metadata === 'object' ? session.metadata : {};
|
|
425
951
|
const currentNamespace = currentMetadata.openchamber && typeof currentMetadata.openchamber === 'object'
|
|
426
952
|
? currentMetadata.openchamber
|
|
@@ -438,18 +964,30 @@ export const createSessionGoalRuntime = ({
|
|
|
438
964
|
return nextGoal;
|
|
439
965
|
};
|
|
440
966
|
|
|
441
|
-
const settleGoal = async ({ sessionId, directory, goal, status, statusReason, note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID }) => {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
+
});
|
|
453
991
|
if (!written) return;
|
|
454
992
|
console.log(`[session-goal] ${sessionId} settled as ${status}${statusReason ? ` (${statusReason})` : ''}`);
|
|
455
993
|
if (typeof emitGoalNotification === 'function') {
|
|
@@ -469,13 +1007,15 @@ export const createSessionGoalRuntime = ({
|
|
|
469
1007
|
}
|
|
470
1008
|
};
|
|
471
1009
|
|
|
472
|
-
const runAudit = async ({ goal, assistantText, directory, lastAssistantInfo }) => {
|
|
1010
|
+
const runAudit = async ({ sessionId, goal, assistantText, directory, lastAssistantInfo, signal }) => {
|
|
1011
|
+
if (signal?.aborted) return null;
|
|
473
1012
|
let service;
|
|
474
1013
|
try {
|
|
475
1014
|
service = await getSmallModelService();
|
|
476
1015
|
} catch {
|
|
477
1016
|
return null;
|
|
478
1017
|
}
|
|
1018
|
+
if (signal?.aborted) return null;
|
|
479
1019
|
const preferredProviderID = typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined;
|
|
480
1020
|
const preferredModelID = typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined;
|
|
481
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()}"`;
|
|
@@ -491,65 +1031,499 @@ export const createSessionGoalRuntime = ({
|
|
|
491
1031
|
}
|
|
492
1032
|
return { verdict, note };
|
|
493
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
|
+
};
|
|
494
1060
|
try {
|
|
495
1061
|
// Prefer staying on the session provider. If that provider has no
|
|
496
1062
|
// suitable small model (404), fall back to any authenticated small
|
|
497
1063
|
// model so simple goals can still settle instead of stranding as
|
|
498
1064
|
// "evaluating" then "blocked".
|
|
499
1065
|
try {
|
|
500
|
-
const generated = await service.generateSmallModelText({
|
|
1066
|
+
const generated = await withAbort(() => service.generateSmallModelText({
|
|
501
1067
|
restrictToPreferredProvider: true,
|
|
502
1068
|
prompt,
|
|
503
1069
|
system,
|
|
504
1070
|
directory,
|
|
505
1071
|
preferredProviderID,
|
|
506
1072
|
preferredModelID,
|
|
507
|
-
});
|
|
1073
|
+
}));
|
|
1074
|
+
if (signal?.aborted) return null;
|
|
508
1075
|
return parseAudit(generated);
|
|
509
1076
|
} catch (restrictedError) {
|
|
1077
|
+
if (restrictedError?.name === 'AbortError' || signal?.aborted) return null;
|
|
510
1078
|
if (Number(restrictedError?.statusCode) !== 404) throw restrictedError;
|
|
511
|
-
const generated = await service.generateSmallModelText({
|
|
1079
|
+
const generated = await withAbort(() => service.generateSmallModelText({
|
|
512
1080
|
restrictToPreferredProvider: false,
|
|
513
1081
|
prompt,
|
|
514
1082
|
system,
|
|
515
1083
|
directory,
|
|
516
1084
|
preferredProviderID,
|
|
517
1085
|
preferredModelID,
|
|
518
|
-
});
|
|
1086
|
+
}));
|
|
1087
|
+
if (signal?.aborted) return null;
|
|
519
1088
|
return parseAudit(generated);
|
|
520
1089
|
}
|
|
521
1090
|
} catch (error) {
|
|
1091
|
+
if (error?.name === 'AbortError' || signal?.aborted) return null;
|
|
522
1092
|
// No authenticated small model (404) or a transient failure — the loop
|
|
523
1093
|
// still terminates via budget and the turn cap.
|
|
524
1094
|
if (Number(error?.statusCode) !== 404) {
|
|
525
1095
|
console.warn('[session-goal] audit failed:', error?.message || error);
|
|
526
1096
|
}
|
|
527
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;
|
|
528
1141
|
}
|
|
529
1142
|
};
|
|
530
1143
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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 || '';
|
|
534
1171
|
if (!providerID || !modelID) {
|
|
535
1172
|
throw new Error('cannot continue goal: last assistant message has no provider/model');
|
|
536
1173
|
}
|
|
537
|
-
const
|
|
538
|
-
?
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
+
},
|
|
551
1196
|
},
|
|
552
|
-
}
|
|
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';
|
|
553
1527
|
};
|
|
554
1528
|
|
|
555
1529
|
const tick = async (sessionId, directory) => {
|
|
@@ -561,6 +1535,10 @@ export const createSessionGoalRuntime = ({
|
|
|
561
1535
|
return;
|
|
562
1536
|
}
|
|
563
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
|
+
|
|
564
1542
|
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
|
|
565
1543
|
.catch((error) => {
|
|
566
1544
|
console.warn(`[session-goal] session fetch failed: ${error?.message || error}`);
|
|
@@ -570,10 +1548,131 @@ export const createSessionGoalRuntime = ({
|
|
|
570
1548
|
// Sub-agent/task sessions never carry user goals — skip them.
|
|
571
1549
|
if (typeof session.parentID === 'string' && session.parentID) return;
|
|
572
1550
|
|
|
573
|
-
|
|
1551
|
+
let goal = isMetadataStoreWired()
|
|
574
1552
|
? await readGoal(sessionId, directory)
|
|
575
1553
|
: parseGoalMetadata(session);
|
|
576
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
|
+
});
|
|
577
1676
|
|
|
578
1677
|
// File-backed objectives: the metadata carries only a flag; the objective
|
|
579
1678
|
// TEXT lives under the OpenChamber data dir keyed by session id and is
|
|
@@ -598,7 +1697,7 @@ export const createSessionGoalRuntime = ({
|
|
|
598
1697
|
|
|
599
1698
|
let lastAssistant = null;
|
|
600
1699
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
601
|
-
if (messages[i]?.info
|
|
1700
|
+
if (messageRole(messages[i]?.info) === 'assistant') {
|
|
602
1701
|
lastAssistant = messages[i];
|
|
603
1702
|
break;
|
|
604
1703
|
}
|
|
@@ -613,7 +1712,7 @@ export const createSessionGoalRuntime = ({
|
|
|
613
1712
|
let executionInfo = null;
|
|
614
1713
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
615
1714
|
const info = messages[i]?.info;
|
|
616
|
-
if (info
|
|
1715
|
+
if (messageRole(info) === 'assistant' && !isSummaryMessage(info)) {
|
|
617
1716
|
executionInfo = info;
|
|
618
1717
|
break;
|
|
619
1718
|
}
|
|
@@ -631,7 +1730,7 @@ export const createSessionGoalRuntime = ({
|
|
|
631
1730
|
// goal on "evaluating". Corroborate against the live session status before
|
|
632
1731
|
// bailing — if the session is really idle, treat the orphan as a dead tail
|
|
633
1732
|
// and let the loop resume; only a genuinely busy session bails here.
|
|
634
|
-
if (lastMessageInfo
|
|
1733
|
+
if (messageRole(lastMessageInfo) === 'user') return;
|
|
635
1734
|
if (lastAssistantInfo && !(lastAssistantInfo.time?.completed > 0) && !lastAssistantInfo.error) {
|
|
636
1735
|
const live = await fetchSessionLiveStatus(sessionId, directory);
|
|
637
1736
|
// live === 'idle' → restart orphan: resume past it. live === null →
|
|
@@ -669,14 +1768,21 @@ export const createSessionGoalRuntime = ({
|
|
|
669
1768
|
// aborted reply is not evidence of anything).
|
|
670
1769
|
const abortedTail = lastAssistantInfo.error?.name === 'MessageAbortedError';
|
|
671
1770
|
if (abortedTail && goal.statusReason !== 'resumed') {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
+
});
|
|
680
1786
|
console.log(`[session-goal] ${sessionId} paused after user abort`);
|
|
681
1787
|
return;
|
|
682
1788
|
}
|
|
@@ -687,7 +1793,7 @@ export const createSessionGoalRuntime = ({
|
|
|
687
1793
|
? lastAssistantInfo.error.name
|
|
688
1794
|
: 'assistant turn failed';
|
|
689
1795
|
await settleGoal({
|
|
690
|
-
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,
|
|
691
1797
|
});
|
|
692
1798
|
return;
|
|
693
1799
|
}
|
|
@@ -695,7 +1801,7 @@ export const createSessionGoalRuntime = ({
|
|
|
695
1801
|
// Token budget crossed → budgetLimited.
|
|
696
1802
|
if (typeof goal.tokenBudget === 'number' && tokensUsed >= goal.tokenBudget) {
|
|
697
1803
|
await settleGoal({
|
|
698
|
-
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,
|
|
699
1805
|
});
|
|
700
1806
|
return;
|
|
701
1807
|
}
|
|
@@ -703,7 +1809,7 @@ export const createSessionGoalRuntime = ({
|
|
|
703
1809
|
// Auto-continuation safety cap → blocked.
|
|
704
1810
|
if (goal.turnsUsed >= maxAutoTurns) {
|
|
705
1811
|
await settleGoal({
|
|
706
|
-
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,
|
|
707
1813
|
});
|
|
708
1814
|
return;
|
|
709
1815
|
}
|
|
@@ -718,16 +1824,42 @@ export const createSessionGoalRuntime = ({
|
|
|
718
1824
|
let audit = null;
|
|
719
1825
|
let blockedStreak = 0;
|
|
720
1826
|
let auditFailStreak = goal.auditFailStreak;
|
|
721
|
-
if (lastAssistantInfo
|
|
1827
|
+
if (isSummaryMessage(lastAssistantInfo) || abortedTail) {
|
|
722
1828
|
blockedStreak = goal.blockedStreak;
|
|
723
1829
|
} else {
|
|
724
|
-
|
|
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
|
+
}
|
|
725
1853
|
|
|
726
1854
|
// Audit unavailable: keep going under the hard turn/budget caps rather
|
|
727
1855
|
// than flipping to "blocked" after a couple of 404s — that left simple
|
|
728
1856
|
// completed goals stranded as evaluating→blocked when no small model
|
|
729
1857
|
// was available on the session provider. Resume still re-audits.
|
|
730
1858
|
if (!audit) {
|
|
1859
|
+
if (auditController.signal.aborted) {
|
|
1860
|
+
console.log(`[session-goal] ${sessionId} audit cancelled`);
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
731
1863
|
auditFailStreak += 1;
|
|
732
1864
|
console.warn(`[session-goal] ${sessionId} audit unavailable, continuing under hard caps (${auditFailStreak} consecutive)`);
|
|
733
1865
|
} else {
|
|
@@ -736,7 +1868,7 @@ export const createSessionGoalRuntime = ({
|
|
|
736
1868
|
|
|
737
1869
|
if (audit?.verdict === 'complete') {
|
|
738
1870
|
await settleGoal({
|
|
739
|
-
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,
|
|
740
1872
|
});
|
|
741
1873
|
return;
|
|
742
1874
|
}
|
|
@@ -745,28 +1877,144 @@ export const createSessionGoalRuntime = ({
|
|
|
745
1877
|
blockedStreak = goal.blockedStreak + 1;
|
|
746
1878
|
if (blockedStreak >= BLOCKED_STREAK_LIMIT) {
|
|
747
1879
|
await settleGoal({
|
|
748
|
-
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,
|
|
749
1881
|
});
|
|
750
1882
|
return;
|
|
751
1883
|
}
|
|
752
1884
|
}
|
|
753
1885
|
}
|
|
754
1886
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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) => ({
|
|
759
2005
|
tokensUsed,
|
|
760
2006
|
tokensBaseline,
|
|
761
2007
|
tokensCommitted,
|
|
762
2008
|
lastAccountedMessageID,
|
|
763
|
-
turnsUsed: current.turnsUsed + 1,
|
|
764
2009
|
blockedStreak,
|
|
765
2010
|
auditFailStreak,
|
|
766
2011
|
statusReason: '',
|
|
767
2012
|
...(audit?.note ? { note: audit.note } : {}),
|
|
2013
|
+
pendingContinuation: pending,
|
|
2014
|
+
// Explicit: do not bump turnsUsed until accept/reconcile.
|
|
2015
|
+
turnsUsed: current.turnsUsed,
|
|
768
2016
|
}));
|
|
769
|
-
if (!
|
|
2017
|
+
if (!reserved) {
|
|
770
2018
|
console.log('[session-goal] goal changed during tick, dropping continuation');
|
|
771
2019
|
return;
|
|
772
2020
|
}
|
|
@@ -776,18 +2024,132 @@ export const createSessionGoalRuntime = ({
|
|
|
776
2024
|
const latest = await fetchRecentMessages(sessionId, directory);
|
|
777
2025
|
const latestLastInfo = latest && latest.length > 0 ? latest[latest.length - 1]?.info : null;
|
|
778
2026
|
if (!latestLastInfo || latestLastInfo.id !== lastMessageInfo?.id) {
|
|
779
|
-
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
|
+
});
|
|
780
2034
|
return;
|
|
781
2035
|
}
|
|
782
2036
|
|
|
783
|
-
// Re-check question blockers immediately before dispatch
|
|
2037
|
+
// Re-check question blockers immediately before dispatch.
|
|
784
2038
|
if (typeof isQuestionBlockingGoal === 'function' && isQuestionBlockingGoal(sessionId) === true) {
|
|
785
|
-
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
|
+
});
|
|
786
2058
|
return;
|
|
787
2059
|
}
|
|
788
2060
|
|
|
789
|
-
console.log(`[session-goal] continuing ${sessionId} (turn ${
|
|
790
|
-
|
|
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
|
+
}
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
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
|
+
}
|
|
791
2153
|
};
|
|
792
2154
|
|
|
793
2155
|
const armTimer = (sessionId, directory, quietMs) => {
|
|
@@ -868,25 +2230,70 @@ export const createSessionGoalRuntime = ({
|
|
|
868
2230
|
// UI races: metadata may land as paused before the abort event. When the goal
|
|
869
2231
|
// is already paused for a non-question reason, still notify so question
|
|
870
2232
|
// auto-delegate timers are cancelled (abort path used to no-op on non-active).
|
|
871
|
-
|
|
872
|
-
|
|
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
|
+
|
|
873
2250
|
const goal = isMetadataStoreWired()
|
|
874
2251
|
? await readGoal(owner.sessionId, owner.directory)
|
|
875
|
-
: parseGoalMetadata(owner.session
|
|
876
|
-
|
|
2252
|
+
: parseGoalMetadata(owner.session ?? await openCodeFetch(
|
|
2253
|
+
`/session/${encodeURIComponent(owner.sessionId)}`,
|
|
2254
|
+
{ directory: owner.directory },
|
|
2255
|
+
).catch(() => null));
|
|
2256
|
+
|
|
2257
|
+
if (!goal) return null;
|
|
877
2258
|
if (goal.status === 'paused') {
|
|
878
|
-
if (!isQuestionDrivenPause(goal)) {
|
|
2259
|
+
if (notify && !isQuestionDrivenPause(goal)) {
|
|
879
2260
|
notifyGoalPaused(owner.sessionId, owner.directory);
|
|
880
2261
|
}
|
|
881
|
-
return;
|
|
2262
|
+
return goal;
|
|
882
2263
|
}
|
|
883
|
-
if (goal.status !== 'active') return;
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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 });
|
|
890
2297
|
};
|
|
891
2298
|
|
|
892
2299
|
// Pause the active goal when the agent asks a question — without aborting
|
|
@@ -898,25 +2305,50 @@ export const createSessionGoalRuntime = ({
|
|
|
898
2305
|
// record — owner.session may be null only if the session itself is missing.
|
|
899
2306
|
if (!isMetadataStoreWired() && !owner.session) return;
|
|
900
2307
|
if (isMetadataStoreWired() && !owner.sessionId) return;
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
if (!goal || goal.status !== 'active') return;
|
|
906
|
-
await writeGoal(owner.sessionId, owner.directory, goal.id, () => ({
|
|
907
|
-
status: 'paused',
|
|
908
|
-
statusReason: 'paused for question',
|
|
909
|
-
}));
|
|
910
|
-
console.log(`[session-goal] ${owner.sessionId} paused for question`);
|
|
2308
|
+
await pauseGoal(owner.sessionId, owner.directory, 'paused for question', {
|
|
2309
|
+
notify: false,
|
|
2310
|
+
resolveOwner: false,
|
|
2311
|
+
});
|
|
911
2312
|
// Takeover path already pauses questions; still notify for abort-style symmetry.
|
|
912
2313
|
};
|
|
913
2314
|
|
|
914
2315
|
const processPayload = (payload, directoryHint = '') => {
|
|
915
2316
|
if (stopped) return;
|
|
916
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
|
+
|
|
917
2348
|
const aborted = extractAbortedAssistant(payload);
|
|
918
2349
|
if (aborted) {
|
|
919
2350
|
clearTimer(aborted.sessionId);
|
|
2351
|
+
shutdownRecoveryPending.delete(aborted.sessionId);
|
|
920
2352
|
if (!inflight.has(aborted.sessionId)) {
|
|
921
2353
|
inflight.add(aborted.sessionId);
|
|
922
2354
|
pauseAfterAbort(aborted.sessionId, directoryHint)
|
|
@@ -954,6 +2386,8 @@ export const createSessionGoalRuntime = ({
|
|
|
954
2386
|
const status = extractSessionStatus(payload);
|
|
955
2387
|
if (status) {
|
|
956
2388
|
if (status.type === 'idle') {
|
|
2389
|
+
// Authoritative idle after shutdown recovery reopens the continue gate.
|
|
2390
|
+
shutdownRecoveryPending.delete(status.sessionId);
|
|
957
2391
|
armTimer(status.sessionId, status.directory || directoryHint, idleQuietMs);
|
|
958
2392
|
} else {
|
|
959
2393
|
clearTimer(status.sessionId);
|
|
@@ -964,11 +2398,12 @@ export const createSessionGoalRuntime = ({
|
|
|
964
2398
|
// session.updated carries goal create/resume/pause without a status event.
|
|
965
2399
|
const update = extractSessionUpdate(payload);
|
|
966
2400
|
if (update && !update.parentID && update.goal) {
|
|
967
|
-
// Explicit UI/user pause (not question-driven):
|
|
968
|
-
//
|
|
969
|
-
// 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.
|
|
970
2405
|
if (update.goal.status === 'paused') {
|
|
971
|
-
|
|
2406
|
+
revokeLocalExecutionPermit(update.sessionId);
|
|
972
2407
|
if (!isQuestionDrivenPause(update.goal)) {
|
|
973
2408
|
notifyGoalPaused(update.sessionId, update.directory || directoryHint);
|
|
974
2409
|
}
|
|
@@ -978,6 +2413,8 @@ export const createSessionGoalRuntime = ({
|
|
|
978
2413
|
// Kickoff path: a goal set (or resumed — the UI stamps statusReason
|
|
979
2414
|
// 'resumed') while the session is already idle. Arm a short timer; the
|
|
980
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.
|
|
981
2418
|
if (
|
|
982
2419
|
update.goal.status === 'active'
|
|
983
2420
|
&& (update.goal.turnsUsed === 0 || update.goal.statusReason === 'resumed')
|
|
@@ -992,10 +2429,18 @@ export const createSessionGoalRuntime = ({
|
|
|
992
2429
|
|
|
993
2430
|
const stop = () => {
|
|
994
2431
|
stopped = true;
|
|
995
|
-
|
|
996
|
-
|
|
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);
|
|
997
2440
|
}
|
|
998
2441
|
timers.clear();
|
|
2442
|
+
auditControllers.clear();
|
|
2443
|
+
shutdownRecoveryPending.clear();
|
|
999
2444
|
};
|
|
1000
2445
|
|
|
1001
2446
|
return {
|
|
@@ -1003,5 +2448,22 @@ export const createSessionGoalRuntime = ({
|
|
|
1003
2448
|
stop,
|
|
1004
2449
|
/** Used by question auto-delegate user takeover / disable paths. */
|
|
1005
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),
|
|
1006
2468
|
};
|
|
1007
2469
|
};
|