@gajae-code/coding-agent 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +215 -8
- package/README.md +2 -0
- package/bin/gajaessi.js +4 -0
- package/dist/types/async/job-manager.d.ts +55 -2
- package/dist/types/async/support.d.ts +14 -0
- package/dist/types/capability/mcp.d.ts +6 -0
- package/dist/types/capability/types.d.ts +6 -0
- package/dist/types/cli/accounts-cli.d.ts +19 -0
- package/dist/types/cli/args.d.ts +3 -0
- package/dist/types/cli/config-cli.d.ts +6 -0
- package/dist/types/cli/crash-cli.d.ts +10 -0
- package/dist/types/cli/customize-doctor.d.ts +145 -0
- package/dist/types/cli/daemon-cli.d.ts +3 -1
- package/dist/types/cli/mcp-cli.d.ts +11 -0
- package/dist/types/cli/notify-cli.d.ts +1 -1
- package/dist/types/cli/root-flags.d.ts +12 -0
- package/dist/types/cli/setup-cli.d.ts +3 -1
- package/dist/types/cli/skills-cli.d.ts +2 -1
- package/dist/types/cli/update-cli.d.ts +19 -1
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/commands/accounts.d.ts +40 -0
- package/dist/types/commands/crash.d.ts +24 -0
- package/dist/types/commands/customize.d.ts +22 -0
- package/dist/types/commands/daemon.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +6 -0
- package/dist/types/commands/setup.d.ts +6 -0
- package/dist/types/commands/skills.d.ts +6 -1
- package/dist/types/commands/update.d.ts +7 -0
- package/dist/types/config/atomic-yaml-patch-worker.d.ts +25 -0
- package/dist/types/config/atomic-yaml-patch.d.ts +62 -2
- package/dist/types/config/config-schema-version.d.ts +10 -0
- package/dist/types/config/file-lock.d.ts +18 -0
- package/dist/types/config/model-profile-activation.d.ts +3 -1
- package/dist/types/config/model-registry.d.ts +10 -1
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +73 -9
- package/dist/types/config/settings.d.ts +63 -0
- package/dist/types/config/skill-settings-defaults.d.ts +26 -0
- package/dist/types/config/task-delegation.d.ts +39 -0
- package/dist/types/config/update-channel.d.ts +10 -0
- package/dist/types/coordinator-mcp/server.d.ts +5 -1
- package/dist/types/crash/index-store.d.ts +87 -0
- package/dist/types/crash/nudge.d.ts +50 -0
- package/dist/types/crash/record-loader.d.ts +17 -0
- package/dist/types/crash/report.d.ts +134 -0
- package/dist/types/crash/sanitize.d.ts +25 -0
- package/dist/types/cursor.d.ts +13 -2
- package/dist/types/customization/import.d.ts +37 -0
- package/dist/types/customization/index.d.ts +4 -0
- package/dist/types/customization/inventory.d.ts +25 -0
- package/dist/types/customization/mutations.d.ts +32 -0
- package/dist/types/customization/types.d.ts +115 -0
- package/dist/types/daemon/control-types.d.ts +8 -1
- package/dist/types/daemon/operator-contract.d.ts +4 -4
- package/dist/types/discovery/claude.d.ts +39 -1
- package/dist/types/discovery/codex.d.ts +39 -1
- package/dist/types/discovery/index.d.ts +2 -0
- package/dist/types/discovery/mcp-compat.d.ts +30 -0
- package/dist/types/edit/renderer.d.ts +23 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +21 -4
- package/dist/types/extensibility/gjc-plugins/observability.d.ts +6 -1
- package/dist/types/extensibility/gjc-plugins/registry.d.ts +8 -1
- package/dist/types/extensibility/hooks/loader.d.ts +19 -5
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +22 -1
- package/dist/types/extensibility/skill-management.d.ts +107 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +3 -1
- package/dist/types/gjc-runtime/launch-worktree.d.ts +8 -0
- package/dist/types/gjc-runtime/linux-proc.d.ts +3 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +10 -6
- package/dist/types/gjc-runtime/session-state-lock.d.ts +104 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +120 -2
- package/dist/types/gjc-runtime/state-writer.d.ts +10 -1
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -2
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -6
- package/dist/types/gjc-runtime/workflow-settings.d.ts +109 -0
- package/dist/types/hooks/events.d.ts +60 -0
- package/dist/types/hooks/normalize.d.ts +83 -0
- package/dist/types/hooks/skill-state.d.ts +9 -2
- package/dist/types/main.d.ts +2 -1
- package/dist/types/modes/acp/abort-scope.d.ts +13 -0
- package/dist/types/modes/acp/acp-agent.d.ts +7 -4
- package/dist/types/modes/components/customization/customization-dashboard.d.ts +44 -0
- package/dist/types/modes/components/customization/import-wizard.d.ts +21 -0
- package/dist/types/modes/components/customization/index.d.ts +1 -0
- package/dist/types/modes/components/dynamic-theme-text.d.ts +9 -0
- package/dist/types/modes/components/gajae-pet-widget.d.ts +6 -28
- package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
- package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
- package/dist/types/modes/components/oauth-selector.d.ts +10 -4
- package/dist/types/modes/components/pet-capability.d.ts +7 -21
- package/dist/types/modes/components/settings-selector.d.ts +5 -0
- package/dist/types/modes/components/status-line/gh.d.ts +6 -2
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
- package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/theme/defaults/index.d.ts +118 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
- package/dist/types/runtime-mcp/config.d.ts +9 -0
- package/dist/types/runtime-mcp/manager.d.ts +26 -1
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
- package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
- package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
- package/dist/types/runtime-mcp/protocol.d.ts +278 -0
- package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
- package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
- package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
- package/dist/types/runtime-mcp/types.d.ts +102 -10
- package/dist/types/sdk/acp/adapter.d.ts +26 -5
- package/dist/types/sdk/broker/broker.d.ts +3 -55
- package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
- package/dist/types/sdk/broker/session-index.d.ts +14 -5
- package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
- package/dist/types/sdk/bus/config.d.ts +11 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
- package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
- package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
- package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
- package/dist/types/sdk/bus/index.d.ts +85 -99
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
- package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
- package/dist/types/sdk/bus/notification-service.d.ts +52 -0
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
- package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
- package/dist/types/sdk/bus/session-control.d.ts +25 -1
- package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
- package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
- package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
- package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
- package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
- package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
- package/dist/types/sdk/cli/rows.d.ts +2 -11
- package/dist/types/sdk/cli/session-cli.d.ts +15 -25
- package/dist/types/sdk/client/client.d.ts +97 -1
- package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +18 -5
- package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
- package/dist/types/sdk/host/host.d.ts +15 -1
- package/dist/types/sdk/host/query/handlers.d.ts +5 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/host/session-runtime.d.ts +127 -7
- package/dist/types/sdk/host/types.d.ts +1 -1
- package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
- package/dist/types/sdk/index.d.ts +2 -1
- package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
- package/dist/types/sdk/lifecycle/client.d.ts +59 -0
- package/dist/types/sdk/lifecycle/index.d.ts +3 -0
- package/dist/types/sdk/lifecycle/service.d.ts +232 -0
- package/dist/types/sdk/mcp/server.d.ts +9 -6
- package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
- package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
- package/dist/types/sdk/prompt-status.d.ts +10 -3
- package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
- package/dist/types/sdk/receipt-state.d.ts +17 -0
- package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
- package/dist/types/sdk/router/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +152 -0
- package/dist/types/sdk/session-list.d.ts +22 -0
- package/dist/types/sdk/session-reconnect.d.ts +43 -1
- package/dist/types/sdk/session.d.ts +34 -1
- package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
- package/dist/types/session/account-inventory.d.ts +86 -0
- package/dist/types/session/agent-session.d.ts +145 -6
- package/dist/types/session/agent-storage.d.ts +10 -0
- package/dist/types/session/coordinator-tool-label.d.ts +28 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
- package/dist/types/session/messages.d.ts +1 -1
- package/dist/types/session/secure-token-file.d.ts +3 -0
- package/dist/types/session/session-manager.d.ts +47 -1
- package/dist/types/session/startup-auth-config.d.ts +44 -0
- package/dist/types/session/terminal-abort.d.ts +378 -0
- package/dist/types/session/yield-queue.d.ts +7 -0
- package/dist/types/session-import/claude.d.ts +26 -0
- package/dist/types/session-import/codex.d.ts +88 -0
- package/dist/types/session-import/command.d.ts +35 -0
- package/dist/types/session-import/detect.d.ts +22 -0
- package/dist/types/session-import/index.d.ts +7 -0
- package/dist/types/session-import/provider-codex.d.ts +22 -0
- package/dist/types/session-import/provider-service.d.ts +38 -0
- package/dist/types/session-import/redact.d.ts +23 -0
- package/dist/types/session-import/service.d.ts +47 -0
- package/dist/types/session-import/types.d.ts +120 -0
- package/dist/types/setup/paseo/check.d.ts +16 -0
- package/dist/types/setup/paseo/install-saga.d.ts +115 -0
- package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
- package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
- package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
- package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
- package/dist/types/setup/paseo/provider-config.d.ts +35 -0
- package/dist/types/setup/paseo/remove.d.ts +14 -0
- package/dist/types/setup/paseo/result-types.d.ts +59 -0
- package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
- package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
- package/dist/types/skill-state/active-state.d.ts +5 -1
- package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
- package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
- package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
- package/dist/types/slash-commands/types.d.ts +7 -1
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/tools/ask-answer-registry.d.ts +6 -3
- package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
- package/dist/types/tools/bash.d.ts +2 -6
- package/dist/types/tools/browser/attach.d.ts +11 -0
- package/dist/types/tools/browser/launch.d.ts +20 -0
- package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
- package/dist/types/tools/browser/render.d.ts +1 -0
- package/dist/types/tools/browser.d.ts +3 -1
- package/dist/types/tools/image-gen.d.ts +10 -2
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/tools/monitor.d.ts +1 -1
- package/dist/types/tools/skill-discovery.d.ts +8 -1
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
- package/dist/types/utils/herdr-pane.d.ts +76 -0
- package/package.json +19 -9
- package/scripts/build-binary.ts +2 -1
- package/scripts/build-sdk-package-smoke.ts +2 -13
- package/scripts/capture-sticky-viewport-showcase.ts +0 -1
- package/scripts/compile-args.ts +2 -0
- package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
- package/scripts/generate-sdk-operation-inventory.ts +28 -0
- package/scripts/generate-telegram-baseline-manifest.ts +1 -0
- package/scripts/generate-tool-catalog.ts +6 -4
- package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
- package/src/async/job-manager.ts +352 -17
- package/src/async/support.ts +25 -0
- package/src/autoresearch/dashboard.ts +28 -13
- package/src/capability/index.ts +3 -1
- package/src/capability/mcp.ts +6 -0
- package/src/capability/types.ts +6 -0
- package/src/cli/accounts-cli.ts +395 -0
- package/src/cli/args.ts +23 -0
- package/src/cli/auth-broker-cli.ts +136 -66
- package/src/cli/auth-gateway-cli.ts +94 -70
- package/src/cli/config-cli.ts +27 -2
- package/src/cli/crash-cli.ts +78 -0
- package/src/cli/customize-doctor.ts +1605 -0
- package/src/cli/daemon-cli.ts +12 -3
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/mcp-cli.ts +128 -60
- package/src/cli/notify-cli.ts +8 -14
- package/src/cli/root-flags.ts +8 -0
- package/src/cli/setup-cli.ts +145 -0
- package/src/cli/skills-cli.ts +38 -2
- package/src/cli/update-cli.ts +167 -12
- package/src/cli.ts +4 -0
- package/src/commands/accounts.ts +71 -0
- package/src/commands/crash.ts +35 -0
- package/src/commands/customize.ts +84 -0
- package/src/commands/daemon.ts +2 -2
- package/src/commands/deep-interview.ts +2 -0
- package/src/commands/launch.ts +1 -0
- package/src/commands/mcp.ts +12 -7
- package/src/commands/ralplan.ts +2 -0
- package/src/commands/sdk.ts +65 -32
- package/src/commands/setup.ts +5 -0
- package/src/commands/skills.ts +14 -4
- package/src/commands/ultragoal.ts +10 -1
- package/src/commands/update.ts +26 -15
- package/src/config/atomic-yaml-patch-worker.ts +57 -0
- package/src/config/atomic-yaml-patch.ts +769 -29
- package/src/config/config-schema-version.ts +10 -0
- package/src/config/file-lock.ts +70 -1
- package/src/config/mcp-schema.json +52 -14
- package/src/config/model-profile-activation.ts +23 -2
- package/src/config/model-profiles.ts +88 -21
- package/src/config/model-registry.ts +147 -21
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +77 -7
- package/src/config/settings.ts +4038 -199
- package/src/config/skill-settings-defaults.ts +41 -3
- package/src/config/task-delegation.ts +87 -0
- package/src/config/update-channel.ts +23 -0
- package/src/coordinator-mcp/server.ts +377 -308
- package/src/crash/index-store.ts +723 -0
- package/src/crash/nudge.ts +89 -0
- package/src/crash/record-loader.ts +149 -0
- package/src/crash/report.ts +551 -0
- package/src/crash/sanitize.ts +129 -0
- package/src/cursor.ts +36 -10
- package/src/customization/import.ts +946 -0
- package/src/customization/index.ts +4 -0
- package/src/customization/inventory.ts +335 -0
- package/src/customization/mutations.ts +141 -0
- package/src/customization/types.ts +223 -0
- package/src/daemon/control-types.ts +8 -1
- package/src/daemon/operator-contract.ts +6 -6
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +31 -6
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
- package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
- package/src/discovery/claude-hooks.ts +38 -0
- package/src/discovery/claude.ts +142 -209
- package/src/discovery/codex-hooks.ts +38 -0
- package/src/discovery/codex.ts +132 -198
- package/src/discovery/helpers.ts +8 -1
- package/src/discovery/index.ts +2 -0
- package/src/discovery/mcp-compat.ts +234 -0
- package/src/discovery/mcp-json.ts +6 -0
- package/src/edit/renderer.ts +40 -0
- package/src/edit/streaming.ts +4 -2
- package/src/exec/bash-executor.ts +24 -20
- package/src/export/html/index.ts +51 -2
- package/src/extensibility/extensions/loader.ts +9 -1
- package/src/extensibility/extensions/runner.ts +17 -4
- package/src/extensibility/extensions/types.ts +31 -6
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
- package/src/extensibility/gjc-plugins/installer.ts +9 -5
- package/src/extensibility/gjc-plugins/observability.ts +8 -2
- package/src/extensibility/gjc-plugins/registry.ts +13 -2
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
- package/src/extensibility/gjc-plugins/schema.ts +222 -39
- package/src/extensibility/hooks/loader.ts +154 -10
- package/src/extensibility/runtime-skill-discovery.ts +257 -60
- package/src/extensibility/skill-management.ts +368 -0
- package/src/extensibility/skills.ts +52 -16
- package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
- package/src/gjc-runtime/launch-worktree.ts +56 -2
- package/src/gjc-runtime/linux-proc.ts +7 -1
- package/src/gjc-runtime/ralplan-runtime.ts +100 -164
- package/src/gjc-runtime/session-state-lock.ts +992 -0
- package/src/gjc-runtime/session-state-sidecar.ts +644 -142
- package/src/gjc-runtime/state-writer.ts +39 -14
- package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
- package/src/gjc-runtime/ultragoal-guard.ts +11 -4
- package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
- package/src/gjc-runtime/workflow-settings.ts +596 -0
- package/src/goals/tools/goal-tool.ts +6 -1
- package/src/hooks/events.ts +291 -0
- package/src/hooks/native-skill-hook.ts +4 -0
- package/src/hooks/normalize.ts +369 -0
- package/src/hooks/skill-state.ts +165 -46
- package/src/internal-urls/docs-index.generated.ts +41 -32
- package/src/main.ts +36 -5
- package/src/migrate/mcp-mapper.ts +25 -2
- package/src/modes/acp/abort-scope.ts +29 -0
- package/src/modes/acp/acp-agent.ts +423 -98
- package/src/modes/components/assistant-message.ts +6 -1
- package/src/modes/components/chrome.ts +4 -3
- package/src/modes/components/customization/customization-dashboard.ts +443 -0
- package/src/modes/components/customization/import-wizard.ts +372 -0
- package/src/modes/components/customization/index.ts +1 -0
- package/src/modes/components/dynamic-theme-text.ts +22 -0
- package/src/modes/components/gajae-pet-widget.ts +607 -193
- package/src/modes/components/irc-sidebar.ts +11 -0
- package/src/modes/components/iterm-pet-transport.ts +585 -0
- package/src/modes/components/model-selector.ts +1 -6
- package/src/modes/components/oauth-selector.ts +277 -86
- package/src/modes/components/pet-capability.ts +35 -31
- package/src/modes/components/settings-selector.ts +29 -5
- package/src/modes/components/status-line/gh.ts +45 -1
- package/src/modes/components/status-line/segments.ts +19 -4
- package/src/modes/components/tool-status-header.ts +13 -3
- package/src/modes/controllers/command-controller.ts +20 -8
- package/src/modes/controllers/extension-ui-controller.ts +56 -257
- package/src/modes/controllers/input-controller.ts +10 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
- package/src/modes/controllers/selector-controller.ts +238 -77
- package/src/modes/controllers/stt-controller.ts +3 -3
- package/src/modes/interactive-mode.ts +88 -16
- package/src/modes/print-mode.ts +35 -6
- package/src/modes/runtime-init.ts +21 -68
- package/src/modes/theme/defaults/index.ts +2 -0
- package/src/modes/theme/defaults/ouroboros.json +118 -0
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/injected-user-submission.ts +25 -0
- package/src/modes/utils/terminal-bell.ts +15 -1
- package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/critic.md +1 -1
- package/src/prompts/system/rlm-research.md +1 -1
- package/src/prompts/system/system-prompt.md +0 -2
- package/src/prompts/tools/bash.md +7 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/cron.md +6 -6
- package/src/prompts/tools/read.md +0 -2
- package/src/prompts/tools/skill-discovery.md +4 -2
- package/src/runtime-mcp/client.ts +427 -23
- package/src/runtime-mcp/config.ts +37 -5
- package/src/runtime-mcp/manager-pool.test.ts +56 -0
- package/src/runtime-mcp/manager.ts +52 -2
- package/src/runtime-mcp/oauth-discovery.ts +13 -0
- package/src/runtime-mcp/oauth-flow.ts +46 -1
- package/src/runtime-mcp/pool-key.ts +5 -1
- package/src/runtime-mcp/pool.test.ts +9 -0
- package/src/runtime-mcp/protocol.test.ts +353 -0
- package/src/runtime-mcp/protocol.ts +624 -0
- package/src/runtime-mcp/scope-config.ts +78 -0
- package/src/runtime-mcp/tool-bridge.ts +44 -10
- package/src/runtime-mcp/transports/http.ts +84 -13
- package/src/runtime-mcp/types.ts +123 -12
- package/src/sdk/acp/adapter.ts +328 -97
- package/src/sdk/broker/broker.ts +160 -741
- package/src/sdk/broker/ensure.ts +9 -1
- package/src/sdk/broker/lifecycle.ts +510 -116
- package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
- package/src/sdk/broker/process-incarnation.ts +19 -0
- package/src/sdk/broker/session-index.ts +516 -117
- package/src/sdk/broker/startup-failure.ts +28 -5
- package/src/sdk/broker/transport.ts +1 -13
- package/src/sdk/bus/chat-daemon-cli.ts +4 -2
- package/src/sdk/bus/chat-daemon-control.ts +67 -6
- package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
- package/src/sdk/bus/config.ts +13 -0
- package/src/sdk/bus/conversation-store.ts +59 -171
- package/src/sdk/bus/daemon-paths.ts +20 -0
- package/src/sdk/bus/discord-conversation.ts +2 -0
- package/src/sdk/bus/discord-daemon.ts +524 -126
- package/src/sdk/bus/discord-live-provider.ts +206 -79
- package/src/sdk/bus/existing-thread-readiness.ts +4 -97
- package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
- package/src/sdk/bus/index.ts +2480 -456
- package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
- package/src/sdk/bus/lifecycle-commands.ts +2 -2
- package/src/sdk/bus/managed-daemon.ts +2 -145
- package/src/sdk/bus/notification-service.ts +196 -0
- package/src/sdk/bus/operator-runtime.ts +1 -1
- package/src/sdk/bus/prompt-reconciliation.ts +24 -15
- package/src/sdk/bus/reconciliation-store.ts +553 -24
- package/src/sdk/bus/session-control.ts +49 -3
- package/src/sdk/bus/slack-conversation.ts +4 -0
- package/src/sdk/bus/slack-daemon.ts +733 -134
- package/src/sdk/bus/slack-live-provider.ts +65 -15
- package/src/sdk/bus/slack-provider.ts +4 -0
- package/src/sdk/bus/slack-thread-binding.ts +9 -104
- package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
- package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
- package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
- package/src/sdk/bus/telegram-daemon-control.ts +3 -13
- package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
- package/src/sdk/bus/telegram-daemon.ts +2552 -2280
- package/src/sdk/bus/telegram-reference.ts +1 -226
- package/src/sdk/bus/telegram-setup.ts +2 -2
- package/src/sdk/bus/threaded-render.ts +1 -0
- package/src/sdk/bus/topic-registry.ts +279 -146
- package/src/sdk/cli/rows.ts +2 -35
- package/src/sdk/cli/session-cli.ts +776 -700
- package/src/sdk/client/client.ts +892 -1
- package/src/sdk/host/control/dispatch.ts +93 -58
- package/src/sdk/host/control/index.ts +1 -1
- package/src/sdk/host/control/operations.ts +18 -10
- package/src/sdk/host/control/runtime-gate.ts +40 -0
- package/src/sdk/host/host.ts +63 -12
- package/src/sdk/host/query/handlers.ts +66 -7
- package/src/sdk/host/reverse-leases.ts +2 -2
- package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
- package/src/sdk/host/session-runtime.test.ts +2787 -34
- package/src/sdk/host/session-runtime.ts +1489 -101
- package/src/sdk/host/surface-policy.ts +12 -8
- package/src/sdk/host/types.ts +1 -1
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
- package/src/sdk/host/websocket-transport.ts +85 -50
- package/src/sdk/index.ts +2 -1
- package/src/sdk/lifecycle/broker-client.ts +53 -0
- package/src/sdk/lifecycle/client.ts +234 -0
- package/src/sdk/lifecycle/index.ts +3 -0
- package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
- package/src/sdk/lifecycle/service.ts +720 -0
- package/src/sdk/mcp/server.ts +226 -188
- package/src/sdk/prompt-deadline-lease.ts +55 -0
- package/src/sdk/prompt-deadline-manager.ts +148 -0
- package/src/sdk/prompt-status.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +272 -129
- package/src/sdk/protocol/operation-registry.ts +71 -8
- package/src/sdk/receipt-state.ts +43 -0
- package/src/sdk/reconciliation-extensions.ts +14 -0
- package/src/sdk/router/index.ts +1 -0
- package/src/sdk/router/session-router.ts +1658 -0
- package/src/sdk/session-list.ts +93 -0
- package/src/sdk/session-reconnect.ts +45 -1
- package/src/sdk/session.ts +716 -120
- package/src/sdk/transport/serve-cli.ts +18 -34
- package/src/session/account-inventory.ts +603 -0
- package/src/session/agent-session.ts +2873 -559
- package/src/session/agent-storage.ts +35 -0
- package/src/session/blob-store.ts +32 -1
- package/src/session/coordinator-tool-label.ts +40 -0
- package/src/session/internal/managed-session-scope.ts +4 -0
- package/src/session/internal/managed-session-storage.ts +199 -31
- package/src/session/messages.ts +1 -1
- package/src/session/secure-token-file.ts +80 -0
- package/src/session/session-manager.ts +757 -57
- package/src/session/session-storage.ts +25 -16
- package/src/session/startup-auth-config.ts +297 -0
- package/src/session/terminal-abort.ts +1253 -0
- package/src/session/yield-queue.ts +57 -15
- package/src/session-import/claude.ts +334 -0
- package/src/session-import/codex.ts +1033 -0
- package/src/session-import/command.ts +77 -0
- package/src/session-import/detect.ts +133 -0
- package/src/session-import/index.ts +7 -0
- package/src/session-import/provider-codex.ts +327 -0
- package/src/session-import/provider-service.ts +457 -0
- package/src/session-import/redact.ts +128 -0
- package/src/session-import/service.ts +1264 -0
- package/src/session-import/types.ts +148 -0
- package/src/setup/paseo/check.ts +213 -0
- package/src/setup/paseo/install-saga.ts +293 -0
- package/src/setup/paseo/json-publisher.ts +263 -0
- package/src/setup/paseo/orchestration-preferences.ts +86 -0
- package/src/setup/paseo/paseo-ownership.ts +224 -0
- package/src/setup/paseo/paseo-setup.ts +253 -0
- package/src/setup/paseo/provider-config.ts +139 -0
- package/src/setup/paseo/remove.ts +183 -0
- package/src/setup/paseo/result-types.ts +79 -0
- package/src/setup/paseo/setup-deps.ts +175 -0
- package/src/setup/paseo/skills-bridge.ts +260 -0
- package/src/skill-state/active-state.ts +21 -11
- package/src/slash-commands/acp-builtins.ts +9 -1
- package/src/slash-commands/builtin-registry.ts +117 -19
- package/src/slash-commands/helpers/credential-switch.ts +143 -0
- package/src/slash-commands/helpers/mcp.ts +9 -3
- package/src/slash-commands/helpers/usage-report.ts +61 -56
- package/src/slash-commands/types.ts +7 -2
- package/src/task/executor.ts +30 -11
- package/src/task/index.ts +66 -7
- package/src/task/render.ts +23 -9
- package/src/tools/ask-answer-registry.ts +23 -7
- package/src/tools/ask.ts +8 -4
- package/src/tools/bash-sleep-advisory.ts +115 -0
- package/src/tools/bash.ts +98 -18
- package/src/tools/browser/attach.ts +71 -3
- package/src/tools/browser/launch.ts +86 -13
- package/src/tools/browser/profile-discovery.ts +57 -7
- package/src/tools/browser/render.ts +8 -1
- package/src/tools/browser.ts +130 -21
- package/src/tools/computer.enforcement.test.ts +1 -1
- package/src/tools/computer.ts +3 -2
- package/src/tools/cron.ts +12 -1
- package/src/tools/image-gen.ts +349 -142
- package/src/tools/index.ts +8 -0
- package/src/tools/job.ts +33 -6
- package/src/tools/monitor.ts +41 -2
- package/src/tools/skill-discovery.ts +21 -29
- package/src/tools/subagent.ts +142 -24
- package/src/tools/todo-contract.ts +55 -3
- package/src/tools/todo-write.ts +4 -3
- package/src/tools/tool-catalog.generated.ts +6 -6
- package/src/tools/ultragoal-ask-guard.ts +10 -3
- package/src/utils/herdr-pane.ts +345 -0
- package/src/utils/title-generator.ts +2 -0
- package/src/web/scrapers/index.ts +6 -4
- package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
- package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
- package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
- package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
- package/dist/types/runtime/lazy-service.test.d.ts +0 -1
- package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
- package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
- package/dist/types/runtime-mcp/config.test.d.ts +0 -1
- package/dist/types/runtime-mcp/http.test.d.ts +0 -1
- package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
- package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
- package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
- package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
- package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
- package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
- package/dist/types/sdk/elevation/audit.d.ts +0 -93
- package/dist/types/sdk/elevation/capability.d.ts +0 -3
- package/dist/types/sdk/elevation/digest.d.ts +0 -72
- package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
- package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
- package/dist/types/sdk/elevation/owner.d.ts +0 -48
- package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
- package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
- package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
- package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
- package/dist/types/session/auth-broker-config.d.ts +0 -13
- package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
- package/dist/types/skills/skills.test.d.ts +0 -1
- package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
- package/dist/types/task/token-log.test.d.ts +0 -1
- package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
- package/dist/types/tools/descriptors.test.d.ts +0 -1
- package/scripts/g005-daemon-smoke.ts +0 -180
- package/scripts/g011-daemon-path-smoke.ts +0 -153
- package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
- package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
- package/src/sdk/bus/telegram-cli.ts +0 -212
- package/src/sdk/elevation/allowlist.ts +0 -54
- package/src/sdk/elevation/audit.ts +0 -208
- package/src/sdk/elevation/capability.ts +0 -41
- package/src/sdk/elevation/digest.ts +0 -129
- package/src/sdk/elevation/dispatch-receipt.ts +0 -184
- package/src/sdk/elevation/grant-ledger.ts +0 -1142
- package/src/sdk/elevation/owner.ts +0 -109
- package/src/session/auth-broker-config.ts +0 -109
- /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
- /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
- /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
package/src/sdk/bus/index.ts
CHANGED
|
@@ -24,7 +24,6 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
24
24
|
import { execFile } from "node:child_process";
|
|
25
25
|
import * as crypto from "node:crypto";
|
|
26
26
|
import * as fs from "node:fs";
|
|
27
|
-
import * as fsPromises from "node:fs/promises";
|
|
28
27
|
import * as os from "node:os";
|
|
29
28
|
import * as path from "node:path";
|
|
30
29
|
import { promisify } from "node:util";
|
|
@@ -49,6 +48,7 @@ function sdkBusNatives(): NativeSdkBusBindings {
|
|
|
49
48
|
type NotificationServer = NativeNotificationServer;
|
|
50
49
|
|
|
51
50
|
import { $credentialEnv, logger, postmortem, VERSION } from "@gajae-code/utils";
|
|
51
|
+
import { AsyncJobManager } from "../../async";
|
|
52
52
|
import { Settings, validateSettingPatch } from "../../config/settings";
|
|
53
53
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
|
|
54
54
|
import { INTERACTIVE_SELECTOR_RESUME_ORIGIN } from "../../extensibility/shared-events";
|
|
@@ -61,6 +61,12 @@ import {
|
|
|
61
61
|
} from "../../modes/shared/agent-wire/workflow-gate-broker";
|
|
62
62
|
import type { AgentSessionEvent } from "../../session/agent-session";
|
|
63
63
|
import type { ClientBridge } from "../../session/client-bridge";
|
|
64
|
+
import {
|
|
65
|
+
boundTerminalRetentionState,
|
|
66
|
+
findOwnedRegistrationsForTurn,
|
|
67
|
+
isOwnedAttemptRegistrationIncomplete,
|
|
68
|
+
settleOwnedWork,
|
|
69
|
+
} from "../../session/terminal-abort";
|
|
64
70
|
import { parseThinkingLevel } from "../../thinking";
|
|
65
71
|
import type {
|
|
66
72
|
AskAnswerRequest,
|
|
@@ -84,9 +90,16 @@ import { ensureBroker } from "../broker/ensure";
|
|
|
84
90
|
import { publishSessionHostRuntimeEvidence, type SessionHostRuntimePublication } from "../broker/lifecycle";
|
|
85
91
|
import { processIncarnation } from "../broker/process-incarnation";
|
|
86
92
|
import { SessionIndex } from "../broker/session-index";
|
|
87
|
-
import {
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
import {
|
|
94
|
+
CAP_GATED_FRAME_KINDS,
|
|
95
|
+
createSdkSurfaceFactory,
|
|
96
|
+
type SessionSdkHost,
|
|
97
|
+
SessionSdkSessionRuntime,
|
|
98
|
+
shouldHostSdk,
|
|
99
|
+
TOOL_ACTIVITY_CAPABILITY,
|
|
100
|
+
} from "../host";
|
|
101
|
+
import { type AbortScope, type ControlSurface, dispatchControl, TypedControlError } from "../host/control";
|
|
102
|
+
import { BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD } from "../host/control/runtime-gate";
|
|
90
103
|
import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query";
|
|
91
104
|
import type { SdkFrame } from "../host/types";
|
|
92
105
|
import {
|
|
@@ -104,6 +117,7 @@ import {
|
|
|
104
117
|
normalizeSdkStartupFailure,
|
|
105
118
|
type SdkStartupFailure,
|
|
106
119
|
} from "../startup-capability";
|
|
120
|
+
import type { TurnResultContent } from "../turn-result";
|
|
107
121
|
import { registerTelegramFileSink } from "./attachment-registry";
|
|
108
122
|
import { ensureDiscordDaemon, ensureSlackDaemon } from "./chat-daemon-control";
|
|
109
123
|
import {
|
|
@@ -111,10 +125,12 @@ import {
|
|
|
111
125
|
getNotificationConfig,
|
|
112
126
|
isProviderEffectivelyEnabled,
|
|
113
127
|
isSlackComplete,
|
|
128
|
+
isTelegramSessionEligible,
|
|
114
129
|
type NotificationConfig,
|
|
115
130
|
type NotificationSettingsReader,
|
|
116
131
|
resolveGenericNotificationSessionEligibility,
|
|
117
132
|
sessionTag,
|
|
133
|
+
tokenFingerprint,
|
|
118
134
|
} from "./config";
|
|
119
135
|
import { telegramControlCommandUsage } from "./config-commands";
|
|
120
136
|
import {
|
|
@@ -129,20 +145,26 @@ import {
|
|
|
129
145
|
isExistingThreadBindingRequested,
|
|
130
146
|
} from "./existing-thread-readiness";
|
|
131
147
|
import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage, truncate } from "./helpers";
|
|
132
|
-
import {
|
|
148
|
+
import {
|
|
149
|
+
createKindAwareReconciliation,
|
|
150
|
+
type KindAwareReconciliation,
|
|
151
|
+
type ReconciliationKind,
|
|
152
|
+
} from "./kind-aware-reconciliation";
|
|
133
153
|
import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility";
|
|
134
154
|
import { proposedTelegramIdentity } from "./notification-orchestration";
|
|
135
155
|
import { createPromptReconciliation } from "./prompt-reconciliation";
|
|
136
|
-
import {
|
|
156
|
+
import {
|
|
157
|
+
createReconciliationStore,
|
|
158
|
+
type DurableTerminalScopeRecord,
|
|
159
|
+
type EvictedTerminalKeyEntry,
|
|
160
|
+
resolveReconciliationSessionFile,
|
|
161
|
+
} from "./reconciliation-store";
|
|
137
162
|
import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control";
|
|
138
163
|
import type { SlackConversation } from "./slack-conversation";
|
|
139
164
|
import {
|
|
140
165
|
ASK_SELECTED_ACK_CAPABILITY,
|
|
141
166
|
type EnsureDaemonResult,
|
|
142
|
-
endpointAuthorityDigest,
|
|
143
167
|
ensureTelegramDaemonRunningDetailed,
|
|
144
|
-
type RegisterNotificationRootResult,
|
|
145
|
-
unregisterNotificationRoot,
|
|
146
168
|
} from "./telegram-daemon";
|
|
147
169
|
|
|
148
170
|
export type {
|
|
@@ -156,6 +178,29 @@ export {
|
|
|
156
178
|
runIdentityControlTerminalPath,
|
|
157
179
|
} from "./control-drain-lease";
|
|
158
180
|
|
|
181
|
+
export type NotificationInboundAdmission =
|
|
182
|
+
| { outcome: "accept" }
|
|
183
|
+
| { outcome: "drop"; reason: "inbound_fenced" | "policy_suspended" }
|
|
184
|
+
| { outcome: "defer"; reason: "policy_suspended" };
|
|
185
|
+
|
|
186
|
+
/** Exact production admission decision for daemon-originated session inbound. */
|
|
187
|
+
export function notificationInboundAdmission(input: {
|
|
188
|
+
inboundFenced: boolean;
|
|
189
|
+
policySuspended: boolean;
|
|
190
|
+
notificationOrigin: boolean;
|
|
191
|
+
controlCommand: boolean;
|
|
192
|
+
}): NotificationInboundAdmission {
|
|
193
|
+
if (input.inboundFenced) return { outcome: "drop", reason: "inbound_fenced" };
|
|
194
|
+
if (input.policySuspended && input.notificationOrigin) {
|
|
195
|
+
// Valid control commands are not dropped: they are deferred while policy is
|
|
196
|
+
// provisional and executed on activate. A terminal `dropped` ack would
|
|
197
|
+
// contradict that later execution and invite client-side retry duplication.
|
|
198
|
+
if (input.controlCommand) return { outcome: "defer", reason: "policy_suspended" };
|
|
199
|
+
return { outcome: "drop", reason: "policy_suspended" };
|
|
200
|
+
}
|
|
201
|
+
return { outcome: "accept" };
|
|
202
|
+
}
|
|
203
|
+
|
|
159
204
|
const PROMPT_SETTLEMENT_DIAGNOSTIC_ENTRY_LIMIT = 8;
|
|
160
205
|
const PROMPT_SETTLEMENT_DIAGNOSTIC_MAX_AGE_MS = 86_400_000;
|
|
161
206
|
/**
|
|
@@ -193,6 +238,27 @@ function formatPromptTerminalFailureReason(reason: unknown): string {
|
|
|
193
238
|
return rawReason ? rawReason.slice(0, PROMPT_TERMINAL_FAILURE_REASON_LOG_MAX) : "unreported";
|
|
194
239
|
}
|
|
195
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Thrown from a serialized durable terminal-scope transaction when the
|
|
243
|
+
* idempotency key is already owned by a DIFFERENT input (scope). The generic
|
|
244
|
+
* dispatch cache normally rejects this before the surface, but after its
|
|
245
|
+
* 256-entry eviction two concurrent requests can both pass the earlier
|
|
246
|
+
* snapshot check; the atomic recheck inside the transaction must reject the
|
|
247
|
+
* second instead of appending a duplicate-key row (review thread P2).
|
|
248
|
+
*/
|
|
249
|
+
class TerminalIdempotencyConflictError extends Error {
|
|
250
|
+
constructor() {
|
|
251
|
+
super("Idempotency key was reused with different input.");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function endpointAuthorityDigest(url: string, token: string): string {
|
|
256
|
+
const parsed = new URL(url);
|
|
257
|
+
parsed.hash = "";
|
|
258
|
+
parsed.search = "";
|
|
259
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
260
|
+
return crypto.createHash("sha256").update(`${parsed.toString()}${token}`, "utf8").digest("hex");
|
|
261
|
+
}
|
|
196
262
|
export function formatPromptSettlementDiagnostic(
|
|
197
263
|
proof: Extract<RunSettlementProof, { status: "unfenced" }>,
|
|
198
264
|
now = Date.now(),
|
|
@@ -210,15 +276,11 @@ export function formatPromptSettlementDiagnostic(
|
|
|
210
276
|
}
|
|
211
277
|
|
|
212
278
|
// ===========================================================================
|
|
213
|
-
// Session lifecycle
|
|
214
|
-
// contract in `crates/gjc-sdk/src/lifecycle.rs`).
|
|
215
|
-
//
|
|
216
|
-
// These describe the frames exchanged over the daemon-owned, session-independent
|
|
217
|
-
// control endpoint for remote session create / close / resume. Field names are
|
|
218
|
-
// camelCase on the wire; `type`/`kind` discriminators are snake_case. The Rust
|
|
219
|
-
// ingress authenticates and forwards; the daemon (TypeScript) owns all policy,
|
|
220
|
-
// spawn orchestration, idempotency, rate limiting, audit, and UX.
|
|
279
|
+
// Session lifecycle presentation contract
|
|
221
280
|
// ===========================================================================
|
|
281
|
+
// Provider-neutral lifecycle command targets and credential-free presentation outcomes.
|
|
282
|
+
// SessionLifecycleService owns request authorization/idempotency; these types contain no
|
|
283
|
+
// control endpoint, process, tmux, session-state, or SDK endpoint authority.
|
|
222
284
|
|
|
223
285
|
/** Where a `session_create` should run. Discriminated by `kind`. */
|
|
224
286
|
export type SessionCreateTarget =
|
|
@@ -229,10 +291,6 @@ export type SessionCreateTarget =
|
|
|
229
291
|
/** Identifies the session a `session_close` targets. */
|
|
230
292
|
export interface SessionCloseTarget {
|
|
231
293
|
sessionId: string;
|
|
232
|
-
/** Expected GJC-managed tmux session name (defense-in-depth match). */
|
|
233
|
-
tmuxSession?: string;
|
|
234
|
-
/** Expected `@gjc-session-state-file` tag (defense-in-depth match). */
|
|
235
|
-
sessionStateFile?: string;
|
|
236
294
|
}
|
|
237
295
|
|
|
238
296
|
/** Identifies the session a `session_resume` targets. */
|
|
@@ -242,110 +300,33 @@ export interface SessionResumeTarget {
|
|
|
242
300
|
path?: string;
|
|
243
301
|
}
|
|
244
302
|
|
|
245
|
-
/** Create a new session. */
|
|
246
|
-
export interface SessionCreateFrame {
|
|
247
|
-
type: "session_create";
|
|
248
|
-
requestId: string;
|
|
249
|
-
/** Deterministic lifecycle marker preallocated by the daemon before spawn. */
|
|
250
|
-
lifecycleRequestId: string;
|
|
251
|
-
/** Session id the daemon preallocated and propagates to the child. */
|
|
252
|
-
intendedSessionId: string;
|
|
253
|
-
/** Telegram update id (idempotency key on the daemon side). */
|
|
254
|
-
updateId: number;
|
|
255
|
-
chatId: string;
|
|
256
|
-
/** Control-endpoint token authorizing this frame. */
|
|
257
|
-
token: string;
|
|
258
|
-
target: SessionCreateTarget;
|
|
259
|
-
/** Reserved for a future capability transport; any supplied value is rejected before lifecycle acceptance. */
|
|
260
|
-
startupPromptRef?: string;
|
|
261
|
-
/** Model profile preset to activate for the spawned session (--mpreset). */
|
|
262
|
-
modelPreset?: string;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** Close (hard-kill, history preserved) a session. */
|
|
266
|
-
export interface SessionCloseFrame {
|
|
267
|
-
type: "session_close";
|
|
268
|
-
requestId: string;
|
|
269
|
-
updateId: number;
|
|
270
|
-
chatId: string;
|
|
271
|
-
token: string;
|
|
272
|
-
target: SessionCloseTarget;
|
|
273
|
-
/** Required force-only close flag; false/omitted is rejected by daemon policy. */
|
|
274
|
-
force?: boolean;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Resume a session (reattach if alive, else cold-restart from history). */
|
|
278
|
-
export interface SessionResumeFrame {
|
|
279
|
-
type: "session_resume";
|
|
280
|
-
requestId: string;
|
|
281
|
-
updateId: number;
|
|
282
|
-
chatId: string;
|
|
283
|
-
token: string;
|
|
284
|
-
target: SessionResumeTarget;
|
|
285
|
-
/** Reserved for a future capability transport; any supplied value is rejected before lifecycle acceptance. */
|
|
286
|
-
startupPromptRef?: string;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** Any client -> ingress lifecycle request frame. */
|
|
290
|
-
export type SessionLifecycleRequest = SessionCreateFrame | SessionCloseFrame | SessionResumeFrame;
|
|
291
|
-
|
|
292
|
-
/** Terminal status of a lifecycle request. */
|
|
293
303
|
export type LifecycleStatus = "ok" | "error";
|
|
294
304
|
|
|
295
|
-
/** A connected session's per-session endpoint, returned to the control client. */
|
|
296
|
-
export interface LifecycleEndpoint {
|
|
297
|
-
url: string;
|
|
298
|
-
token: string;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** The Telegram topic/thread a session is surfaced in. */
|
|
302
|
-
export interface LifecycleTopic {
|
|
303
|
-
chatId: string;
|
|
304
|
-
threadId: string;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/** How a create request was correlated to its spawned session. */
|
|
308
|
-
export type MatchedBy = "spawn_marker" | "session_ready";
|
|
309
|
-
|
|
310
|
-
/** Response to a successful `session_create`. */
|
|
311
305
|
export interface SessionCreateResponseFrame {
|
|
312
306
|
type: "session_create_response";
|
|
313
307
|
requestId: string;
|
|
314
308
|
status: LifecycleStatus;
|
|
315
|
-
lifecycleRequestId: string;
|
|
316
309
|
sessionId: string;
|
|
317
|
-
matchedBy: MatchedBy;
|
|
318
|
-
endpoint: LifecycleEndpoint;
|
|
319
|
-
topic: LifecycleTopic;
|
|
320
310
|
target: SessionCreateTarget;
|
|
321
311
|
}
|
|
322
312
|
|
|
323
|
-
/** Response to a successful `session_close`. */
|
|
324
313
|
export interface SessionCloseResponseFrame {
|
|
325
314
|
type: "session_close_response";
|
|
326
315
|
requestId: string;
|
|
327
316
|
status: LifecycleStatus;
|
|
328
317
|
sessionId: string;
|
|
329
|
-
processGone: boolean;
|
|
330
|
-
historyPreserved: boolean;
|
|
331
|
-
endpointStale: boolean;
|
|
332
318
|
}
|
|
333
319
|
|
|
334
|
-
/** Whether a resume reattached to a live session or cold-restarted a dead one. */
|
|
335
320
|
export type ResumeMode = "reattached" | "cold_restarted";
|
|
336
321
|
|
|
337
|
-
/** Response to a successful `session_resume`. */
|
|
338
322
|
export interface SessionResumeResponseFrame {
|
|
339
323
|
type: "session_resume_response";
|
|
340
324
|
requestId: string;
|
|
341
325
|
status: LifecycleStatus;
|
|
342
326
|
sessionId: string;
|
|
343
327
|
mode: ResumeMode;
|
|
344
|
-
endpoint: LifecycleEndpoint;
|
|
345
|
-
topic: LifecycleTopic;
|
|
346
328
|
}
|
|
347
329
|
|
|
348
|
-
/** Machine-readable reason a lifecycle request failed. */
|
|
349
330
|
export type LifecycleErrorReason =
|
|
350
331
|
| "unauthorized"
|
|
351
332
|
| "rate_limited"
|
|
@@ -360,15 +341,12 @@ export type LifecycleErrorReason =
|
|
|
360
341
|
| "terminal_uncertain"
|
|
361
342
|
| "unsupported_platform";
|
|
362
343
|
|
|
363
|
-
/** A candidate returned with an `ambiguous_target` resume error. */
|
|
364
344
|
export interface ResumeCandidate {
|
|
365
345
|
sessionId: string;
|
|
366
346
|
path?: string;
|
|
367
|
-
/** Last-activity epoch-millis (session history file mtime), if known. */
|
|
368
347
|
mtimeMs?: number;
|
|
369
348
|
}
|
|
370
349
|
|
|
371
|
-
/** A structured lifecycle error frame. */
|
|
372
350
|
export interface SessionLifecycleErrorFrame {
|
|
373
351
|
type: "session_lifecycle_error";
|
|
374
352
|
requestId: string;
|
|
@@ -378,7 +356,6 @@ export interface SessionLifecycleErrorFrame {
|
|
|
378
356
|
candidates?: ResumeCandidate[];
|
|
379
357
|
}
|
|
380
358
|
|
|
381
|
-
/** Any ingress -> client lifecycle response frame. */
|
|
382
359
|
export type SessionLifecycleResponse =
|
|
383
360
|
| SessionCreateResponseFrame
|
|
384
361
|
| SessionCloseResponseFrame
|
|
@@ -454,19 +431,21 @@ export function readGitRepoName(cwd: string): string | undefined {
|
|
|
454
431
|
/** Build the one-time identity header fields for a session thread. */
|
|
455
432
|
function buildIdentity(
|
|
456
433
|
cwd: string,
|
|
457
|
-
sessionName
|
|
434
|
+
sessionName: string | undefined,
|
|
435
|
+
telegramTopicsEnabled: boolean,
|
|
458
436
|
): {
|
|
459
437
|
repo: string;
|
|
460
438
|
branch: string;
|
|
461
439
|
machine: string;
|
|
462
440
|
title?: string;
|
|
441
|
+
telegramTopicsEnabled: boolean;
|
|
463
442
|
} {
|
|
464
443
|
const repo = readGitRepoName(cwd) ?? (path.basename(cwd) || cwd);
|
|
465
444
|
const branch = readGitBranch(cwd) ?? "(detached)";
|
|
466
445
|
// Send repo/branch and the raw session title separately; the consumer
|
|
467
446
|
// composes the topic name ("{repo}/{branch}" before the session title is
|
|
468
447
|
// auto-generated, then "{repo}/{branch} - {session title}" once it exists).
|
|
469
|
-
return { repo, branch, machine: os.hostname(), title: sessionName };
|
|
448
|
+
return { repo, branch, machine: os.hostname(), title: sessionName, telegramTopicsEnabled };
|
|
470
449
|
}
|
|
471
450
|
|
|
472
451
|
/** Compact cwd label for remote session identity; never emits the full host path by default. */
|
|
@@ -1162,6 +1141,9 @@ export class PresentationArbiter {
|
|
|
1162
1141
|
interface SessionRuntime {
|
|
1163
1142
|
server: NotificationServer;
|
|
1164
1143
|
host: SessionSdkHost;
|
|
1144
|
+
/** Delivers one ring-positioned event envelope to every attached subscriber
|
|
1145
|
+
* connection, applying the same capability gate as event replay. */
|
|
1146
|
+
broadcastEventFrame: (event: SdkFrame) => void;
|
|
1165
1147
|
/** Owns stateRoot-backed revisions and removes their spills on terminal shutdown. */
|
|
1166
1148
|
revisions: RevisionStore;
|
|
1167
1149
|
/** Releases all snapshot pins before the revision store is closed. */
|
|
@@ -1171,6 +1153,8 @@ interface SessionRuntime {
|
|
|
1171
1153
|
/** Discovery scope is fixed before publication; a live default endpoint is never rotated in place. */
|
|
1172
1154
|
endpointScope: "default" | "chat";
|
|
1173
1155
|
idleSeq: number;
|
|
1156
|
+
/** Stops delayed session-name observation when this runtime loses authority. */
|
|
1157
|
+
stopSessionNameObserver: () => void;
|
|
1174
1158
|
/** Interactive asks awaiting a remote answer, by action id. */
|
|
1175
1159
|
pendingInteractive: Map<string, PendingInteractiveAsk>;
|
|
1176
1160
|
/** Deregisters this session's ask answer source. */
|
|
@@ -1183,6 +1167,12 @@ interface SessionRuntime {
|
|
|
1183
1167
|
notificationsActive: boolean;
|
|
1184
1168
|
/** Provider ownership state is independent from the already-published core SDK runtime. */
|
|
1185
1169
|
notificationOwnerState: "ready" | "retry" | "blocked";
|
|
1170
|
+
/**
|
|
1171
|
+
* Ownership-relevant configuration identity this runtime's owner state was
|
|
1172
|
+
* proved under. A settled outcome may only be applied while it still matches,
|
|
1173
|
+
* so a credential/destination/enablement change forces a re-proof.
|
|
1174
|
+
*/
|
|
1175
|
+
notificationOwnerKey?: string;
|
|
1186
1176
|
/** Rejects new SDK frames while a leased terminal response drains. */
|
|
1187
1177
|
inboundFenced: boolean;
|
|
1188
1178
|
/** Set as soon as terminal teardown is requested, before startup settles. */
|
|
@@ -1216,14 +1206,14 @@ interface SessionRuntime {
|
|
|
1216
1206
|
/** This runtime's own host-liveness publication; only its teardown may retract it. */
|
|
1217
1207
|
evidencePublication?: SessionHostRuntimePublication;
|
|
1218
1208
|
brokerRegistrationReleased: boolean;
|
|
1219
|
-
/** Managed Telegram root registration released during terminal teardown. */
|
|
1220
|
-
notificationRootRegistration?: { settings: Settings; cwd: string; registrationToken: string };
|
|
1221
1209
|
verbosity: "lean" | "verbose";
|
|
1222
1210
|
sessionTag: string;
|
|
1223
1211
|
/** Whether the agent loop is currently running (drives the typing indicator). */
|
|
1224
1212
|
busy: boolean;
|
|
1225
1213
|
/** Prompt command/turn identities awaiting their corresponding agent_start. */
|
|
1226
1214
|
pendingPromptCorrelations: Array<{ commandId: string; turnId: string }>;
|
|
1215
|
+
/** SDK run tokens bind an accepted queued follow-up to only its matching agent_start. */
|
|
1216
|
+
pendingPromptCorrelationsBySdkRunToken: Map<string, { commandId: string; turnId: string }>;
|
|
1227
1217
|
/** Identity bound to the agent lifecycle currently in flight. */
|
|
1228
1218
|
activePromptCorrelation?: { commandId: string; turnId: string };
|
|
1229
1219
|
/** Binds the executing Agent run to a correlated prompt so cleanup targets only it. */
|
|
@@ -1243,7 +1233,7 @@ interface SessionRuntime {
|
|
|
1243
1233
|
notePromptReconciliation: (
|
|
1244
1234
|
correlation: { commandId: string; turnId: string } | undefined,
|
|
1245
1235
|
frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
|
|
1246
|
-
) => void
|
|
1236
|
+
) => void | Promise<void>;
|
|
1247
1237
|
/** Settles and emits one sanitized correlated prompt failure. */
|
|
1248
1238
|
emitPromptFailure: (correlation: { commandId: string; turnId: string }, error: unknown) => void;
|
|
1249
1239
|
/** Records correlated lifecycle frames for replay and delivers them only to the accepted requester after acknowledgement. */
|
|
@@ -1286,13 +1276,27 @@ interface SessionRuntime {
|
|
|
1286
1276
|
* frames so a stale live edit can never be emitted after the finalized turn. */
|
|
1287
1277
|
turnClosed?: boolean;
|
|
1288
1278
|
/** Finalized while provisional policy was held; flush exactly once on stable activation. */
|
|
1289
|
-
pendingFinal?: {
|
|
1279
|
+
pendingFinal?: {
|
|
1280
|
+
window: number;
|
|
1281
|
+
receipts: Array<{ text: string; messageRef?: string; origin: "user" | "autonomous" | "continuation" }>;
|
|
1282
|
+
};
|
|
1283
|
+
/** Monotonic user-request boundary for deferred lean delivery. */
|
|
1284
|
+
settlementWindow: number;
|
|
1285
|
+
/** Provenance of the currently executing assistant turn. */
|
|
1286
|
+
currentTurnSettlementOrigin?: "user" | "autonomous" | "continuation";
|
|
1287
|
+
/** Immutable settlement boundary captured when the current turn begins. */
|
|
1288
|
+
currentTurnSettlementWindow?: number;
|
|
1290
1289
|
/**
|
|
1291
|
-
* Lean-mode deferred
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1290
|
+
* Lean-mode deferred receipts for the current user-request settlement window.
|
|
1291
|
+
* Ordinary tool-loop turns retain latest-turn-wins behaviour. An autonomous
|
|
1292
|
+
* continuation has no new user request, so it appends instead of erasing the
|
|
1293
|
+
* prior receipt. The small fixed receipt bound prevents an unbounded idle wait
|
|
1294
|
+
* from retaining the full transcript.
|
|
1294
1295
|
*/
|
|
1295
|
-
pendingSettled?: {
|
|
1296
|
+
pendingSettled?: {
|
|
1297
|
+
window: number;
|
|
1298
|
+
receipts: Array<{ text: string; messageRef?: string; origin: "user" | "autonomous" | "continuation" }>;
|
|
1299
|
+
};
|
|
1296
1300
|
/** SDK control frames received during provisional ownership; replayed only after stable activation. */
|
|
1297
1301
|
deferredInboundControls: Array<() => void>;
|
|
1298
1302
|
/** Started tool calls awaiting a terminal activity frame, keyed by tool call id. */
|
|
@@ -1355,11 +1359,22 @@ type SessionStartResult = {
|
|
|
1355
1359
|
suppressExtensionError?: boolean;
|
|
1356
1360
|
};
|
|
1357
1361
|
|
|
1362
|
+
/** Ring append plus positioned live broadcast. An event retained for replay
|
|
1363
|
+
* must also reach already-attached subscribers live as the same positioned
|
|
1364
|
+
* envelope, or they can only observe it by issuing another replay. */
|
|
1365
|
+
function emitSessionEvent(
|
|
1366
|
+
runtime: Pick<SessionRuntime, "host" | "broadcastEventFrame">,
|
|
1367
|
+
frame: { type: string; [key: string]: unknown },
|
|
1368
|
+
payload: Record<string, unknown> = frame,
|
|
1369
|
+
): void {
|
|
1370
|
+
runtime.broadcastEventFrame(runtime.host.emitEvent({ kind: frame.type, payload }));
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1358
1373
|
function pushSessionFrame(
|
|
1359
|
-
runtime: Pick<SessionRuntime, "server" | "host">,
|
|
1374
|
+
runtime: Pick<SessionRuntime, "server" | "host" | "broadcastEventFrame">,
|
|
1360
1375
|
frame: { type: string; [key: string]: unknown },
|
|
1361
1376
|
): void {
|
|
1362
|
-
runtime
|
|
1377
|
+
emitSessionEvent(runtime, frame);
|
|
1363
1378
|
if (frame.type === "turn_stream") {
|
|
1364
1379
|
runtime.server.pushTurnStreamUnchecked(
|
|
1365
1380
|
String(frame.sessionId),
|
|
@@ -1374,30 +1389,30 @@ function pushSessionFrame(
|
|
|
1374
1389
|
}
|
|
1375
1390
|
|
|
1376
1391
|
async function pushTerminalSessionFrame(
|
|
1377
|
-
runtime: Pick<SessionRuntime, "server" | "host">,
|
|
1392
|
+
runtime: Pick<SessionRuntime, "server" | "host" | "broadcastEventFrame">,
|
|
1378
1393
|
frame: { type: "session_closed"; sessionId: string },
|
|
1379
1394
|
): Promise<boolean> {
|
|
1380
|
-
runtime
|
|
1395
|
+
emitSessionEvent(runtime, frame);
|
|
1381
1396
|
return await runtime.server.pushFrameAndWait(JSON.stringify(frame), 1_000);
|
|
1382
1397
|
}
|
|
1383
1398
|
|
|
1384
1399
|
function pushFileAttachment(
|
|
1385
|
-
runtime: Pick<SessionRuntime, "server" | "host">,
|
|
1400
|
+
runtime: Pick<SessionRuntime, "server" | "host" | "broadcastEventFrame">,
|
|
1386
1401
|
frame: { type: "file_attachment"; sessionId: string; name: string; mime?: string; caption?: string },
|
|
1387
1402
|
data: Buffer,
|
|
1388
1403
|
): void {
|
|
1389
|
-
runtime
|
|
1404
|
+
emitSessionEvent(runtime, frame, { ...frame, data: data.toString("base64") });
|
|
1390
1405
|
runtime.server.pushFileAttachmentUnchecked(frame.sessionId, frame.name, frame.mime, data, frame.caption);
|
|
1391
1406
|
}
|
|
1392
1407
|
|
|
1393
1408
|
/** Agent lifecycle is SDK session truth, independent of optional chat delivery. */
|
|
1394
1409
|
function emitAgentLifecycle(
|
|
1395
|
-
runtime: Pick<SessionRuntime, "server" | "host">,
|
|
1410
|
+
runtime: Pick<SessionRuntime, "server" | "host" | "broadcastEventFrame">,
|
|
1396
1411
|
frame: { type: "agent_start" | "agent_end"; sessionId: string; commandId?: string; turnId?: string },
|
|
1397
1412
|
): void {
|
|
1398
1413
|
try {
|
|
1399
1414
|
const json = JSON.stringify(frame);
|
|
1400
|
-
runtime
|
|
1415
|
+
emitSessionEvent(runtime, frame);
|
|
1401
1416
|
runtime.server.pushFrame(json);
|
|
1402
1417
|
} catch (error) {
|
|
1403
1418
|
logger.warn(`sdk: lifecycle delivery failed: ${String(error)}`);
|
|
@@ -2299,6 +2314,9 @@ function sdkQuerySurface(
|
|
|
2299
2314
|
turnId?: string;
|
|
2300
2315
|
clientRef?: string;
|
|
2301
2316
|
}) => unknown = () => ({ status: "unknown" }),
|
|
2317
|
+
steerStatusLookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown = () => ({
|
|
2318
|
+
status: "unknown",
|
|
2319
|
+
}),
|
|
2302
2320
|
): SessionSurface {
|
|
2303
2321
|
return createSdkSurfaceFactory({
|
|
2304
2322
|
ctx,
|
|
@@ -2309,6 +2327,7 @@ function sdkQuerySurface(
|
|
|
2309
2327
|
configOverrides,
|
|
2310
2328
|
settings,
|
|
2311
2329
|
turnResultLookup,
|
|
2330
|
+
steerStatusLookup,
|
|
2312
2331
|
hostTools: () => getInstalledDefinitions("host_tools") !== undefined,
|
|
2313
2332
|
}).query;
|
|
2314
2333
|
}
|
|
@@ -2379,8 +2398,14 @@ function sdkControlSurface(
|
|
|
2379
2398
|
requesterConnectionId?: string,
|
|
2380
2399
|
clientRef?: string,
|
|
2381
2400
|
trackReconciliation?: boolean,
|
|
2401
|
+
preflightAbort?: () => void | Promise<void>,
|
|
2402
|
+
reconciliationKind?: ReconciliationKind,
|
|
2403
|
+
sdkRunToken?: string,
|
|
2404
|
+
) => void | Promise<void> = () => {},
|
|
2405
|
+
onPromptFailed: (
|
|
2406
|
+
correlation: { commandId: string; turnId: string },
|
|
2407
|
+
error: unknown,
|
|
2382
2408
|
) => void | Promise<void> = () => {},
|
|
2383
|
-
onPromptFailed: (correlation: { commandId: string; turnId: string }, error: unknown) => void = () => {},
|
|
2384
2409
|
onPromptAcceptFailed: (correlation: { commandId: string; turnId: string }) => void = () => {},
|
|
2385
2410
|
acceptGateResolution: () => boolean,
|
|
2386
2411
|
trackGateResolution: <T>(resolution: Promise<T>) => Promise<T>,
|
|
@@ -2390,13 +2415,41 @@ function sdkControlSurface(
|
|
|
2390
2415
|
settings?: Settings,
|
|
2391
2416
|
configOverrides: Map<string, unknown> = new Map(),
|
|
2392
2417
|
configRevision: { current: number } = { current: 0 },
|
|
2393
|
-
elevationAuthorityToken?: string,
|
|
2394
2418
|
abortOwnedPrompt: (
|
|
2395
2419
|
connectionId: string | undefined,
|
|
2396
2420
|
) => Promise<{ aborted: true; disposition: "cancelled" | "already_terminal" | "idle" }> = async () => ({
|
|
2397
2421
|
aborted: true,
|
|
2398
2422
|
disposition: "idle",
|
|
2399
2423
|
}),
|
|
2424
|
+
abortTerminalPrompt: (
|
|
2425
|
+
connectionId: string | undefined,
|
|
2426
|
+
scope: AbortScope,
|
|
2427
|
+
idempotencyKey?: string,
|
|
2428
|
+
preflightCancel?: {
|
|
2429
|
+
hasPending: () => boolean;
|
|
2430
|
+
cancel: () => void;
|
|
2431
|
+
},
|
|
2432
|
+
// True only when NO OTHER connection has a pending preflight admission;
|
|
2433
|
+
// the session-wide preflight seam must not be invoked while another
|
|
2434
|
+
// connection's active preflight could be cancelled by it (review P1).
|
|
2435
|
+
noOtherConnectionPreflights?: () => boolean,
|
|
2436
|
+
) => Promise<
|
|
2437
|
+
| {
|
|
2438
|
+
ok: true;
|
|
2439
|
+
outcome:
|
|
2440
|
+
| "stopped"
|
|
2441
|
+
| "stopped_owned"
|
|
2442
|
+
| "no_active_turn"
|
|
2443
|
+
| "already_terminal"
|
|
2444
|
+
| "no_store"
|
|
2445
|
+
| "no_effect"
|
|
2446
|
+
| "no_effect_replay"
|
|
2447
|
+
| "pending_replay"
|
|
2448
|
+
| "uncertain_replay";
|
|
2449
|
+
stored?: { responseState: string; responsePayloadHash: string; terminalPublished: boolean };
|
|
2450
|
+
}
|
|
2451
|
+
| { ok: false; reason: "worker_unsettled" | "owned_unsettled" | "conflict" | "reservation_failed" }
|
|
2452
|
+
> = async () => ({ ok: true, outcome: "no_active_turn" }),
|
|
2400
2453
|
skillRecon?: {
|
|
2401
2454
|
admit: (clientRef?: string) => void;
|
|
2402
2455
|
release: (clientRef?: string) => void;
|
|
@@ -2405,15 +2458,27 @@ function sdkControlSurface(
|
|
|
2405
2458
|
clientRef?: string,
|
|
2406
2459
|
extra?: { skillName?: string },
|
|
2407
2460
|
) => Promise<void>;
|
|
2461
|
+
cancel: (correlation: { commandId: string; turnId: string }) => Promise<void>;
|
|
2408
2462
|
noteTransition: (
|
|
2409
2463
|
correlation: { commandId: string; turnId: string } | undefined,
|
|
2410
|
-
frame:
|
|
2464
|
+
frame:
|
|
2465
|
+
| { type: "agent_start" | "agent_end"; content?: TurnResultContent }
|
|
2466
|
+
| { type: "agent_failed"; error: unknown; content?: TurnResultContent },
|
|
2411
2467
|
) => Promise<void>;
|
|
2412
2468
|
lookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown;
|
|
2469
|
+
reserveSteer?: KindAwareReconciliation["reserveSteer"];
|
|
2470
|
+
settleSteer?: KindAwareReconciliation["settleSteer"];
|
|
2471
|
+
},
|
|
2472
|
+
terminalAbortSeams?: {
|
|
2473
|
+
getTerminalTurnEpoch: () => number | undefined;
|
|
2474
|
+
cancelPendingPreflightForTerminalAbort: () => void;
|
|
2475
|
+
captureTerminalAbortSteeringSnapshot?: () => void;
|
|
2476
|
+
discardTerminalAbortSteeringSnapshot?: (token: number) => void;
|
|
2477
|
+
rebindTerminalAbortSteeringSnapshot?: (token: number) => void;
|
|
2413
2478
|
},
|
|
2414
2479
|
): ControlSurface & {
|
|
2415
|
-
cancelPendingPreflights(): void
|
|
2416
|
-
cancelPendingPreflightsForConnection(connectionId: string): void
|
|
2480
|
+
cancelPendingPreflights(): Promise<void>;
|
|
2481
|
+
cancelPendingPreflightsForConnection(connectionId: string): Promise<void>;
|
|
2417
2482
|
} {
|
|
2418
2483
|
const unavailable = (operation: string, reason: string) => () => {
|
|
2419
2484
|
throw Object.assign(new Error(`${operation} is unavailable: ${reason}`), { code: "unavailable" });
|
|
@@ -2454,11 +2519,29 @@ function sdkControlSurface(
|
|
|
2454
2519
|
}
|
|
2455
2520
|
return "unknown";
|
|
2456
2521
|
};
|
|
2457
|
-
const sendSteer = async (text: string) => {
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2522
|
+
const sendSteer = async (text: string, clientRef?: string) => {
|
|
2523
|
+
if (clientRef === undefined) {
|
|
2524
|
+
const correlation = { commandId: crypto.randomUUID(), turnId: crypto.randomUUID() };
|
|
2525
|
+
await api.sendUserMessage(text, { deliverAs: "steer" });
|
|
2526
|
+
return { ...correlation, accepted: true };
|
|
2527
|
+
}
|
|
2528
|
+
const normalizedClientRef = clientRef.trim();
|
|
2529
|
+
if (!skillRecon?.reserveSteer || !skillRecon.settleSteer)
|
|
2530
|
+
throw Object.assign(new Error("Steer reconciliation is unavailable."), { code: "unavailable" });
|
|
2531
|
+
const reservation = await skillRecon.reserveSteer(normalizedClientRef, text);
|
|
2532
|
+
if (reservation.replay) return { sessionId: ctx.sessionManager.getSessionId(), ...reservation.result };
|
|
2533
|
+
try {
|
|
2534
|
+
await api.sendUserMessage(text, { deliverAs: "steer" });
|
|
2535
|
+
return {
|
|
2536
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
2537
|
+
...(await skillRecon.settleSteer(normalizedClientRef, "accepted")),
|
|
2538
|
+
};
|
|
2539
|
+
} catch (error) {
|
|
2540
|
+
return {
|
|
2541
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
2542
|
+
...(await skillRecon.settleSteer(normalizedClientRef, "rejected", error)),
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2462
2545
|
};
|
|
2463
2546
|
const resolveModel = (id: string) => {
|
|
2464
2547
|
const [provider, ...modelId] = id.split("/");
|
|
@@ -2525,19 +2608,22 @@ function sdkControlSurface(
|
|
|
2525
2608
|
return unavailable(operation, "no typed session seam is installed")();
|
|
2526
2609
|
return ctx.sdkControl(operation, input);
|
|
2527
2610
|
};
|
|
2528
|
-
const pendingPreflightCancellations = new Map<string, { connectionId?: string; cancel: () => void }>();
|
|
2611
|
+
const pendingPreflightCancellations = new Map<string, { connectionId?: string; cancel: () => Promise<void> }>();
|
|
2529
2612
|
const preflightKey = (connectionId: string | undefined, correlation: { commandId: string; turnId: string }) =>
|
|
2530
2613
|
`${connectionId ?? ""}\0${correlation.commandId}\0${correlation.turnId}`;
|
|
2531
|
-
const cancelPendingPreflights = () => {
|
|
2532
|
-
|
|
2614
|
+
const cancelPendingPreflights = async () => {
|
|
2615
|
+
await Promise.all([...pendingPreflightCancellations.values()].map(async entry => await entry.cancel()));
|
|
2533
2616
|
};
|
|
2534
|
-
const cancelPendingPreflightsForConnection = (connectionId: string) => {
|
|
2535
|
-
|
|
2536
|
-
|
|
2617
|
+
const cancelPendingPreflightsForConnection = async (connectionId: string) => {
|
|
2618
|
+
await Promise.all(
|
|
2619
|
+
[...pendingPreflightCancellations.values()]
|
|
2620
|
+
.filter(entry => entry.connectionId === connectionId)
|
|
2621
|
+
.map(async entry => await entry.cancel()),
|
|
2622
|
+
);
|
|
2537
2623
|
};
|
|
2538
2624
|
const isSessionBusy = () => isBusy() || ctx.isIdle?.() === false;
|
|
2539
2625
|
const awaitAbortReady = async () => {
|
|
2540
|
-
cancelPendingPreflights();
|
|
2626
|
+
await cancelPendingPreflights();
|
|
2541
2627
|
await (ctx.abort as () => unknown)();
|
|
2542
2628
|
while (isSessionBusy()) {
|
|
2543
2629
|
await Bun.sleep(10);
|
|
@@ -2602,39 +2688,71 @@ function sdkControlSurface(
|
|
|
2602
2688
|
: text;
|
|
2603
2689
|
const commandId = crypto.randomUUID();
|
|
2604
2690
|
const turnId = crypto.randomUUID();
|
|
2691
|
+
const sdkRunToken = deliverAs === "followUp" ? crypto.randomUUID() : undefined;
|
|
2605
2692
|
type PreflightTerminalResult = { status: "accepted" } | { status: "rejected"; error: unknown };
|
|
2606
2693
|
const preflight = Promise.withResolvers<PreflightTerminalResult>();
|
|
2694
|
+
const preflightController = new AbortController();
|
|
2695
|
+
const cancellationError = Object.assign(new Error("Prompt preflight was cancelled before execution."), {
|
|
2696
|
+
code: "busy",
|
|
2697
|
+
});
|
|
2607
2698
|
let preflightSettled = false;
|
|
2699
|
+
let accepting = false;
|
|
2608
2700
|
let accepted = false;
|
|
2701
|
+
let submission: Promise<void> | undefined;
|
|
2702
|
+
const submissionSettled = Promise.withResolvers<void>();
|
|
2703
|
+
let cancellation: Promise<void> | undefined;
|
|
2609
2704
|
const correlation = { commandId, turnId };
|
|
2705
|
+
const key = preflightKey(requesterConnectionId, correlation);
|
|
2610
2706
|
const settlePreflight = (result: PreflightTerminalResult) => {
|
|
2611
2707
|
if (preflightSettled) return;
|
|
2612
2708
|
preflightSettled = true;
|
|
2613
2709
|
preflight.resolve(result);
|
|
2614
2710
|
};
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2711
|
+
// This is retained by the accepted durable record. It settles only this
|
|
2712
|
+
// submission; terminal authority remains with the pending cancellation owner.
|
|
2713
|
+
const settleSubmission = async () => {
|
|
2714
|
+
preflightController.abort();
|
|
2715
|
+
await submissionSettled.promise;
|
|
2716
|
+
};
|
|
2717
|
+
const cancelPreflight = () => {
|
|
2718
|
+
cancellation ??= (async () => {
|
|
2719
|
+
preflightController.abort();
|
|
2720
|
+
if (!accepting) settlePreflight({ status: "rejected", error: cancellationError });
|
|
2721
|
+
const ownedCancellation = await abortOwnedPrompt(requesterConnectionId);
|
|
2722
|
+
if (ownedCancellation.disposition === "idle") await settleSubmission();
|
|
2723
|
+
})();
|
|
2724
|
+
return cancellation;
|
|
2725
|
+
};
|
|
2726
|
+
pendingPreflightCancellations.set(key, {
|
|
2621
2727
|
connectionId: requesterConnectionId,
|
|
2622
2728
|
cancel: cancelPreflight,
|
|
2623
2729
|
});
|
|
2624
2730
|
const settleAccepted = async () => {
|
|
2625
2731
|
if (preflightSettled) return;
|
|
2626
|
-
|
|
2732
|
+
accepting = true;
|
|
2627
2733
|
try {
|
|
2628
|
-
await onPromptAccepted(
|
|
2734
|
+
await onPromptAccepted(
|
|
2735
|
+
correlation,
|
|
2736
|
+
requesterConnectionId,
|
|
2737
|
+
trimmedClientRef,
|
|
2738
|
+
trackReconciliation,
|
|
2739
|
+
settleSubmission,
|
|
2740
|
+
"prompt",
|
|
2741
|
+
sdkRunToken,
|
|
2742
|
+
);
|
|
2629
2743
|
} catch (error) {
|
|
2744
|
+
accepting = false;
|
|
2630
2745
|
// Durable acceptance failed, so the prompt was never accepted: reject the
|
|
2631
2746
|
// control preflight and rethrow so the awaiting session does not execute it.
|
|
2632
|
-
accepted = false;
|
|
2633
2747
|
onPromptAcceptFailed(correlation);
|
|
2634
2748
|
settlePreflight({ status: "rejected", error });
|
|
2635
2749
|
throw error;
|
|
2636
2750
|
}
|
|
2751
|
+
accepting = false;
|
|
2752
|
+
accepted = true;
|
|
2753
|
+
pendingPreflightCancellations.delete(key);
|
|
2637
2754
|
settlePreflight({ status: "accepted" });
|
|
2755
|
+
if (preflightController.signal.aborted) throw cancellationError;
|
|
2638
2756
|
};
|
|
2639
2757
|
// Durable fence preferred; keep legacy onPreflightAccepted for hosts/tests that only fire the sync hook.
|
|
2640
2758
|
const onPreflightAcceptCommit = settleAccepted;
|
|
@@ -2645,17 +2763,19 @@ function sdkControlSurface(
|
|
|
2645
2763
|
};
|
|
2646
2764
|
// Do not acknowledge the prompt until AgentSession's async preflight
|
|
2647
2765
|
// succeeds. The terminal result records correlation before agent_start can fire.
|
|
2648
|
-
let submission: Promise<void> | undefined;
|
|
2649
2766
|
try {
|
|
2650
2767
|
submission = Promise.resolve(
|
|
2651
2768
|
api.sendUserMessage(content, {
|
|
2652
2769
|
...(deliverAs ? { deliverAs } : !forceFresh && isBusy() ? { deliverAs: "steer" as const } : {}),
|
|
2653
2770
|
onPreflightAcceptCommit,
|
|
2654
2771
|
onPreflightAccepted,
|
|
2772
|
+
preflightSignal: preflightController.signal,
|
|
2773
|
+
...(sdkRunToken ? { sdkRunToken } : {}),
|
|
2655
2774
|
}),
|
|
2656
2775
|
);
|
|
2657
2776
|
} catch (error) {
|
|
2658
|
-
|
|
2777
|
+
submissionSettled.resolve();
|
|
2778
|
+
if (accepted && !preflightController.signal.aborted) void onPromptFailed(correlation, error);
|
|
2659
2779
|
else settlePreflight({ status: "rejected", error });
|
|
2660
2780
|
}
|
|
2661
2781
|
if (submission) {
|
|
@@ -2668,10 +2788,12 @@ function sdkControlSurface(
|
|
|
2668
2788
|
code: "busy",
|
|
2669
2789
|
}),
|
|
2670
2790
|
});
|
|
2791
|
+
submissionSettled.resolve();
|
|
2671
2792
|
},
|
|
2672
2793
|
error => {
|
|
2673
|
-
if (accepted) onPromptFailed(correlation, error);
|
|
2794
|
+
if (accepted && !preflightController.signal.aborted) void onPromptFailed(correlation, error);
|
|
2674
2795
|
else settlePreflight({ status: "rejected", error });
|
|
2796
|
+
submissionSettled.resolve();
|
|
2675
2797
|
},
|
|
2676
2798
|
);
|
|
2677
2799
|
}
|
|
@@ -2683,19 +2805,16 @@ function sdkControlSurface(
|
|
|
2683
2805
|
if (trackReconciliation) releasePromptAdmission(trimmedClientRef);
|
|
2684
2806
|
throw error;
|
|
2685
2807
|
} finally {
|
|
2686
|
-
pendingPreflightCancellations.delete(
|
|
2808
|
+
pendingPreflightCancellations.delete(key);
|
|
2687
2809
|
}
|
|
2688
2810
|
};
|
|
2689
2811
|
const surface: ControlSurface & {
|
|
2690
|
-
cancelPendingPreflights(): void
|
|
2691
|
-
cancelPendingPreflightsForConnection(connectionId: string): void
|
|
2812
|
+
cancelPendingPreflights(): Promise<void>;
|
|
2813
|
+
cancelPendingPreflightsForConnection(connectionId: string): Promise<void>;
|
|
2692
2814
|
} = {
|
|
2693
|
-
authorizeElevationClaim: (sdkId, input, capability) =>
|
|
2694
|
-
elevationAuthorityToken !== undefined &&
|
|
2695
|
-
verifyElevationCapability(elevationAuthorityToken, capability, sdkId, input),
|
|
2696
2815
|
prompt: (text, images, clientRef) =>
|
|
2697
2816
|
submitPrompt(text, images, false, undefined, true, controlRequesterContext.getStore(), clientRef, true),
|
|
2698
|
-
steer: text => sendSteer(text),
|
|
2817
|
+
steer: (text, clientRef) => sendSteer(text, clientRef),
|
|
2699
2818
|
followUp: text => submitPrompt(text, undefined, false, "followUp", false, controlRequesterContext.getStore()),
|
|
2700
2819
|
abort: async () => {
|
|
2701
2820
|
const requesterConnectionId = controlRequesterContext.getStore();
|
|
@@ -2703,12 +2822,193 @@ function sdkControlSurface(
|
|
|
2703
2822
|
entry => entry.connectionId === requesterConnectionId,
|
|
2704
2823
|
);
|
|
2705
2824
|
if (pendingPreflight) {
|
|
2706
|
-
|
|
2707
|
-
|
|
2825
|
+
// Fire-and-forget the cancel: the preflight signal is aborted
|
|
2826
|
+
// immediately (synchronously inside cancelPreflight), but the
|
|
2827
|
+
// full cleanup (settleRun awaiting executionSettled) may be
|
|
2828
|
+
// gated by a durable acceptance commit that this same abort
|
|
2829
|
+
// path fences. Awaiting it would deadlock. The response is
|
|
2830
|
+
// published once the pending writes settle.
|
|
2831
|
+
if (requesterConnectionId) void cancelPendingPreflightsForConnection(requesterConnectionId);
|
|
2832
|
+
else void cancelPendingPreflights();
|
|
2708
2833
|
return { aborted: true, disposition: "preflight_cancelled" };
|
|
2709
2834
|
}
|
|
2710
2835
|
return await abortOwnedPrompt(requesterConnectionId);
|
|
2711
2836
|
},
|
|
2837
|
+
abortTerminal: async (input, idempotencyKey) => {
|
|
2838
|
+
// Terminal abort (C04 mode:"terminal", approved plan): stop the root
|
|
2839
|
+
// worker's current turn and block only its own continuation routes.
|
|
2840
|
+
// Left-running owned work (background Bash/task jobs, detached
|
|
2841
|
+
// subagents) keeps running and its completions are delivered normally
|
|
2842
|
+
// through the existing followUp/prompt path as a fresh turn — owned
|
|
2843
|
+
// delivery is intentionally NOT suppressed.
|
|
2844
|
+
const requesterConnectionId = controlRequesterContext.getStore();
|
|
2845
|
+
// Capture the requester's preflight entries AT ADMISSION: a successor
|
|
2846
|
+
// prompt pipelined by the same connection while the abort awaits must
|
|
2847
|
+
// never be cancelled as part of this abort (review thread P1).
|
|
2848
|
+
const capturedRequesterPreflights = requesterConnectionId
|
|
2849
|
+
? [...pendingPreflightCancellations.values()].filter(entry => entry.connectionId === requesterConnectionId)
|
|
2850
|
+
: [...pendingPreflightCancellations.values()].filter(entry => entry.connectionId === undefined);
|
|
2851
|
+
const cancelCapturedPreflights = () => {
|
|
2852
|
+
for (const entry of capturedRequesterPreflights) entry.cancel();
|
|
2853
|
+
};
|
|
2854
|
+
// Preflight cancellation happens INSIDE abortTerminalPrompt, AFTER the
|
|
2855
|
+
// durable admission/replay decision: a no-store request or a same-key
|
|
2856
|
+
// replay/conflict must NOT cancel a pending prompt — only a newly
|
|
2857
|
+
// admitted abort may (review thread P2).
|
|
2858
|
+
const scope: AbortScope = input.scope === "owned" ? "owned" : "turn";
|
|
2859
|
+
const outcome = await abortTerminalPrompt(
|
|
2860
|
+
requesterConnectionId,
|
|
2861
|
+
scope,
|
|
2862
|
+
idempotencyKey,
|
|
2863
|
+
{
|
|
2864
|
+
hasPending: () => capturedRequesterPreflights.length > 0,
|
|
2865
|
+
cancel: cancelCapturedPreflights,
|
|
2866
|
+
},
|
|
2867
|
+
// The seam cancels the SESSION-WIDE preflight controller; the
|
|
2868
|
+
// internal per-connection map lets the queued requester's abort
|
|
2869
|
+
// verify no OTHER connection has a pending admission before
|
|
2870
|
+
// invoking it (review thread P1).
|
|
2871
|
+
() =>
|
|
2872
|
+
![...pendingPreflightCancellations.values()].some(entry => entry.connectionId !== requesterConnectionId),
|
|
2873
|
+
);
|
|
2874
|
+
// Preflight cancellation happens ONLY for a NEWLY ADMITTED abort,
|
|
2875
|
+
// after the durable admission/replay decision inside
|
|
2876
|
+
// abortTerminalPrompt: a no-store request or a same-key
|
|
2877
|
+
// replay/conflict must never cancel a pending prompt (review
|
|
2878
|
+
// thread P2). A turn.prompt still in PREFLIGHT has no
|
|
2879
|
+
// promptSubmissions entry, so the new no-active abort cancels the
|
|
2880
|
+
// connection's pending preflights and invalidates the underlying
|
|
2881
|
+
// session preflight — otherwise the prompt could start after this
|
|
2882
|
+
// abort.
|
|
2883
|
+
// Treat ANY outcome carrying the durable replay marker as a
|
|
2884
|
+
// non-admission: after the in-memory dispatch entry expires or a
|
|
2885
|
+
// restart, a SUCCESSFUL replay returns stopped/stopped_owned with
|
|
2886
|
+
// `stored`, and cancelling the requester's unrelated in-preflight
|
|
2887
|
+
// prompt there would give an idempotency replay real effects (review
|
|
2888
|
+
// thread P1). Only a newly admitted abort may cancel preflights.
|
|
2889
|
+
const outcomeIsNewAdmission =
|
|
2890
|
+
outcome.ok &&
|
|
2891
|
+
outcome.stored === undefined &&
|
|
2892
|
+
outcome.outcome !== "no_store" &&
|
|
2893
|
+
outcome.outcome !== "no_effect";
|
|
2894
|
+
if (outcomeIsNewAdmission && capturedRequesterPreflights.length > 0) {
|
|
2895
|
+
// Cancel ONLY the preflight entries captured when the abort was
|
|
2896
|
+
// admitted: a successor prompt pipelined by the same connection
|
|
2897
|
+
// while the abort was completing must never be cancelled as part of
|
|
2898
|
+
// this abort (review thread P1). The session seam stays
|
|
2899
|
+
// requester-gated so an unrelated connection's prompt is not
|
|
2900
|
+
// failed: it cancels the SESSION-WIDE preflight controller, so it
|
|
2901
|
+
// is invoked only when no OTHER connection has a pending admission
|
|
2902
|
+
// (review thread P1).
|
|
2903
|
+
cancelCapturedPreflights();
|
|
2904
|
+
const otherConnectionPreflights = [...pendingPreflightCancellations.values()].some(
|
|
2905
|
+
entry => entry.connectionId !== requesterConnectionId,
|
|
2906
|
+
);
|
|
2907
|
+
if (!otherConnectionPreflights) terminalAbortSeams?.cancelPendingPreflightForTerminalAbort?.();
|
|
2908
|
+
}
|
|
2909
|
+
if (!outcome.ok && outcome.reason === "conflict") {
|
|
2910
|
+
// Throw a typed control error instead of returning a nested result
|
|
2911
|
+
// so dispatchControl produces a TOP-LEVEL ok:false response with
|
|
2912
|
+
// code idempotency_conflict (the generic cache does the same for
|
|
2913
|
+
// in-cache conflicts; this path covers the evicted/restart case).
|
|
2914
|
+
throw new TypedControlError("idempotency_conflict", "Idempotency key was reused with different input.");
|
|
2915
|
+
}
|
|
2916
|
+
if (!outcome.ok) {
|
|
2917
|
+
return {
|
|
2918
|
+
ok: true,
|
|
2919
|
+
selection: scope,
|
|
2920
|
+
turn: "uncertain",
|
|
2921
|
+
ownedWork: scope === "turn" ? "left_running" : "uncertain",
|
|
2922
|
+
automaticDelivery: scope === "turn" ? "enabled" : "none",
|
|
2923
|
+
resumeOnOwnedCompletion: scope === "turn",
|
|
2924
|
+
reason: outcome.reason,
|
|
2925
|
+
};
|
|
2926
|
+
}
|
|
2927
|
+
if (outcome.outcome === "no_active_turn" || outcome.outcome === "already_terminal") {
|
|
2928
|
+
// No active root turn to stop: process-local no-effect, no fence.
|
|
2929
|
+
return {
|
|
2930
|
+
ok: true,
|
|
2931
|
+
selection: scope,
|
|
2932
|
+
turn: "no_active_turn",
|
|
2933
|
+
terminal: "terminal_no_effect",
|
|
2934
|
+
...(outcome.stored ? { replay: outcome.stored } : {}),
|
|
2935
|
+
};
|
|
2936
|
+
}
|
|
2937
|
+
if (outcome.outcome === "no_store") {
|
|
2938
|
+
// No file-backed reconciliation owner: terminal admission is gated
|
|
2939
|
+
// off before any fence/stop/cleanup (plan AC 5).
|
|
2940
|
+
return {
|
|
2941
|
+
ok: true,
|
|
2942
|
+
selection: scope,
|
|
2943
|
+
turn: "no_store",
|
|
2944
|
+
terminal: "terminal_no_effect",
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
if (outcome.outcome === "no_effect") {
|
|
2948
|
+
// Initial marker could not be persisted before any destructive work
|
|
2949
|
+
// (AC 10): process-local no-effect, no fence, no stop. A
|
|
2950
|
+
// marker-failure reservation replays through this same outcome, so
|
|
2951
|
+
// one idempotency key always returns the same result after
|
|
2952
|
+
// eviction/restart.
|
|
2953
|
+
return {
|
|
2954
|
+
ok: true,
|
|
2955
|
+
selection: scope,
|
|
2956
|
+
turn: "no_effect",
|
|
2957
|
+
terminal: "terminal_no_effect",
|
|
2958
|
+
...(outcome.stored ? { replay: outcome.stored } : {}),
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
if (outcome.outcome === "no_effect_replay") {
|
|
2962
|
+
// Durable idle/already-terminal reservation replayed: exact
|
|
2963
|
+
// no_active_turn, so a same-key retry after eviction/restart never
|
|
2964
|
+
// aborts an unrelated later turn.
|
|
2965
|
+
return {
|
|
2966
|
+
ok: true,
|
|
2967
|
+
selection: scope,
|
|
2968
|
+
turn: "no_active_turn",
|
|
2969
|
+
terminal: "terminal_no_effect",
|
|
2970
|
+
...(outcome.stored ? { replay: outcome.stored } : {}),
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
if (outcome.outcome === "pending_replay" || outcome.outcome === "uncertain_replay") {
|
|
2974
|
+
// A crashed or restart-settled attempt left a non-stopped durable
|
|
2975
|
+
// marker (AC 4/41): replay safe uncertainty without re-running the
|
|
2976
|
+
// stop/cleanup/event, carrying the stored immutable row.
|
|
2977
|
+
return {
|
|
2978
|
+
ok: true,
|
|
2979
|
+
selection: scope,
|
|
2980
|
+
turn: "uncertain",
|
|
2981
|
+
ownedWork: scope === "turn" ? "left_running" : "uncertain",
|
|
2982
|
+
automaticDelivery: scope === "turn" ? "enabled" : "none",
|
|
2983
|
+
resumeOnOwnedCompletion: scope === "turn",
|
|
2984
|
+
reason: outcome.outcome === "pending_replay" ? "replay_pending" : "replay_uncertain",
|
|
2985
|
+
...(outcome.stored ? { replay: outcome.stored } : {}),
|
|
2986
|
+
};
|
|
2987
|
+
}
|
|
2988
|
+
if (outcome.outcome === "stopped_owned") {
|
|
2989
|
+
// scope:"owned" stopped the exact captured owned work and proved
|
|
2990
|
+
// quiescence (every captured generation/entry terminal); stopped
|
|
2991
|
+
// work can never resume the agent.
|
|
2992
|
+
return {
|
|
2993
|
+
ok: true,
|
|
2994
|
+
selection: "owned",
|
|
2995
|
+
turn: "stopped",
|
|
2996
|
+
ownedWork: "stopped",
|
|
2997
|
+
automaticDelivery: "none",
|
|
2998
|
+
resumeOnOwnedCompletion: false,
|
|
2999
|
+
...(outcome.stored ? { replay: outcome.stored } : {}),
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
return {
|
|
3003
|
+
ok: true,
|
|
3004
|
+
selection: "turn",
|
|
3005
|
+
turn: "stopped",
|
|
3006
|
+
ownedWork: "left_running",
|
|
3007
|
+
automaticDelivery: "enabled",
|
|
3008
|
+
resumeOnOwnedCompletion: true,
|
|
3009
|
+
...(outcome.stored ? { replay: outcome.stored } : {}),
|
|
3010
|
+
};
|
|
3011
|
+
},
|
|
2712
3012
|
abortAndPrompt: async text => {
|
|
2713
3013
|
await awaitAbortReady();
|
|
2714
3014
|
return await submitPrompt(text, undefined, true, undefined, false, controlRequesterContext.getStore());
|
|
@@ -2727,7 +3027,7 @@ function sdkControlSurface(
|
|
|
2727
3027
|
pending.completeDirect();
|
|
2728
3028
|
return { resolved: true };
|
|
2729
3029
|
},
|
|
2730
|
-
answerGate: async (id, response, expectedSessionId, idempotencyKey
|
|
3030
|
+
answerGate: async (id, response, expectedSessionId, idempotencyKey) => {
|
|
2731
3031
|
if (!acceptGateResolution())
|
|
2732
3032
|
throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
|
|
2733
3033
|
if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.gate_answer");
|
|
@@ -2802,7 +3102,7 @@ function sdkControlSurface(
|
|
|
2802
3102
|
code: "terminal_uncertain",
|
|
2803
3103
|
});
|
|
2804
3104
|
},
|
|
2805
|
-
approvePlan: async (id, choice, expectedSessionId
|
|
3105
|
+
approvePlan: async (id, choice, expectedSessionId) => {
|
|
2806
3106
|
if (!acceptGateResolution())
|
|
2807
3107
|
throw Object.assign(new Error("Workflow plan is no longer answerable."), { code: "resource_gone" });
|
|
2808
3108
|
if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.plan_approve");
|
|
@@ -2888,6 +3188,7 @@ function sdkControlSurface(
|
|
|
2888
3188
|
const commandId = crypto.randomUUID();
|
|
2889
3189
|
const turnId = crypto.randomUUID();
|
|
2890
3190
|
const correlation = { commandId, turnId };
|
|
3191
|
+
const requesterConnectionId = controlRequesterContext.getStore();
|
|
2891
3192
|
if (skillRecon) {
|
|
2892
3193
|
try {
|
|
2893
3194
|
await awaitReconciliationReady();
|
|
@@ -2898,44 +3199,116 @@ function sdkControlSurface(
|
|
|
2898
3199
|
}
|
|
2899
3200
|
skillRecon.admit(trimmedClientRef);
|
|
2900
3201
|
}
|
|
3202
|
+
const cancellationError = Object.assign(new Error("Skill preflight was cancelled before execution."), {
|
|
3203
|
+
code: "busy",
|
|
3204
|
+
});
|
|
3205
|
+
const preflightController = new AbortController();
|
|
2901
3206
|
const { promise: acceptedP, resolve, reject } = Promise.withResolvers<Record<string, unknown>>();
|
|
2902
|
-
let phase: "pending" | "accepted" | "rejected" = "pending";
|
|
3207
|
+
let phase: "pending" | "accepting" | "accepted" | "rejected" = "pending";
|
|
3208
|
+
let promptOwned = false;
|
|
3209
|
+
let durableSkillAccepted = false;
|
|
3210
|
+
let admissionReleased = false;
|
|
3211
|
+
const releaseAdmission = () => {
|
|
3212
|
+
if (admissionReleased || durableSkillAccepted) return;
|
|
3213
|
+
admissionReleased = true;
|
|
3214
|
+
skillRecon?.release(trimmedClientRef);
|
|
3215
|
+
};
|
|
2903
3216
|
const settleAccept = (value: Record<string, unknown>) => {
|
|
2904
|
-
if (phase !== "pending") return;
|
|
3217
|
+
if (phase !== "pending" && phase !== "accepting") return;
|
|
2905
3218
|
phase = "accepted";
|
|
2906
3219
|
resolve(value);
|
|
2907
3220
|
};
|
|
2908
3221
|
const settleReject = (error: unknown) => {
|
|
2909
|
-
if (phase
|
|
3222
|
+
if (phase === "accepted" || phase === "rejected") return;
|
|
2910
3223
|
phase = "rejected";
|
|
2911
3224
|
reject(error);
|
|
2912
3225
|
};
|
|
3226
|
+
const executionSettled = Promise.withResolvers<void>();
|
|
3227
|
+
let cancellation: Promise<void> | undefined;
|
|
3228
|
+
const key = preflightKey(requesterConnectionId, correlation);
|
|
3229
|
+
// This is retained by the accepted durable record. It settles only this
|
|
3230
|
+
// invocation; terminal authority remains with the pending cancellation owner.
|
|
3231
|
+
const settleRun = async () => {
|
|
3232
|
+
preflightController.abort();
|
|
3233
|
+
await executionSettled.promise;
|
|
3234
|
+
};
|
|
3235
|
+
const cancelPreflight = () => {
|
|
3236
|
+
cancellation ??= (async () => {
|
|
3237
|
+
preflightController.abort();
|
|
3238
|
+
if (phase === "pending") {
|
|
3239
|
+
releaseAdmission();
|
|
3240
|
+
settleReject(cancellationError);
|
|
3241
|
+
}
|
|
3242
|
+
const ownedCancellation = await abortOwnedPrompt(requesterConnectionId);
|
|
3243
|
+
if (ownedCancellation.disposition === "idle") {
|
|
3244
|
+
await settleRun();
|
|
3245
|
+
if (durableSkillAccepted && skillRecon) await skillRecon.cancel(correlation);
|
|
3246
|
+
}
|
|
3247
|
+
})();
|
|
3248
|
+
return cancellation;
|
|
3249
|
+
};
|
|
3250
|
+
pendingPreflightCancellations.set(key, {
|
|
3251
|
+
connectionId: requesterConnectionId,
|
|
3252
|
+
cancel: cancelPreflight,
|
|
3253
|
+
});
|
|
2913
3254
|
let prepared: { name: string; path: string; lineCount?: number; cleanedArgs?: string } | undefined;
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
3255
|
+
let run: Promise<unknown>;
|
|
3256
|
+
try {
|
|
3257
|
+
run = Promise.resolve(
|
|
3258
|
+
ctx.invokeSkill(name, args as string | undefined, {
|
|
3259
|
+
preflightSignal: preflightController.signal,
|
|
3260
|
+
onSkillPrepared: meta => {
|
|
3261
|
+
prepared = meta;
|
|
3262
|
+
},
|
|
3263
|
+
onPreflightAcceptCommit: async () => {
|
|
3264
|
+
if (preflightController.signal.aborted) throw cancellationError;
|
|
3265
|
+
phase = "accepting";
|
|
3266
|
+
const meta = prepared ?? { name: String(name), path: "" };
|
|
3267
|
+
const acceptedValue = {
|
|
3268
|
+
accepted: true,
|
|
3269
|
+
commandId,
|
|
3270
|
+
turnId,
|
|
3271
|
+
name: meta.name,
|
|
3272
|
+
path: meta.path,
|
|
3273
|
+
...(meta.lineCount !== undefined ? { lineCount: meta.lineCount } : {}),
|
|
3274
|
+
...(meta.cleanedArgs !== undefined ? { args: meta.cleanedArgs } : {}),
|
|
3275
|
+
...(trimmedClientRef ? { clientRef: trimmedClientRef } : {}),
|
|
3276
|
+
};
|
|
3277
|
+
try {
|
|
3278
|
+
await onPromptAccepted(
|
|
3279
|
+
correlation,
|
|
3280
|
+
requesterConnectionId,
|
|
3281
|
+
undefined,
|
|
3282
|
+
false,
|
|
3283
|
+
settleRun,
|
|
3284
|
+
"skill",
|
|
3285
|
+
);
|
|
3286
|
+
promptOwned = requesterConnectionId !== undefined;
|
|
3287
|
+
if (skillRecon) {
|
|
3288
|
+
await skillRecon.noteAccepted(correlation, trimmedClientRef, { skillName: meta.name });
|
|
3289
|
+
durableSkillAccepted = true;
|
|
3290
|
+
}
|
|
3291
|
+
} catch (error) {
|
|
3292
|
+
onPromptAcceptFailed(correlation);
|
|
3293
|
+
releaseAdmission();
|
|
3294
|
+
settleReject(error);
|
|
3295
|
+
throw error;
|
|
3296
|
+
}
|
|
3297
|
+
pendingPreflightCancellations.delete(key);
|
|
3298
|
+
settleAccept(acceptedValue);
|
|
3299
|
+
if (preflightController.signal.aborted) throw cancellationError;
|
|
3300
|
+
},
|
|
3301
|
+
}),
|
|
3302
|
+
);
|
|
3303
|
+
} catch (error) {
|
|
3304
|
+
releaseAdmission();
|
|
3305
|
+
settleReject(error);
|
|
3306
|
+
run = Promise.reject(error);
|
|
3307
|
+
}
|
|
3308
|
+
void run.then(
|
|
2936
3309
|
result => {
|
|
2937
3310
|
if (phase === "pending") {
|
|
2938
|
-
|
|
3311
|
+
pendingPreflightCancellations.delete(key);
|
|
2939
3312
|
settleAccept({
|
|
2940
3313
|
accepted: true,
|
|
2941
3314
|
commandId,
|
|
@@ -2943,7 +3316,7 @@ function sdkControlSurface(
|
|
|
2943
3316
|
...(typeof result === "object" && result ? (result as object) : {}),
|
|
2944
3317
|
...(trimmedClientRef ? { clientRef: trimmedClientRef } : {}),
|
|
2945
3318
|
});
|
|
2946
|
-
} else if (skillRecon) {
|
|
3319
|
+
} else if (durableSkillAccepted && skillRecon) {
|
|
2947
3320
|
void skillRecon.noteTransition(correlation, {
|
|
2948
3321
|
type: "agent_end",
|
|
2949
3322
|
...(typeof result === "string"
|
|
@@ -2951,20 +3324,25 @@ function sdkControlSurface(
|
|
|
2951
3324
|
: {}),
|
|
2952
3325
|
});
|
|
2953
3326
|
}
|
|
2954
|
-
|
|
3327
|
+
executionSettled.resolve();
|
|
2955
3328
|
},
|
|
2956
3329
|
error => {
|
|
2957
|
-
if (phase === "pending") {
|
|
2958
|
-
|
|
3330
|
+
if (phase === "pending" || phase === "accepting") {
|
|
3331
|
+
releaseAdmission();
|
|
2959
3332
|
settleReject(error);
|
|
2960
|
-
} else if (
|
|
2961
|
-
void
|
|
3333
|
+
} else if (phase === "accepted" && promptOwned && !preflightController.signal.aborted) {
|
|
3334
|
+
void onPromptFailed(correlation, error);
|
|
2962
3335
|
}
|
|
2963
|
-
|
|
3336
|
+
if (durableSkillAccepted && skillRecon && !preflightController.signal.aborted)
|
|
3337
|
+
void skillRecon.noteTransition(correlation, { type: "agent_failed", error });
|
|
3338
|
+
executionSettled.resolve();
|
|
2964
3339
|
},
|
|
2965
3340
|
);
|
|
2966
|
-
|
|
2967
|
-
|
|
3341
|
+
try {
|
|
3342
|
+
return await acceptedP;
|
|
3343
|
+
} finally {
|
|
3344
|
+
pendingPreflightCancellations.delete(key);
|
|
3345
|
+
}
|
|
2968
3346
|
},
|
|
2969
3347
|
setPlanMode: async on => {
|
|
2970
3348
|
if (!bindings.has("setPlanMode") || !ctx.setPlanMode)
|
|
@@ -3074,7 +3452,11 @@ function sdkControlSurface(
|
|
|
3074
3452
|
newSession: () => typed("session.new"),
|
|
3075
3453
|
forkSession: () => typed("session.fork"),
|
|
3076
3454
|
resumeSession: id => typed("session.resume", { id }),
|
|
3077
|
-
closeSession:
|
|
3455
|
+
closeSession: capability =>
|
|
3456
|
+
typed(
|
|
3457
|
+
"session.close",
|
|
3458
|
+
capability === undefined ? {} : { [BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD]: capability },
|
|
3459
|
+
),
|
|
3078
3460
|
switchSession: id => typed("session.switch", { id }),
|
|
3079
3461
|
branchSession: entryId => typed("session.branch", { entryId }),
|
|
3080
3462
|
renameSession: name => typed("session.rename", { name }),
|
|
@@ -3528,9 +3910,13 @@ function sdkInboundFrame(commandJson: string | undefined): Record<string, unknow
|
|
|
3528
3910
|
}
|
|
3529
3911
|
}
|
|
3530
3912
|
/**
|
|
3531
|
-
* Ensures every configured chat-provider daemon is ready
|
|
3532
|
-
*
|
|
3533
|
-
*
|
|
3913
|
+
* Ensures every configured chat-provider daemon is ready.
|
|
3914
|
+
*
|
|
3915
|
+
* This runs strictly AFTER the SDK publishes session identity and its core
|
|
3916
|
+
* endpoint, through the detached ownership coordinator: chat providers are
|
|
3917
|
+
* optional notification adapters, never session authority. A rejected ensure
|
|
3918
|
+
* therefore degrades notification delivery only — the coordinator records it
|
|
3919
|
+
* as `failed`, adapters stay withheld, and a later reconcile re-attempts.
|
|
3534
3920
|
*/
|
|
3535
3921
|
export async function ensureConfiguredProviderDaemons(
|
|
3536
3922
|
settings: Settings,
|
|
@@ -3560,12 +3946,7 @@ export function createNotificationsExtension(
|
|
|
3560
3946
|
api: ExtensionAPI,
|
|
3561
3947
|
options: {
|
|
3562
3948
|
settings?: Settings;
|
|
3563
|
-
ensureTelegramDaemon?: (input: {
|
|
3564
|
-
settings: Settings;
|
|
3565
|
-
cwd: string;
|
|
3566
|
-
sessionId: string;
|
|
3567
|
-
onRegistered?: (registration: RegisterNotificationRootResult) => void;
|
|
3568
|
-
}) => Promise<EnsureDaemonResult>;
|
|
3949
|
+
ensureTelegramDaemon?: (input: { settings: Settings }) => Promise<EnsureDaemonResult>;
|
|
3569
3950
|
ensureProviderDaemon?: (provider: "discord" | "slack", settings: Settings) => Promise<unknown>;
|
|
3570
3951
|
/** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
|
|
3571
3952
|
spawnedByGjc?: boolean;
|
|
@@ -3579,9 +3960,34 @@ export function createNotificationsExtension(
|
|
|
3579
3960
|
onBranchStartupSettled?: (receipt: { sessionId: string; status: SessionStartResult["status"] }) => void;
|
|
3580
3961
|
readNotificationFile?: (path: string) => Promise<Buffer>;
|
|
3581
3962
|
readNotificationDiffStat?: (cwd: string) => Promise<string | undefined>;
|
|
3963
|
+
/**
|
|
3964
|
+
* INTERNAL terminal-abort session seams, threaded directly from the owning
|
|
3965
|
+
* session — deliberately NOT on the public ExtensionContext so third-party
|
|
3966
|
+
* extensions cannot observe attempt epochs or cancel the session-global
|
|
3967
|
+
* preflight outside the durable admission path (review thread P2).
|
|
3968
|
+
*/
|
|
3969
|
+
terminalAbortSeams?: {
|
|
3970
|
+
getTerminalTurnEpoch: () => number | undefined;
|
|
3971
|
+
cancelPendingPreflightForTerminalAbort: () => void;
|
|
3972
|
+
captureTerminalAbortSteeringSnapshot?: () => void;
|
|
3973
|
+
discardTerminalAbortSteeringSnapshot?: (token: number) => void;
|
|
3974
|
+
rebindTerminalAbortSteeringSnapshot?: (token: number) => void;
|
|
3975
|
+
abortPromptAndWaitWithTerminal: (
|
|
3976
|
+
handle: string,
|
|
3977
|
+
options: { graceMs: number; terminal?: { scope: "turn" | "owned"; expectedEpoch?: number } },
|
|
3978
|
+
) => Promise<RunSettlementProof>;
|
|
3979
|
+
};
|
|
3582
3980
|
} = {},
|
|
3583
3981
|
): void {
|
|
3982
|
+
const terminalAbortSeams = options.terminalAbortSeams;
|
|
3584
3983
|
const lifecycleStartupCapability = lifecycleStartupCapabilityForApi(api);
|
|
3984
|
+
// Telegram session eligibility follows the user's configuration, not how the
|
|
3985
|
+
// session happened to be launched. Any session may own a forum topic and
|
|
3986
|
+
// receive notifications once Telegram is configured and effectively active;
|
|
3987
|
+
// gating this on coordinator/lifecycle provenance silently denied every
|
|
3988
|
+
// ordinary interactive session (the daemon refuses an identity header that
|
|
3989
|
+
// declares itself ineligible, so nothing was ever delivered).
|
|
3990
|
+
const telegramTopicsEnabled = (): boolean => isTelegramSessionEligible(resolveSettings(options.settings).cfg);
|
|
3585
3991
|
const runtimes = new Map<string, SessionRuntime>();
|
|
3586
3992
|
const controller =
|
|
3587
3993
|
options.controller ??
|
|
@@ -3609,28 +4015,12 @@ export function createNotificationsExtension(
|
|
|
3609
4015
|
| undefined;
|
|
3610
4016
|
let extensionShuttingDown = false;
|
|
3611
4017
|
|
|
3612
|
-
async function ensureTelegramOwner(
|
|
3613
|
-
|
|
3614
|
-
cwd: string,
|
|
3615
|
-
id: string,
|
|
3616
|
-
onRegistered?: (registrationToken: string) => void,
|
|
3617
|
-
): Promise<"ready" | "blocked_identity"> {
|
|
4018
|
+
async function ensureTelegramOwner(settings: Settings): Promise<"ready" | "blocked_identity"> {
|
|
4019
|
+
if (!telegramTopicsEnabled()) return "blocked_identity";
|
|
3618
4020
|
if (options.ensureTelegramDaemon) {
|
|
3619
|
-
return (await options.ensureTelegramDaemon({
|
|
3620
|
-
settings,
|
|
3621
|
-
cwd,
|
|
3622
|
-
sessionId: id,
|
|
3623
|
-
onRegistered: registration => onRegistered?.(registration.token),
|
|
3624
|
-
})) === "blocked"
|
|
3625
|
-
? "blocked_identity"
|
|
3626
|
-
: "ready";
|
|
4021
|
+
return (await options.ensureTelegramDaemon({ settings })) === "blocked" ? "blocked_identity" : "ready";
|
|
3627
4022
|
}
|
|
3628
|
-
return (await ensureTelegramDaemonRunningDetailed({
|
|
3629
|
-
settings,
|
|
3630
|
-
cwd,
|
|
3631
|
-
sessionId: id,
|
|
3632
|
-
onRegistered: registration => onRegistered?.(registration.token),
|
|
3633
|
-
})) === "blocked_identity"
|
|
4023
|
+
return (await ensureTelegramDaemonRunningDetailed({ settings })) === "blocked_identity"
|
|
3634
4024
|
? "blocked_identity"
|
|
3635
4025
|
: "ready";
|
|
3636
4026
|
}
|
|
@@ -3638,11 +4028,8 @@ export function createNotificationsExtension(
|
|
|
3638
4028
|
async function ensureConfiguredDaemonOwners(
|
|
3639
4029
|
settings: Settings,
|
|
3640
4030
|
cfg: NotificationConfig,
|
|
3641
|
-
cwd: string,
|
|
3642
|
-
id: string,
|
|
3643
|
-
onRegistered?: (registrationToken: string) => void,
|
|
3644
4031
|
): Promise<ConfiguredDaemonOwnerResult> {
|
|
3645
|
-
if (isProviderEffectivelyEnabled(cfg, "telegram")) {
|
|
4032
|
+
if (telegramTopicsEnabled() && isProviderEffectivelyEnabled(cfg, "telegram")) {
|
|
3646
4033
|
const telegramMarker = getCurrentTelegramActivationMarker(cfg);
|
|
3647
4034
|
if (telegramMarker) {
|
|
3648
4035
|
if (!isProviderEffectivelyEnabled(cfg, "discord") && !isProviderEffectivelyEnabled(cfg, "slack")) {
|
|
@@ -3651,7 +4038,7 @@ export function createNotificationsExtension(
|
|
|
3651
4038
|
await ensureConfiguredProviderDaemons(settings, cfg, options.ensureProviderDaemon);
|
|
3652
4039
|
return "blocked_identity_with_sibling";
|
|
3653
4040
|
}
|
|
3654
|
-
const telegram = await ensureTelegramOwner(settings
|
|
4041
|
+
const telegram = await ensureTelegramOwner(settings);
|
|
3655
4042
|
if (telegram === "blocked_identity") {
|
|
3656
4043
|
if (!isProviderEffectivelyEnabled(cfg, "discord") && !isProviderEffectivelyEnabled(cfg, "slack")) {
|
|
3657
4044
|
return "blocked_identity";
|
|
@@ -3664,23 +4051,149 @@ export function createNotificationsExtension(
|
|
|
3664
4051
|
return "ready";
|
|
3665
4052
|
}
|
|
3666
4053
|
|
|
3667
|
-
|
|
3668
|
-
|
|
4054
|
+
/**
|
|
4055
|
+
* Single-flight chat-daemon ownership acquisition.
|
|
4056
|
+
*
|
|
4057
|
+
* Chat daemons are optional notification adapters, never session authority,
|
|
4058
|
+
* so NO session-lifecycle path (startSession, reconcile, authority rotation)
|
|
4059
|
+
* may await ownership — a wedged ensure would hold `session/new` open. This
|
|
4060
|
+
* coordinator keeps at most one ensure in flight and exposes it as a
|
|
4061
|
+
* settle-or-pending read: {@link peekDaemonOwnership} answers `undefined`
|
|
4062
|
+
* while an ensure is still running, and callers proceed without it. Exactly
|
|
4063
|
+
* one ensure runs at a time, so two callers can never race two outcomes onto
|
|
4064
|
+
* the same runtime.
|
|
4065
|
+
*/
|
|
4066
|
+
type DaemonOwnershipOutcome = ConfiguredDaemonOwnerResult | "failed";
|
|
4067
|
+
let daemonOwnershipInFlight: { key: string; task: Promise<DaemonOwnershipOutcome> } | undefined;
|
|
4068
|
+
let daemonOwnershipSettled: DaemonOwnershipOutcome | undefined;
|
|
4069
|
+
let daemonOwnershipSettledKey: string | undefined;
|
|
4070
|
+
|
|
4071
|
+
/**
|
|
4072
|
+
* Identity of the ownership-relevant configuration. A settled outcome is
|
|
4073
|
+
* only reusable while this is unchanged: toggling enablement, credentials,
|
|
4074
|
+
* destination, or the activation marker must force a fresh ensure, while a
|
|
4075
|
+
* delivery-only change (redaction, verbosity) must not.
|
|
4076
|
+
*/
|
|
4077
|
+
function daemonOwnershipKey(cfg: NotificationConfig): string {
|
|
4078
|
+
// Secrets are keyed by non-reversible fingerprint, never by raw value or
|
|
4079
|
+
// length: a same-length credential rotation MUST invalidate the outcome.
|
|
4080
|
+
const fingerprint = (secret: string | undefined): string | null =>
|
|
4081
|
+
secret === undefined || secret.length === 0 ? null : tokenFingerprint(secret);
|
|
4082
|
+
return JSON.stringify([
|
|
4083
|
+
isProviderEffectivelyEnabled(cfg, "telegram"),
|
|
4084
|
+
isProviderEffectivelyEnabled(cfg, "discord"),
|
|
4085
|
+
isProviderEffectivelyEnabled(cfg, "slack"),
|
|
4086
|
+
fingerprint(cfg.botToken),
|
|
4087
|
+
cfg.chatId ?? null,
|
|
4088
|
+
fingerprint(cfg.discord.botToken),
|
|
4089
|
+
cfg.discord.applicationId ?? null,
|
|
4090
|
+
cfg.discord.guildId ?? null,
|
|
4091
|
+
cfg.discord.parentChannelId ?? null,
|
|
4092
|
+
fingerprint(cfg.slack.botToken),
|
|
4093
|
+
fingerprint(cfg.slack.appToken),
|
|
4094
|
+
cfg.slack.workspaceId ?? null,
|
|
4095
|
+
cfg.slack.channelId ?? null,
|
|
4096
|
+
// Slack's inbound actor authorization is part of the daemon's durable
|
|
4097
|
+
// identity: rotating the paired user must re-prove ownership, or the
|
|
4098
|
+
// previous user keeps command authority through a stale outcome.
|
|
4099
|
+
cfg.slack.authorizedUserId ?? null,
|
|
4100
|
+
getCurrentTelegramActivationMarker(cfg) ?? null,
|
|
4101
|
+
// Redaction and verbosity are ordinarily delivery policy, applied
|
|
4102
|
+
// in-process. For Discord/Slack they are also part of the DAEMON's
|
|
4103
|
+
// durable identity (chatDaemonIdentity) and are snapshotted into the
|
|
4104
|
+
// provider's presentation engine at construction, so only a daemon
|
|
4105
|
+
// re-proof can retire a presenter still rendering under the old policy.
|
|
4106
|
+
// Key them exactly when such a provider is effective, so a Telegram-only
|
|
4107
|
+
// session is not churned by an in-process policy change.
|
|
4108
|
+
isProviderEffectivelyEnabled(cfg, "discord") || isProviderEffectivelyEnabled(cfg, "slack")
|
|
4109
|
+
? `${String(cfg.redact)}:${cfg.verbosity}`
|
|
4110
|
+
: null,
|
|
4111
|
+
]);
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
function peekDaemonOwnership(cfg: NotificationConfig): DaemonOwnershipOutcome | undefined {
|
|
4115
|
+
if (daemonOwnershipInFlight) return undefined;
|
|
4116
|
+
return daemonOwnershipSettledKey === daemonOwnershipKey(cfg) ? daemonOwnershipSettled : undefined;
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
function kickDaemonOwnership(
|
|
3669
4120
|
settings: Settings,
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
registrationToken: string,
|
|
4121
|
+
cfg: NotificationConfig,
|
|
4122
|
+
onSettled?: (outcome: DaemonOwnershipOutcome, key: string) => void,
|
|
3673
4123
|
): void {
|
|
3674
|
-
|
|
3675
|
-
|
|
4124
|
+
const key = daemonOwnershipKey(cfg);
|
|
4125
|
+
const existing = daemonOwnershipInFlight;
|
|
4126
|
+
// Only an ensure for the SAME configuration can serve this caller. An
|
|
4127
|
+
// in-flight ensure for a different key is superseded: its outcome proves
|
|
4128
|
+
// nothing about this configuration, and its callbacks are key-checked.
|
|
4129
|
+
if (existing && existing.key === key) {
|
|
4130
|
+
if (onSettled)
|
|
4131
|
+
void existing.task.then(
|
|
4132
|
+
outcome => onSettled(outcome, key),
|
|
4133
|
+
() => {},
|
|
4134
|
+
);
|
|
3676
4135
|
return;
|
|
3677
4136
|
}
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
4137
|
+
daemonOwnershipSettled = undefined;
|
|
4138
|
+
daemonOwnershipSettledKey = undefined;
|
|
4139
|
+
const task = (async (): Promise<DaemonOwnershipOutcome> => {
|
|
4140
|
+
try {
|
|
4141
|
+
return await ensureConfiguredDaemonOwners(settings, cfg);
|
|
4142
|
+
} catch (error) {
|
|
4143
|
+
logger.warn(
|
|
4144
|
+
`notifications: provider daemon ownership unavailable; core SDK remains available: ${String(error)}`,
|
|
4145
|
+
);
|
|
4146
|
+
return "failed";
|
|
4147
|
+
}
|
|
4148
|
+
})();
|
|
4149
|
+
daemonOwnershipInFlight = { key, task };
|
|
4150
|
+
void task.then(outcome => {
|
|
4151
|
+
if (daemonOwnershipInFlight?.task === task) {
|
|
4152
|
+
daemonOwnershipInFlight = undefined;
|
|
4153
|
+
daemonOwnershipSettled = outcome;
|
|
4154
|
+
daemonOwnershipSettledKey = key;
|
|
4155
|
+
}
|
|
4156
|
+
onSettled?.(outcome, key);
|
|
4157
|
+
});
|
|
3683
4158
|
}
|
|
4159
|
+
|
|
4160
|
+
/**
|
|
4161
|
+
* Apply one settled ownership outcome to exactly one runtime.
|
|
4162
|
+
*
|
|
4163
|
+
* Four guards keep a late or superseded outcome harmless: the runtime must
|
|
4164
|
+
* still be the registered one for its id and not be stopping (a replacement
|
|
4165
|
+
* session never inherits a predecessor's outcome), the state must still be
|
|
4166
|
+
* `retry` (an outcome that lost a race must never downgrade a runtime whose
|
|
4167
|
+
* adapters are already active), and the outcome must have been proved under
|
|
4168
|
+
* the exact configuration this runtime is waiting on — an outcome proved for
|
|
4169
|
+
* a superseded configuration can never authorize adapters. A `failed` ensure
|
|
4170
|
+
* leaves the state retryable.
|
|
4171
|
+
*/
|
|
4172
|
+
function applyDaemonOwnership(
|
|
4173
|
+
id: string,
|
|
4174
|
+
runtime: SessionRuntime,
|
|
4175
|
+
outcome: DaemonOwnershipOutcome,
|
|
4176
|
+
isolateChatEndpoint: boolean,
|
|
4177
|
+
key: string,
|
|
4178
|
+
): void {
|
|
4179
|
+
if (runtimes.get(id) !== runtime || runtime.stopping) return;
|
|
4180
|
+
if (runtime.notificationOwnerState !== "retry") return;
|
|
4181
|
+
if (runtime.notificationOwnerKey !== key) return;
|
|
4182
|
+
if (outcome === "failed") return;
|
|
4183
|
+
runtime.notificationOwnerState =
|
|
4184
|
+
outcome === "ready" || (outcome === "blocked_identity_with_sibling" && isolateChatEndpoint)
|
|
4185
|
+
? "ready"
|
|
4186
|
+
: "blocked";
|
|
4187
|
+
if (outcome === "blocked_identity") {
|
|
4188
|
+
logger.warn("notifications: Telegram daemon ownership is blocked; core SDK remains available.");
|
|
4189
|
+
}
|
|
4190
|
+
if (outcome === "blocked_identity_with_sibling" && !isolateChatEndpoint) {
|
|
4191
|
+
logger.warn(
|
|
4192
|
+
"notifications: Telegram ownership changed after core publication; preserving the canonical endpoint and withholding adapters.",
|
|
4193
|
+
);
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
|
|
3684
4197
|
const identityControlOperations = new Set([
|
|
3685
4198
|
"session.new",
|
|
3686
4199
|
"session.fork",
|
|
@@ -3704,6 +4217,7 @@ export function createNotificationsExtension(
|
|
|
3704
4217
|
requestedRuntime.stopping = true;
|
|
3705
4218
|
requestedRuntime.abortEphemeralTurns();
|
|
3706
4219
|
}
|
|
4220
|
+
if (reason === "session" && requestedRuntime) requestedRuntime.stopSessionNameObserver();
|
|
3707
4221
|
if (reason === "session" && requestedRuntime) {
|
|
3708
4222
|
// Fence the exact runtime before awaiting its startup promise: a late start
|
|
3709
4223
|
// must observe removal and clean itself up rather than becoming reachable.
|
|
@@ -3834,20 +4348,6 @@ export function createNotificationsExtension(
|
|
|
3834
4348
|
hostStopped: rt.hostStopped && rt.serverStopped,
|
|
3835
4349
|
brokerRegistrationReleased: rt.brokerRegistrationReleased,
|
|
3836
4350
|
});
|
|
3837
|
-
if (rt.notificationRootRegistration) {
|
|
3838
|
-
try {
|
|
3839
|
-
await unregisterNotificationRoot({
|
|
3840
|
-
settings: rt.notificationRootRegistration.settings,
|
|
3841
|
-
cwd: rt.notificationRootRegistration.cwd,
|
|
3842
|
-
sessionId: id,
|
|
3843
|
-
registrationToken: rt.notificationRootRegistration.registrationToken,
|
|
3844
|
-
});
|
|
3845
|
-
rt.notificationRootRegistration = undefined;
|
|
3846
|
-
} catch (e) {
|
|
3847
|
-
ownerReleaseFailures.push(e);
|
|
3848
|
-
logger.warn(`notifications: Telegram root unregister failed: ${String(e)}`);
|
|
3849
|
-
}
|
|
3850
|
-
}
|
|
3851
4351
|
if (ownerReleaseFailures.length > 0) {
|
|
3852
4352
|
cleanupRetries.set(id, rt);
|
|
3853
4353
|
throw new AggregateError(ownerReleaseFailures, `SDK notification runtime ${id} owner release failed.`);
|
|
@@ -3942,6 +4442,7 @@ export function createNotificationsExtension(
|
|
|
3942
4442
|
|
|
3943
4443
|
const pendingInteractive = new Map<string, PendingInteractiveAsk>();
|
|
3944
4444
|
const pendingPromptCorrelations: Array<{ commandId: string; turnId: string }> = [];
|
|
4445
|
+
const pendingPromptCorrelationsBySdkRunToken = new Map<string, { commandId: string; turnId: string }>();
|
|
3945
4446
|
const tag = sessionTag(id);
|
|
3946
4447
|
let runtime: SessionRuntime | undefined;
|
|
3947
4448
|
|
|
@@ -3950,11 +4451,6 @@ export function createNotificationsExtension(
|
|
|
3950
4451
|
// build information and required capability while lifecycle startup can settle
|
|
3951
4452
|
// a structured failure instead of leaving the lifecycle caller pending.
|
|
3952
4453
|
const token = resolveToken();
|
|
3953
|
-
const elevationAuthorityToken = crypto.randomBytes(32).toString("base64url");
|
|
3954
|
-
const elevationAuthorityFile = elevationAuthorityPath(endpointStateRoot, id);
|
|
3955
|
-
await fsPromises.mkdir(path.dirname(elevationAuthorityFile), { recursive: true, mode: 0o700 });
|
|
3956
|
-
await Bun.write(elevationAuthorityFile, `${JSON.stringify({ version: 1, token: elevationAuthorityToken })}\n`);
|
|
3957
|
-
await fsPromises.chmod(elevationAuthorityFile, 0o600);
|
|
3958
4454
|
let server: NotificationServer;
|
|
3959
4455
|
try {
|
|
3960
4456
|
const { NotificationServer, nativeBuildInfo } = sdkBusNatives();
|
|
@@ -3995,6 +4491,7 @@ export function createNotificationsExtension(
|
|
|
3995
4491
|
createSdkPermissionAskAnswerSource(
|
|
3996
4492
|
async (params, signal) => await host!.reverse.request("permission", "request", params, signal),
|
|
3997
4493
|
),
|
|
4494
|
+
"protocol",
|
|
3998
4495
|
);
|
|
3999
4496
|
};
|
|
4000
4497
|
const installProviderDefinitions = (capability: string, definitions: unknown) => {
|
|
@@ -4038,6 +4535,7 @@ export function createNotificationsExtension(
|
|
|
4038
4535
|
createSdkUiAskAnswerSource(
|
|
4039
4536
|
async (params, signal) => await host!.reverse.request("ui", "ui.elicit", params, signal),
|
|
4040
4537
|
),
|
|
4538
|
+
"protocol",
|
|
4041
4539
|
);
|
|
4042
4540
|
disposePermissionAnswerSource?.();
|
|
4043
4541
|
disposePermissionAnswerSource = undefined;
|
|
@@ -4103,6 +4601,8 @@ export function createNotificationsExtension(
|
|
|
4103
4601
|
const PROMPT_TERMINAL_TOMBSTONE_TTL_MS = 15 * 60_000;
|
|
4104
4602
|
// SDK-owned terminalization grace; injectable in tests, never a user setting.
|
|
4105
4603
|
const PROMPT_TERMINALIZATION_GRACE_MS = 10_000;
|
|
4604
|
+
// Fixed grace for exact owned-job stop before the second quiescence proof.
|
|
4605
|
+
const OWNED_SETTLEMENT_GRACE_MS = 500;
|
|
4106
4606
|
const promptSubmissionKey = (correlation: { commandId: string; turnId: string }) =>
|
|
4107
4607
|
`${correlation.commandId}:${correlation.turnId}`;
|
|
4108
4608
|
type PromptLifecycleFrame =
|
|
@@ -4138,21 +4638,50 @@ export function createNotificationsExtension(
|
|
|
4138
4638
|
outcome?: SdkPromptTerminalOutcome;
|
|
4139
4639
|
/** Agent-owned resource run captured at acceptance; cleanup targets only this handle. */
|
|
4140
4640
|
executionHandle?: string;
|
|
4641
|
+
/** Cancels accepted skill preparation before an execution handle exists. */
|
|
4642
|
+
preflightAbort?: () => void | Promise<void>;
|
|
4643
|
+
reconciliationKind: ReconciliationKind;
|
|
4141
4644
|
bufferedFrames: Array<PromptLifecycleFrame | Record<string, unknown>>;
|
|
4142
4645
|
};
|
|
4143
4646
|
const promptSubmissions = new Map<string, PromptSubmission>();
|
|
4144
4647
|
/** Connections fenced by a fatal prompt closure; their later frames are refused. */
|
|
4145
4648
|
const fencedConnections = new Set<string>();
|
|
4146
|
-
|
|
4649
|
+
/**
|
|
4650
|
+
* Live positioned-event delivery to attached subscribers. The native
|
|
4651
|
+
* broadcast channel round-trips a closed frame enum and cannot carry the
|
|
4652
|
+
* positioned event envelope, so each envelope rides the validated directed
|
|
4653
|
+
* leg instead — to every connection that completed capability negotiation
|
|
4654
|
+
* or an event replay, which is exactly the attached-subscriber set. Fenced
|
|
4655
|
+
* connections are excluded like their inbound frames, and capability-gated
|
|
4656
|
+
* kinds follow the same gate replay applies, so live and replay delivery
|
|
4657
|
+
* stay one truth per connection.
|
|
4658
|
+
*/
|
|
4659
|
+
const broadcastEventFrame = (event: SdkFrame): void => {
|
|
4660
|
+
const gated = CAP_GATED_FRAME_KINDS.has(String(event.kind));
|
|
4661
|
+
const json = JSON.stringify(event);
|
|
4662
|
+
for (const [connectionId, capabilities] of hostCapCache) {
|
|
4663
|
+
if (fencedConnections.has(connectionId)) continue;
|
|
4664
|
+
if (gated && !capabilities.has(TOOL_ACTIVITY_CAPABILITY)) continue;
|
|
4665
|
+
try {
|
|
4666
|
+
server.sendTo(connectionId, json);
|
|
4667
|
+
} catch {
|
|
4668
|
+
// Broadcasts are best effort; directed responses surface send failures.
|
|
4669
|
+
}
|
|
4670
|
+
}
|
|
4671
|
+
};
|
|
4672
|
+
let cancelPreflightsForConnection: ((connectionId: string) => Promise<void>) | undefined;
|
|
4147
4673
|
const promptTerminalTombstones = new Map<string, { connectionId: string; expiresAt: number }>();
|
|
4148
|
-
// Authoritative bounded reconciliation state for Q26 turn.
|
|
4674
|
+
// Authoritative bounded reconciliation state for canonical Q26 turn.result
|
|
4149
4675
|
// (contract documented in ./prompt-reconciliation and ../prompt-status).
|
|
4150
4676
|
// Active records never age into terminal; documented TTL/capacity
|
|
4151
4677
|
// eviction is the only removal, after which lookups report `unknown`.
|
|
4152
|
-
const
|
|
4678
|
+
const persistedSessionFile =
|
|
4153
4679
|
typeof ctx.sessionManager?.getSessionFile === "function" ? ctx.sessionManager.getSessionFile() : null;
|
|
4154
4680
|
const reconciliationSessionId =
|
|
4155
4681
|
typeof ctx.sessionManager?.getSessionId === "function" ? ctx.sessionManager.getSessionId() : "";
|
|
4682
|
+
const sessionFile = reconciliationSessionId
|
|
4683
|
+
? resolveReconciliationSessionFile(persistedSessionFile, stateRoot, String(reconciliationSessionId))
|
|
4684
|
+
: null;
|
|
4156
4685
|
const durableStore =
|
|
4157
4686
|
sessionFile && reconciliationSessionId
|
|
4158
4687
|
? createReconciliationStore({ sessionFile, sessionId: String(reconciliationSessionId) })
|
|
@@ -4160,12 +4689,22 @@ export function createNotificationsExtension(
|
|
|
4160
4689
|
const kindReconciliation = createKindAwareReconciliation({ store: durableStore });
|
|
4161
4690
|
// Restart recovery must commit before any prompt is admitted; otherwise a new
|
|
4162
4691
|
// admission can race the hydrated full-state replacement.
|
|
4163
|
-
|
|
4164
|
-
? kindReconciliation.hydrateFromStore()
|
|
4165
|
-
: Promise.resolve();
|
|
4692
|
+
let reconciliationReady: Promise<void> = durableStore ? kindReconciliation.hydrateFromStore() : Promise.resolve();
|
|
4166
4693
|
// Never let a hydration rejection become an unhandled rejection; tracked prompts
|
|
4167
4694
|
// re-await the same promise and fail closed below.
|
|
4168
4695
|
void reconciliationReady.catch(() => {});
|
|
4696
|
+
const awaitReconciliationReady = async () => {
|
|
4697
|
+
const observed = reconciliationReady;
|
|
4698
|
+
try {
|
|
4699
|
+
await observed;
|
|
4700
|
+
} catch {
|
|
4701
|
+
if (reconciliationReady === observed) {
|
|
4702
|
+
reconciliationReady = durableStore ? kindReconciliation.hydrateFromStore() : Promise.resolve();
|
|
4703
|
+
void reconciliationReady.catch(() => {});
|
|
4704
|
+
}
|
|
4705
|
+
await reconciliationReady;
|
|
4706
|
+
}
|
|
4707
|
+
};
|
|
4169
4708
|
// Backward-compatible process-local prompt reconciler kept for unit-test isolation;
|
|
4170
4709
|
// production path uses kindReconciliation for prompt+skill.
|
|
4171
4710
|
const reconciliation = createPromptReconciliation();
|
|
@@ -4182,6 +4721,10 @@ export function createNotificationsExtension(
|
|
|
4182
4721
|
candidate => candidate.commandId === correlation.commandId && candidate.turnId === correlation.turnId,
|
|
4183
4722
|
);
|
|
4184
4723
|
if (pendingIndex !== -1) pendingPromptCorrelations.splice(pendingIndex, 1);
|
|
4724
|
+
for (const [sdkRunToken, candidate] of pendingPromptCorrelationsBySdkRunToken) {
|
|
4725
|
+
if (candidate.commandId === correlation.commandId && candidate.turnId === correlation.turnId)
|
|
4726
|
+
pendingPromptCorrelationsBySdkRunToken.delete(sdkRunToken);
|
|
4727
|
+
}
|
|
4185
4728
|
};
|
|
4186
4729
|
const addTerminalTombstone = (key: string, connectionId: string, now = Date.now()) => {
|
|
4187
4730
|
promptTerminalTombstones.delete(key);
|
|
@@ -4234,7 +4777,7 @@ export function createNotificationsExtension(
|
|
|
4234
4777
|
const key = promptSubmissionKey(correlation);
|
|
4235
4778
|
const submission = promptSubmissions.get(key);
|
|
4236
4779
|
if (!submission) return;
|
|
4237
|
-
runtime
|
|
4780
|
+
emitSessionEvent(runtime, frame);
|
|
4238
4781
|
if (submission.abandoned) {
|
|
4239
4782
|
if (submission.terminal) finalizePrompt(key, correlation);
|
|
4240
4783
|
return;
|
|
@@ -4298,6 +4841,9 @@ export function createNotificationsExtension(
|
|
|
4298
4841
|
requesterConnectionId?: string,
|
|
4299
4842
|
clientRef?: string,
|
|
4300
4843
|
trackReconciliation = false,
|
|
4844
|
+
preflightAbort?: () => void | Promise<void>,
|
|
4845
|
+
reconciliationKind: ReconciliationKind = "prompt",
|
|
4846
|
+
sdkRunToken?: string,
|
|
4301
4847
|
) => {
|
|
4302
4848
|
if (!requesterConnectionId) {
|
|
4303
4849
|
// No delivery owner: tracked prompts cannot be reconciled. Release
|
|
@@ -4317,7 +4863,8 @@ export function createNotificationsExtension(
|
|
|
4317
4863
|
);
|
|
4318
4864
|
expirePromptDelivery(oldestTerminal[0], oldestTerminal[1]);
|
|
4319
4865
|
}
|
|
4320
|
-
|
|
4866
|
+
if (sdkRunToken) pendingPromptCorrelationsBySdkRunToken.set(sdkRunToken, correlation);
|
|
4867
|
+
else pendingPromptCorrelations.push(correlation);
|
|
4321
4868
|
const submission: PromptSubmission = {
|
|
4322
4869
|
acknowledged: false,
|
|
4323
4870
|
connectionId: requesterConnectionId,
|
|
@@ -4330,6 +4877,8 @@ export function createNotificationsExtension(
|
|
|
4330
4877
|
phase: "active",
|
|
4331
4878
|
// Bound to the Agent run at `agent_start`; acceptance precedes execution.
|
|
4332
4879
|
executionHandle: undefined,
|
|
4880
|
+
...(preflightAbort ? { preflightAbort } : {}),
|
|
4881
|
+
reconciliationKind,
|
|
4333
4882
|
bufferedFrames: [],
|
|
4334
4883
|
};
|
|
4335
4884
|
promptSubmissions.set(promptSubmissionKey(correlation), submission);
|
|
@@ -4350,6 +4899,8 @@ export function createNotificationsExtension(
|
|
|
4350
4899
|
};
|
|
4351
4900
|
/** Roll back process-local registration when durable acceptance failed. */
|
|
4352
4901
|
const discardPromptAcceptance = (correlation: { commandId: string; turnId: string }) => {
|
|
4902
|
+
const submission = promptSubmissions.get(promptSubmissionKey(correlation));
|
|
4903
|
+
if (submission?.deadlineTimer) clearTimeout(submission.deadlineTimer);
|
|
4353
4904
|
promptSubmissions.delete(promptSubmissionKey(correlation));
|
|
4354
4905
|
removePendingPromptCorrelation(correlation);
|
|
4355
4906
|
};
|
|
@@ -4402,7 +4953,10 @@ export function createNotificationsExtension(
|
|
|
4402
4953
|
// connection is marked failed (every later inbound frame from it is refused),
|
|
4403
4954
|
// its reverse leases are released, and its deliveries are abandoned. The durable
|
|
4404
4955
|
// record stays active for Q26 and restart recovery.
|
|
4405
|
-
cancelPreflightsForConnection
|
|
4956
|
+
if (cancelPreflightsForConnection)
|
|
4957
|
+
void cancelPreflightsForConnection(submission.connectionId).catch(error =>
|
|
4958
|
+
logger.warn(`sdk: failed to cancel fenced preflight: ${String(error)}`),
|
|
4959
|
+
);
|
|
4406
4960
|
fencedConnections.add(submission.connectionId);
|
|
4407
4961
|
host?.handleDisconnect(submission.connectionId);
|
|
4408
4962
|
for (const [key, owned] of promptSubmissions)
|
|
@@ -4422,7 +4976,10 @@ export function createNotificationsExtension(
|
|
|
4422
4976
|
handle: string | undefined,
|
|
4423
4977
|
) => {
|
|
4424
4978
|
const submission = promptSubmissions.get(promptSubmissionKey(correlation));
|
|
4425
|
-
if (submission)
|
|
4979
|
+
if (submission) {
|
|
4980
|
+
submission.executionHandle = handle;
|
|
4981
|
+
submission.preflightAbort = undefined;
|
|
4982
|
+
}
|
|
4426
4983
|
};
|
|
4427
4984
|
const terminalizePrompt = async (
|
|
4428
4985
|
correlation: { commandId: string; turnId: string },
|
|
@@ -4430,15 +4987,35 @@ export function createNotificationsExtension(
|
|
|
4430
4987
|
// Cleanup-initiated claims (cancel, deadline, owner disconnect) must abort the
|
|
4431
4988
|
// run and prove settlement. A natural `agent_end`/`agent_failed` already unwound,
|
|
4432
4989
|
// so aborting there would cancel the next turn instead of fencing this one.
|
|
4433
|
-
options: { fence?: boolean } = {},
|
|
4990
|
+
options: { fence?: boolean; terminal?: { scope: AbortScope; steeringSnapshotToken?: number } } = {},
|
|
4434
4991
|
extra?: PromptTerminalExtra,
|
|
4992
|
+
capture?: {
|
|
4993
|
+
proof?: RunSettlementProof & {
|
|
4994
|
+
terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string };
|
|
4995
|
+
};
|
|
4996
|
+
/** Whether the correlated agent_end event was published (AC 19). */
|
|
4997
|
+
published?: boolean;
|
|
4998
|
+
/** Whether terminalization reached the durable terminal (fail-closed paths leave this unset). */
|
|
4999
|
+
terminalized?: boolean;
|
|
5000
|
+
},
|
|
4435
5001
|
) => {
|
|
5002
|
+
const receiptState = extra?.finalText?.trim() ? "present" : "missing";
|
|
4436
5003
|
const submission = promptSubmissions.get(promptSubmissionKey(correlation));
|
|
4437
5004
|
if (!submission || submission.terminal || submission.phase !== "active") return;
|
|
4438
5005
|
submission.phase = "outcome_claimed";
|
|
5006
|
+
// Capture the attempt epoch BEFORE the durable claim: a successor
|
|
5007
|
+
// prompt admitted while the claim is awaited advances the epoch, so
|
|
5008
|
+
// the seam must refuse to fence the successor's lineage (review
|
|
5009
|
+
// thread P1).
|
|
5010
|
+
const terminalExpectedEpoch = options.terminal ? terminalAbortSeams?.getTerminalTurnEpoch?.() : undefined;
|
|
4439
5011
|
let winner: SdkPromptTerminalOutcome;
|
|
4440
5012
|
try {
|
|
4441
|
-
winner = await kindReconciliation.claimPendingOutcome(
|
|
5013
|
+
winner = await kindReconciliation.claimPendingOutcome(
|
|
5014
|
+
submission.reconciliationKind,
|
|
5015
|
+
correlation,
|
|
5016
|
+
requestedOutcome,
|
|
5017
|
+
receiptState,
|
|
5018
|
+
);
|
|
4442
5019
|
} catch (error) {
|
|
4443
5020
|
// The claim is the durability boundary: without it nothing may be published
|
|
4444
5021
|
// and the endpoint must fail closed so the client rejects exactly once. The
|
|
@@ -4450,12 +5027,17 @@ export function createNotificationsExtension(
|
|
|
4450
5027
|
submission.outcome = winner;
|
|
4451
5028
|
submission.phase = "terminalizing";
|
|
4452
5029
|
if (options.fence) {
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
5030
|
+
// Terminal fencing goes through a BUS-PRIVATE session capability
|
|
5031
|
+
// (never the extension context, whose abortPromptAndWait has no
|
|
5032
|
+
// terminal option): a JavaScript extension cannot register a
|
|
5033
|
+
// closed terminal scope without the SDK's durable admission
|
|
5034
|
+
// (review thread P2).
|
|
4456
5035
|
// Only the handle captured for this correlation may be fenced; a later run
|
|
4457
5036
|
// must never be aborted by an older prompt's cleanup.
|
|
4458
|
-
if (
|
|
5037
|
+
if (
|
|
5038
|
+
typeof terminalAbortSeams?.abortPromptAndWaitWithTerminal !== "function" ||
|
|
5039
|
+
!submission.executionHandle
|
|
5040
|
+
) {
|
|
4459
5041
|
failPromptClosed(
|
|
4460
5042
|
correlation,
|
|
4461
5043
|
submission,
|
|
@@ -4464,10 +5046,27 @@ export function createNotificationsExtension(
|
|
|
4464
5046
|
);
|
|
4465
5047
|
return;
|
|
4466
5048
|
}
|
|
5049
|
+
// Rebind the steering snapshot to the current turn before the
|
|
5050
|
+
// settlement: the token was captured under the abort's admission
|
|
5051
|
+
// turn, which may differ from the now-active turn that the
|
|
5052
|
+
// settlement will fence. Without the rebind the session rejects
|
|
5053
|
+
// the still-old token as unknown_run (review thread P1).
|
|
5054
|
+
if (options.terminal?.steeringSnapshotToken !== undefined) {
|
|
5055
|
+
terminalAbortSeams?.rebindTerminalAbortSteeringSnapshot?.(options.terminal.steeringSnapshotToken);
|
|
5056
|
+
}
|
|
4467
5057
|
let proof: RunSettlementProof;
|
|
4468
5058
|
try {
|
|
4469
|
-
proof = await
|
|
5059
|
+
proof = await terminalAbortSeams.abortPromptAndWaitWithTerminal(submission.executionHandle, {
|
|
4470
5060
|
graceMs: PROMPT_TERMINALIZATION_GRACE_MS,
|
|
5061
|
+
// Terminal abort registers the continuation fence for the
|
|
5062
|
+
// aborted turn before the run is interrupted (see
|
|
5063
|
+
// AgentSession.abortPromptAndWait). Ordinary cancels pass no
|
|
5064
|
+
// terminal option and register nothing. The request-time epoch
|
|
5065
|
+
// travels with the scope so the seam refuses to fence a
|
|
5066
|
+
// successor turn admitted while the durable claim was awaited.
|
|
5067
|
+
...(options.terminal
|
|
5068
|
+
? { terminal: { ...options.terminal, expectedEpoch: terminalExpectedEpoch } }
|
|
5069
|
+
: {}),
|
|
4471
5070
|
});
|
|
4472
5071
|
} catch (error) {
|
|
4473
5072
|
logger.warn(`sdk: prompt resource fencing failed: ${String(error)}`);
|
|
@@ -4491,9 +5090,16 @@ export function createNotificationsExtension(
|
|
|
4491
5090
|
);
|
|
4492
5091
|
return;
|
|
4493
5092
|
}
|
|
5093
|
+
if (capture) capture.proof = proof;
|
|
4494
5094
|
}
|
|
4495
5095
|
try {
|
|
4496
|
-
await kindReconciliation.
|
|
5096
|
+
await kindReconciliation.finalizeOutcome(
|
|
5097
|
+
submission.reconciliationKind,
|
|
5098
|
+
correlation,
|
|
5099
|
+
winner,
|
|
5100
|
+
extra?.error,
|
|
5101
|
+
extra?.finalText,
|
|
5102
|
+
);
|
|
4497
5103
|
} catch (error) {
|
|
4498
5104
|
// The durable pending claim survives; publishing an unpersisted terminal
|
|
4499
5105
|
// would contradict it, so fail the endpoint closed instead.
|
|
@@ -4521,25 +5127,57 @@ export function createNotificationsExtension(
|
|
|
4521
5127
|
reason: formatPromptTerminalFailureReason(diagnostic?.reason),
|
|
4522
5128
|
});
|
|
4523
5129
|
}
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
5130
|
+
try {
|
|
5131
|
+
if (winner.kind === "failed") {
|
|
5132
|
+
emitPromptLifecycle(correlation, {
|
|
5133
|
+
type: "agent_failed",
|
|
5134
|
+
sessionId: runtime.id,
|
|
5135
|
+
...correlation,
|
|
5136
|
+
error: extra?.error ?? { code: winner.code, message: winner.message },
|
|
5137
|
+
outcome: winner,
|
|
5138
|
+
});
|
|
5139
|
+
} else {
|
|
5140
|
+
emitPromptLifecycle(correlation, {
|
|
5141
|
+
type: "agent_end",
|
|
5142
|
+
sessionId: runtime.id,
|
|
5143
|
+
...correlation,
|
|
5144
|
+
...(extra?.finalText ? { finalText: extra.finalText } : {}),
|
|
5145
|
+
outcome: winner,
|
|
5146
|
+
// Terminal abort: one correlated existing agent_end carries bounded
|
|
5147
|
+
// scope/turn/ownedWork/automatic metadata before the first terminal
|
|
5148
|
+
// success. ownedWork is pre-proof here (owned cleanup settles it in
|
|
5149
|
+
// the terminal response); later owned-completion feedback uses the
|
|
5150
|
+
// ordinary fresh-turn event path, never a second terminal event.
|
|
5151
|
+
...(options.terminal
|
|
5152
|
+
? {
|
|
5153
|
+
terminal: {
|
|
5154
|
+
scope: options.terminal.scope,
|
|
5155
|
+
turn: "stopped",
|
|
5156
|
+
ownedWork: options.terminal.scope === "turn" ? "left_running" : "uncertain",
|
|
5157
|
+
automaticDelivery: options.terminal.scope === "turn" ? "enabled" : "none",
|
|
5158
|
+
resumeOnOwnedCompletion: options.terminal.scope === "turn",
|
|
5159
|
+
},
|
|
5160
|
+
}
|
|
5161
|
+
: {}),
|
|
5162
|
+
});
|
|
5163
|
+
}
|
|
5164
|
+
// The correlated event was published (AC 19): record the outcome so
|
|
5165
|
+
// the durable terminal-scope record carries terminalPublished:true.
|
|
5166
|
+
// terminalized marks a genuinely landed durable terminal — the
|
|
5167
|
+
// submission may already have been finalized/deleted for an
|
|
5168
|
+
// acknowledged prompt, so the callback must not re-derive success
|
|
5169
|
+
// from promptSubmissions after this point (P1).
|
|
5170
|
+
if (capture) {
|
|
5171
|
+
capture.published = true;
|
|
5172
|
+
capture.terminalized = true;
|
|
5173
|
+
}
|
|
5174
|
+
} catch (error) {
|
|
5175
|
+
// Event publication failed: the semantic terminal stands but the
|
|
5176
|
+
// event bit stays false (no second event is ever emitted on replay).
|
|
5177
|
+
logger.warn(`sdk: prompt terminal event publication failed: ${String(error)}`);
|
|
4540
5178
|
}
|
|
4541
5179
|
};
|
|
4542
|
-
const emitPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
|
|
5180
|
+
const emitPromptFailure = async (correlation: { commandId: string; turnId: string }, error: unknown) => {
|
|
4543
5181
|
logger.error("SDK prompt submission failed", {
|
|
4544
5182
|
commandId: correlation.commandId,
|
|
4545
5183
|
turnId: correlation.turnId,
|
|
@@ -4562,7 +5200,7 @@ export function createNotificationsExtension(
|
|
|
4562
5200
|
provenance: outcome.provenance,
|
|
4563
5201
|
reason: formatPromptTerminalFailureReason(error),
|
|
4564
5202
|
});
|
|
4565
|
-
|
|
5203
|
+
await terminalizePrompt(
|
|
4566
5204
|
correlation,
|
|
4567
5205
|
outcome,
|
|
4568
5206
|
{},
|
|
@@ -4571,7 +5209,7 @@ export function createNotificationsExtension(
|
|
|
4571
5209
|
{ error: sanitized, diagnostic: { reason: error }, diagnosticAlreadyLogged: true },
|
|
4572
5210
|
);
|
|
4573
5211
|
};
|
|
4574
|
-
const recordPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
|
|
5212
|
+
const recordPromptFailure = async (correlation: { commandId: string; turnId: string }, error: unknown) => {
|
|
4575
5213
|
const submission = promptSubmissions.get(promptSubmissionKey(correlation));
|
|
4576
5214
|
if (!submission) return;
|
|
4577
5215
|
submission.failed = true;
|
|
@@ -4581,7 +5219,7 @@ export function createNotificationsExtension(
|
|
|
4581
5219
|
runtime.activePromptCorrelation.turnId === correlation.turnId
|
|
4582
5220
|
)
|
|
4583
5221
|
runtime.activePromptCorrelation = undefined;
|
|
4584
|
-
emitPromptFailure(correlation, error);
|
|
5222
|
+
await emitPromptFailure(correlation, error);
|
|
4585
5223
|
};
|
|
4586
5224
|
const acknowledgePrompt = (connectionId: string, correlation: { commandId: string; turnId: string }) => {
|
|
4587
5225
|
const key = promptSubmissionKey(correlation);
|
|
@@ -4612,6 +5250,7 @@ export function createNotificationsExtension(
|
|
|
4612
5250
|
configOverrides,
|
|
4613
5251
|
settings,
|
|
4614
5252
|
selector => kindReconciliation.lookupResult(selector.kind, selector),
|
|
5253
|
+
selector => kindReconciliation.lookupSteer(selector),
|
|
4615
5254
|
),
|
|
4616
5255
|
id,
|
|
4617
5256
|
revisions,
|
|
@@ -4622,7 +5261,10 @@ export function createNotificationsExtension(
|
|
|
4622
5261
|
pendingInteractive,
|
|
4623
5262
|
gatePresentations,
|
|
4624
5263
|
api,
|
|
4625
|
-
() =>
|
|
5264
|
+
() =>
|
|
5265
|
+
runtime?.busy === true ||
|
|
5266
|
+
pendingPromptCorrelations.length > 0 ||
|
|
5267
|
+
pendingPromptCorrelationsBySdkRunToken.size > 0,
|
|
4626
5268
|
recordPromptAccepted,
|
|
4627
5269
|
recordPromptFailure,
|
|
4628
5270
|
discardPromptAcceptance,
|
|
@@ -4630,33 +5272,1017 @@ export function createNotificationsExtension(
|
|
|
4630
5272
|
trackGateResolution,
|
|
4631
5273
|
admitPromptSubmission,
|
|
4632
5274
|
releasePromptAdmission,
|
|
4633
|
-
|
|
5275
|
+
awaitReconciliationReady,
|
|
4634
5276
|
settings,
|
|
4635
5277
|
configOverrides,
|
|
4636
5278
|
configRevision,
|
|
4637
|
-
elevationAuthorityToken,
|
|
4638
5279
|
async connectionId => {
|
|
4639
5280
|
const active = [...promptSubmissions.entries()].find(
|
|
4640
5281
|
([, submission]) => submission.connectionId === connectionId && !submission.terminal,
|
|
4641
5282
|
);
|
|
4642
5283
|
if (!active) return { aborted: true, disposition: "idle" as const };
|
|
5284
|
+
const [key, submission] = active;
|
|
5285
|
+
const [commandId, turnId] = key.split(":", 2);
|
|
5286
|
+
if (commandId && turnId) {
|
|
5287
|
+
const hasExecutionHandle = Boolean(submission.executionHandle);
|
|
5288
|
+
const cancellationOutcome: SdkPromptTerminalOutcome = {
|
|
5289
|
+
kind: "stopped",
|
|
5290
|
+
reason: "cancelled",
|
|
5291
|
+
provenance: "client_cancel",
|
|
5292
|
+
};
|
|
5293
|
+
if (!hasExecutionHandle && submission.preflightAbort) {
|
|
5294
|
+
try {
|
|
5295
|
+
await kindReconciliation.claimPendingOutcome(
|
|
5296
|
+
submission.reconciliationKind,
|
|
5297
|
+
{ commandId, turnId },
|
|
5298
|
+
cancellationOutcome,
|
|
5299
|
+
);
|
|
5300
|
+
} catch (error) {
|
|
5301
|
+
logger.warn(`sdk: accepted preflight cancellation claim failed: ${String(error)}`);
|
|
5302
|
+
failPromptClosed(
|
|
5303
|
+
{ commandId, turnId },
|
|
5304
|
+
submission,
|
|
5305
|
+
"terminal_uncertain",
|
|
5306
|
+
"Accepted prompt cancellation could not be persisted.",
|
|
5307
|
+
);
|
|
5308
|
+
return { aborted: true, disposition: "cancelled" as const };
|
|
5309
|
+
}
|
|
5310
|
+
try {
|
|
5311
|
+
await submission.preflightAbort();
|
|
5312
|
+
} catch (error) {
|
|
5313
|
+
logger.warn(`sdk: accepted preflight cancellation failed: ${String(error)}`);
|
|
5314
|
+
failPromptClosed(
|
|
5315
|
+
{ commandId, turnId },
|
|
5316
|
+
submission,
|
|
5317
|
+
"terminal_uncertain",
|
|
5318
|
+
"Accepted prompt preparation could not be settled before cancellation.",
|
|
5319
|
+
);
|
|
5320
|
+
return { aborted: true, disposition: "cancelled" as const };
|
|
5321
|
+
}
|
|
5322
|
+
}
|
|
5323
|
+
await terminalizePrompt({ commandId, turnId }, cancellationOutcome, {
|
|
5324
|
+
fence: hasExecutionHandle || !submission.preflightAbort,
|
|
5325
|
+
});
|
|
5326
|
+
}
|
|
5327
|
+
return { aborted: true, disposition: "cancelled" as const };
|
|
5328
|
+
},
|
|
5329
|
+
async (connectionId, scope, idempotencyKey, preflightCancel, noOtherConnectionPreflights) => {
|
|
5330
|
+
// Capture the steering snapshot at ADMISSION (before the durable
|
|
5331
|
+
// marker transaction): client steering admitted while the abort
|
|
5332
|
+
// is in flight classifies as post-snapshot and is preserved at
|
|
5333
|
+
// abortPromptAndWait (review thread P1).
|
|
5334
|
+
const steeringSnapshotToken = terminalAbortSeams?.captureTerminalAbortSteeringSnapshot?.();
|
|
5335
|
+
// Hash the EXACT response payload this abort will return: the durable
|
|
5336
|
+
// row stores it at finalization so the response-state advance requires
|
|
5337
|
+
// equality instead of trusting a non-pending placeholder (review
|
|
5338
|
+
// thread P2).
|
|
5339
|
+
const hashResult = (value: unknown): string =>
|
|
5340
|
+
crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
5341
|
+
// The public uncertain disposition the client receives for an
|
|
5342
|
+
// {ok:false, reason} outcome (single source for finalization hashes).
|
|
5343
|
+
const hashPublicUncertain = (reason: "worker_unsettled" | "owned_unsettled"): string =>
|
|
5344
|
+
hashResult({
|
|
5345
|
+
ok: true,
|
|
5346
|
+
selection: scope,
|
|
5347
|
+
turn: "uncertain",
|
|
5348
|
+
ownedWork: scope === "turn" ? "left_running" : "uncertain",
|
|
5349
|
+
automaticDelivery: scope === "turn" ? "enabled" : "none",
|
|
5350
|
+
resumeOnOwnedCompletion: scope === "turn",
|
|
5351
|
+
reason,
|
|
5352
|
+
});
|
|
5353
|
+
// Terminal abort stops the root turn through the same durable
|
|
5354
|
+
// terminalization as ordinary client cancel, then verifies the
|
|
5355
|
+
// terminal actually landed before claiming "stopped". The fence
|
|
5356
|
+
// for the aborted turn is registered by the session (via the
|
|
5357
|
+
// terminal option on abortPromptAndWait) so a later left-running
|
|
5358
|
+
// owned completion classifies by exact source. A fatal
|
|
5359
|
+
// fail-closed path (no exact run handle or unsettled resources)
|
|
5360
|
+
// reports safe uncertainty, never a fabricated stop.
|
|
5361
|
+
if (!durableStore?.path) {
|
|
5362
|
+
// No FILE-BACKED reconciliation owner: terminal admission is
|
|
5363
|
+
// gated off (plan AC 5) before any fence, stop, or cleanup. A
|
|
5364
|
+
// memory-only store (path null, e.g. an unsafe session header
|
|
5365
|
+
// id) must not report durable success — restart would lose the
|
|
5366
|
+
// idempotency row and a same-key retry could affect a later
|
|
5367
|
+
// turn (review thread P2).
|
|
5368
|
+
return { ok: true as const, outcome: "no_store" as const };
|
|
5369
|
+
}
|
|
5370
|
+
// Await the startup reconciliation hydration before ANY snapshot or
|
|
5371
|
+
// terminal-scope transaction, so a same-key retry immediately after
|
|
5372
|
+
// a restarted endpoint becomes reachable replays the durable row
|
|
5373
|
+
// instead of racing the still-pending store load (P2).
|
|
5374
|
+
await reconciliationReady;
|
|
5375
|
+
// Same-key replay/conflict: a durable terminal-scope record already
|
|
5376
|
+
// exists for this bounded idempotency key. Same key + same
|
|
5377
|
+
// normalized input -> return the stored dispositions exactly, never
|
|
5378
|
+
// re-run cleanup, never a second event. Same key + different input
|
|
5379
|
+
// (scope change) -> deterministic conflict (AC 3).
|
|
5380
|
+
const keyHash = idempotencyKey
|
|
5381
|
+
? crypto.createHash("sha256").update(idempotencyKey).digest("hex")
|
|
5382
|
+
: undefined;
|
|
5383
|
+
const inputHash = crypto
|
|
5384
|
+
.createHash("sha256")
|
|
5385
|
+
.update(JSON.stringify({ mode: "terminal", scope }))
|
|
5386
|
+
.digest("hex");
|
|
5387
|
+
if (keyHash) {
|
|
5388
|
+
const existing = durableStore.snapshotTerminalScopes().find(s => s.idempotencyKeyHash === keyHash);
|
|
5389
|
+
if (!existing) {
|
|
5390
|
+
// The completed row may have been evicted by the retention
|
|
5391
|
+
// cap; its compact key tombstone survives, so a same-key
|
|
5392
|
+
// retry replays as already-handled instead of aborting an
|
|
5393
|
+
// unrelated later prompt. Look the tombstone up by keyHash
|
|
5394
|
+
// FIRST: reusing the key with the OTHER scope must return
|
|
5395
|
+
// idempotency_conflict, not bypass the conflict path
|
|
5396
|
+
// (review thread P1).
|
|
5397
|
+
const tombstone = durableStore.snapshotTerminalKeys().find(k => k.keyHash === keyHash);
|
|
5398
|
+
if (tombstone) {
|
|
5399
|
+
if (tombstone.inputHash !== inputHash) {
|
|
5400
|
+
return { ok: false as const, reason: "conflict" as const };
|
|
5401
|
+
}
|
|
5402
|
+
// The evicted row's compact tombstone carries its
|
|
5403
|
+
// disposition, so the replay reconstructs the ORIGINAL
|
|
5404
|
+
// result (stopped/stopped_owned/uncertain/no_effect)
|
|
5405
|
+
// with the stored response/publication state instead of
|
|
5406
|
+
// collapsing everything to no_effect (review thread P2).
|
|
5407
|
+
const storedRow = {
|
|
5408
|
+
responseState: tombstone.responseState ?? "",
|
|
5409
|
+
responsePayloadHash: tombstone.responsePayloadHash ?? "",
|
|
5410
|
+
terminalPublished: tombstone.terminalPublished === true,
|
|
5411
|
+
};
|
|
5412
|
+
if (tombstone.turnDisposition === "stopped") {
|
|
5413
|
+
return {
|
|
5414
|
+
ok: true as const,
|
|
5415
|
+
outcome: (tombstone.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as
|
|
5416
|
+
| "stopped"
|
|
5417
|
+
| "stopped_owned",
|
|
5418
|
+
stored: storedRow,
|
|
5419
|
+
};
|
|
5420
|
+
}
|
|
5421
|
+
if (tombstone.turnDisposition === "uncertain") {
|
|
5422
|
+
return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
|
|
5423
|
+
}
|
|
5424
|
+
if (tombstone.turnDisposition === "no_effect_marker_failure") {
|
|
5425
|
+
// Marker failure before any destructive work: replay the SAME
|
|
5426
|
+
// no_effect result, so eviction/restart can never turn it into
|
|
5427
|
+
// a fabricated no_active_turn (review thread P2).
|
|
5428
|
+
return { ok: true as const, outcome: "no_effect" as const, stored: storedRow };
|
|
5429
|
+
}
|
|
5430
|
+
if (tombstone.turnDisposition === "no_effect_reserved") {
|
|
5431
|
+
// A transitional reservation evicted mid-flight: the result was
|
|
5432
|
+
// never finalized, so replay safe uncertainty — never a
|
|
5433
|
+
// fabricated no_active_turn (review thread P2).
|
|
5434
|
+
return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
|
|
5435
|
+
}
|
|
5436
|
+
// Idle/already-terminal reservation: replay as no_active_turn so a
|
|
5437
|
+
// same-key retry after eviction/restart never aborts an unrelated
|
|
5438
|
+
// later turn.
|
|
5439
|
+
return { ok: true as const, outcome: "no_effect_replay" as const, stored: storedRow };
|
|
5440
|
+
}
|
|
5441
|
+
}
|
|
5442
|
+
if (existing) {
|
|
5443
|
+
if (existing.selection !== scope || existing.idempotencyInputHash !== inputHash) {
|
|
5444
|
+
return { ok: false as const, reason: "conflict" as const };
|
|
5445
|
+
}
|
|
5446
|
+
// Replay every persisted durable row (AC 18/19/41) WITHOUT
|
|
5447
|
+
// re-running the stop, cleanup, or event, carrying the stored
|
|
5448
|
+
// response state, payload hash, and publication bit so the
|
|
5449
|
+
// client sees the exact immutable row.
|
|
5450
|
+
const storedRow = {
|
|
5451
|
+
responseState: existing.responseState,
|
|
5452
|
+
responsePayloadHash: existing.responsePayloadHash,
|
|
5453
|
+
terminalPublished: existing.terminalPublished === true,
|
|
5454
|
+
};
|
|
5455
|
+
if (existing.turnDisposition === "stopped") {
|
|
5456
|
+
return {
|
|
5457
|
+
ok: true as const,
|
|
5458
|
+
outcome: (existing.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as
|
|
5459
|
+
| "stopped"
|
|
5460
|
+
| "stopped_owned",
|
|
5461
|
+
stored: storedRow,
|
|
5462
|
+
};
|
|
5463
|
+
}
|
|
5464
|
+
if (existing.turnDisposition === "pending") {
|
|
5465
|
+
// A crashed attempt left an incomplete marker: replay the
|
|
5466
|
+
// plan's pending row (AC 4/41) — safe uncertainty, NO
|
|
5467
|
+
// re-run of the stop, cleanup, or event.
|
|
5468
|
+
return { ok: true as const, outcome: "pending_replay" as const, stored: storedRow };
|
|
5469
|
+
}
|
|
5470
|
+
if (existing.turnDisposition === "no_effect_marker_failure") {
|
|
5471
|
+
// Marker failure before any destructive work: replay the SAME
|
|
5472
|
+
// no_effect result the request returned, never a fabricated
|
|
5473
|
+
// no_active_turn (review thread P2).
|
|
5474
|
+
return { ok: true as const, outcome: "no_effect" as const, stored: storedRow };
|
|
5475
|
+
}
|
|
5476
|
+
if (existing.turnDisposition === "no_effect_reserved") {
|
|
5477
|
+
// A transitional reservation: the abort may still transition to
|
|
5478
|
+
// active, so a duplicate must never claim no_active_turn over a
|
|
5479
|
+
// provisional row (review thread P2).
|
|
5480
|
+
return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
|
|
5481
|
+
}
|
|
5482
|
+
if (existing.turnDisposition === "no_effect") {
|
|
5483
|
+
// A durable idle/already-terminal reservation: replay the exact
|
|
5484
|
+
// no_active_turn result so a same-key retry after
|
|
5485
|
+
// eviction/restart never aborts an unrelated later turn and
|
|
5486
|
+
// never turns the reservation into a no_effect marker failure.
|
|
5487
|
+
return { ok: true as const, outcome: "no_effect_replay" as const, stored: storedRow };
|
|
5488
|
+
}
|
|
5489
|
+
// uncertain (restart-settled) or any other durable state: safe
|
|
5490
|
+
// uncertainty replay, never a re-run (AC 41 restart row).
|
|
5491
|
+
return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
|
|
5492
|
+
}
|
|
5493
|
+
}
|
|
5494
|
+
// Durable terminal-state writes below go through the shared
|
|
5495
|
+
// `boundTerminalRetentionState` bound (see
|
|
5496
|
+
// `session/terminal-abort.ts`), which the SDK-only host runtime uses
|
|
5497
|
+
// too: idle aborts with unique keys cannot grow the reconciliation
|
|
5498
|
+
// document, only the oldest COMPLETED rows are evicted, and evicted
|
|
5499
|
+
// keys are retained as compact tombstones (review thread P2).
|
|
5500
|
+
// Set when the no-effect reservation finds an existing SAME-input row
|
|
5501
|
+
// or tombstone: the caller replays it instead of returning a no-active
|
|
5502
|
+
// result over the original row's replay authority (review thread P2).
|
|
5503
|
+
let existingReplay: DurableTerminalScopeRecord | EvictedTerminalKeyEntry | undefined;
|
|
5504
|
+
// Finalize THIS abort's transitional no_effect_reserved reservation to
|
|
5505
|
+
// plain no_effect once the recheck confirms there is no active turn to
|
|
5506
|
+
// stop: a later same-key retry then replays the deterministic
|
|
5507
|
+
// no_active_turn result instead of reservation uncertainty. Only OUR
|
|
5508
|
+
// row (exact key+input, still reserved) is touched (review thread P2).
|
|
5509
|
+
const finalizeNoEffectReservation = async (payloadHash: string): Promise<void> => {
|
|
5510
|
+
if (!keyHash) return;
|
|
5511
|
+
// The same-key retry delivers the replay envelope; store its hash
|
|
5512
|
+
// too so a written replay can advance the finalized row (review
|
|
5513
|
+
// thread P2).
|
|
5514
|
+
const replayPayloadHash = hashResult({
|
|
5515
|
+
ok: true,
|
|
5516
|
+
selection: scope,
|
|
5517
|
+
turn: "no_active_turn",
|
|
5518
|
+
terminal: "terminal_no_effect",
|
|
5519
|
+
replay: {
|
|
5520
|
+
responseState: "pending",
|
|
5521
|
+
responsePayloadHash: payloadHash,
|
|
5522
|
+
terminalPublished: false,
|
|
5523
|
+
},
|
|
5524
|
+
});
|
|
5525
|
+
try {
|
|
5526
|
+
await durableStore.transactTerminalState(state => {
|
|
5527
|
+
const scopes: DurableTerminalScopeRecord[] = state.scopes.map(record =>
|
|
5528
|
+
record.idempotencyKeyHash === keyHash &&
|
|
5529
|
+
record.idempotencyInputHash === inputHash &&
|
|
5530
|
+
record.turnDisposition === "no_effect_reserved"
|
|
5531
|
+
? {
|
|
5532
|
+
...record,
|
|
5533
|
+
turnDisposition: "no_effect",
|
|
5534
|
+
responsePayloadHash: payloadHash,
|
|
5535
|
+
replayPayloadHash,
|
|
5536
|
+
}
|
|
5537
|
+
: record,
|
|
5538
|
+
);
|
|
5539
|
+
// Finalized reservations become evictable completed rows: apply
|
|
5540
|
+
// the SAME bounded retention as reserveTerminalNoEffect so a
|
|
5541
|
+
// burst of idle aborts cannot grow the document (review
|
|
5542
|
+
// thread P2).
|
|
5543
|
+
return boundTerminalRetentionState(state.keys, scopes);
|
|
5544
|
+
});
|
|
5545
|
+
} catch {
|
|
5546
|
+
// Best-effort: the row stays reserved (replays as uncertainty)
|
|
5547
|
+
// rather than failing the abort (review thread P2).
|
|
5548
|
+
}
|
|
5549
|
+
};
|
|
5550
|
+
// Finalize pending markers through the SAME bounded retention as the
|
|
5551
|
+
// admission writes: mapping pending rows to completed dispositions
|
|
5552
|
+
// (uncertain/stopped) must evict the oldest completed rows and
|
|
5553
|
+
// retain tombstones, or a burst of concurrent distinct-key aborts of
|
|
5554
|
+
// one slow turn leaves an arbitrarily large reconciliation document
|
|
5555
|
+
// (review thread P2).
|
|
5556
|
+
const transactBoundedTerminalScopes = async (
|
|
5557
|
+
mutate: (scopes: DurableTerminalScopeRecord[]) => DurableTerminalScopeRecord[],
|
|
5558
|
+
): Promise<void> => {
|
|
5559
|
+
await durableStore.transactTerminalState(state => {
|
|
5560
|
+
return boundTerminalRetentionState(state.keys, mutate(state.scopes));
|
|
5561
|
+
});
|
|
5562
|
+
};
|
|
5563
|
+
const replayExisting = (row: DurableTerminalScopeRecord | EvictedTerminalKeyEntry) => {
|
|
5564
|
+
const storedRow = {
|
|
5565
|
+
responseState: row.responseState ?? "",
|
|
5566
|
+
responsePayloadHash: row.responsePayloadHash ?? "",
|
|
5567
|
+
terminalPublished: row.terminalPublished === true,
|
|
5568
|
+
};
|
|
5569
|
+
if (row.turnDisposition === "stopped") {
|
|
5570
|
+
return {
|
|
5571
|
+
ok: true as const,
|
|
5572
|
+
outcome: (row.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as
|
|
5573
|
+
| "stopped"
|
|
5574
|
+
| "stopped_owned",
|
|
5575
|
+
stored: storedRow,
|
|
5576
|
+
};
|
|
5577
|
+
}
|
|
5578
|
+
if (row.turnDisposition === "pending") {
|
|
5579
|
+
return { ok: true as const, outcome: "pending_replay" as const, stored: storedRow };
|
|
5580
|
+
}
|
|
5581
|
+
if (row.turnDisposition === "uncertain") {
|
|
5582
|
+
return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
|
|
5583
|
+
}
|
|
5584
|
+
if (row.turnDisposition === "no_effect_marker_failure") {
|
|
5585
|
+
return { ok: true as const, outcome: "no_effect" as const, stored: storedRow };
|
|
5586
|
+
}
|
|
5587
|
+
if (row.turnDisposition === "no_effect_reserved") {
|
|
5588
|
+
// A transitional reservation never finalized: replay safe
|
|
5589
|
+
// uncertainty, never a fabricated no_active_turn (review
|
|
5590
|
+
// thread P2).
|
|
5591
|
+
return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
|
|
5592
|
+
}
|
|
5593
|
+
return { ok: true as const, outcome: "no_effect_replay" as const, stored: storedRow };
|
|
5594
|
+
};
|
|
5595
|
+
const reserveTerminalNoEffect = async (
|
|
5596
|
+
reason: "idle" | "marker_failure" = "idle",
|
|
5597
|
+
): Promise<"ok" | "failed" | "conflict"> => {
|
|
5598
|
+
if (!keyHash) return "ok";
|
|
5599
|
+
try {
|
|
5600
|
+
await durableStore.transactTerminalState(state => {
|
|
5601
|
+
// Atomic recheck: a concurrent request may have committed a
|
|
5602
|
+
// DIFFERENT input under this key after the earlier snapshot
|
|
5603
|
+
// check (the 256-entry dispatch cache evicted the in-flight
|
|
5604
|
+
// entry). Appending a second same-key row would make later
|
|
5605
|
+
// replay's .find() by key hash ambiguous and could report a
|
|
5606
|
+
// conflict for a request that already succeeded; reject the
|
|
5607
|
+
// conflicting input inside the transaction instead (review
|
|
5608
|
+
// thread P2).
|
|
5609
|
+
const conflicting = state.scopes.find(s => s.idempotencyKeyHash === keyHash);
|
|
5610
|
+
if (conflicting && conflicting.idempotencyInputHash !== inputHash)
|
|
5611
|
+
throw new TerminalIdempotencyConflictError();
|
|
5612
|
+
// A SAME-input live row is durable replay authority (the
|
|
5613
|
+
// original in-flight abort's marker): never replace it with a
|
|
5614
|
+
// no-effect reservation, or the successful abort would replay
|
|
5615
|
+
// later as no_active_turn. Leave the store unchanged and let
|
|
5616
|
+
// the caller replay the existing row (review thread P2).
|
|
5617
|
+
if (conflicting) {
|
|
5618
|
+
existingReplay = conflicting;
|
|
5619
|
+
return { scopes: state.scopes, keys: state.keys };
|
|
5620
|
+
}
|
|
5621
|
+
// A concurrent admission may ALSO have evicted a same-key row
|
|
5622
|
+
// into the tombstone collection after this request's snapshot;
|
|
5623
|
+
// recheck keys so a different input can never install a fresh
|
|
5624
|
+
// marker over existing durable replay authority (review
|
|
5625
|
+
// thread P2). A same-input tombstone already carries the
|
|
5626
|
+
// reservation: leave the store unchanged and replay it.
|
|
5627
|
+
const tombstone = state.keys.find(s => s.keyHash === keyHash);
|
|
5628
|
+
if (tombstone) {
|
|
5629
|
+
if (tombstone.inputHash !== inputHash) throw new TerminalIdempotencyConflictError();
|
|
5630
|
+
existingReplay = tombstone;
|
|
5631
|
+
return { scopes: state.scopes, keys: state.keys };
|
|
5632
|
+
}
|
|
5633
|
+
const retained = state.scopes.filter(
|
|
5634
|
+
s => !(s.idempotencyKeyHash === keyHash && s.idempotencyInputHash === inputHash),
|
|
5635
|
+
);
|
|
5636
|
+
const preBound: DurableTerminalScopeRecord[] = [
|
|
5637
|
+
...retained,
|
|
5638
|
+
{
|
|
5639
|
+
selection: scope,
|
|
5640
|
+
idempotencyKeyHash: keyHash,
|
|
5641
|
+
idempotencyInputHash: inputHash,
|
|
5642
|
+
// Idle reservations write a TRANSITIONAL reserved disposition: the
|
|
5643
|
+
// prompt may become active while the reservation is awaited, and a
|
|
5644
|
+
// duplicate must never claim no_active_turn over a provisional row
|
|
5645
|
+
// — the row is finalized to plain no_effect only when the recheck
|
|
5646
|
+
// confirms no active turn. Marker-failure reservations use a
|
|
5647
|
+
// distinct final disposition so replay returns the SAME no_effect
|
|
5648
|
+
// result instead of a fabricated no_active_turn (review thread
|
|
5649
|
+
// P2).
|
|
5650
|
+
turnDisposition:
|
|
5651
|
+
reason === "marker_failure" ? "no_effect_marker_failure" : "no_effect_reserved",
|
|
5652
|
+
ownedWorkDisposition: "not_requested",
|
|
5653
|
+
automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none",
|
|
5654
|
+
resumeOnOwnedCompletion: scope === "turn",
|
|
5655
|
+
turnContinuationFence: {
|
|
5656
|
+
state: "retained",
|
|
5657
|
+
abortedAttemptEpoch: 0,
|
|
5658
|
+
blockedContinuationIds: [],
|
|
5659
|
+
predecessorTombstones: [],
|
|
5660
|
+
ownedCompletionPolicy: scope === "turn" ? "enabled" : "disabled",
|
|
5661
|
+
},
|
|
5662
|
+
responseState: "pending",
|
|
5663
|
+
// marker_failure rows are FINAL as written (the abort
|
|
5664
|
+
// returns the public no_effect result immediately, with no
|
|
5665
|
+
// later finalization), so store the public payload hash;
|
|
5666
|
+
// idle reservations are finalized by
|
|
5667
|
+
// finalizeNoEffectReservation (review thread P2).
|
|
5668
|
+
responsePayloadHash:
|
|
5669
|
+
reason === "marker_failure"
|
|
5670
|
+
? hashResult({
|
|
5671
|
+
ok: true,
|
|
5672
|
+
selection: scope,
|
|
5673
|
+
turn: "no_effect",
|
|
5674
|
+
terminal: "terminal_no_effect",
|
|
5675
|
+
})
|
|
5676
|
+
: inputHash,
|
|
5677
|
+
acceptedAt: Date.now(),
|
|
5678
|
+
} satisfies DurableTerminalScopeRecord,
|
|
5679
|
+
];
|
|
5680
|
+
// Compact key tombstones for rows evicted by the cap are
|
|
5681
|
+
// retained ATOMICALLY with the scope write (review thread P2).
|
|
5682
|
+
return boundTerminalRetentionState(state.keys, preBound);
|
|
5683
|
+
});
|
|
5684
|
+
return "ok";
|
|
5685
|
+
} catch (error) {
|
|
5686
|
+
if (error instanceof TerminalIdempotencyConflictError) return "conflict";
|
|
5687
|
+
logger.warn(`sdk: terminal no-effect reservation failed: ${String(error)}`);
|
|
5688
|
+
return "failed";
|
|
5689
|
+
}
|
|
5690
|
+
};
|
|
5691
|
+
let active = [...promptSubmissions.entries()].find(
|
|
5692
|
+
([, submission]) => submission.connectionId === connectionId && !submission.terminal,
|
|
5693
|
+
);
|
|
5694
|
+
if (!active) {
|
|
5695
|
+
// DURABLY reserve the key even for a no-active-turn abort: the
|
|
5696
|
+
// generic idempotency cache is in-memory only, so after restart
|
|
5697
|
+
// or eviction a same-key retry while a later prompt is active
|
|
5698
|
+
// must replay this no-effect row instead of aborting an
|
|
5699
|
+
// unrelated turn (review thread P2). No active turn means no
|
|
5700
|
+
// fence epoch; the marker uses sentinel 0. The reservation is
|
|
5701
|
+
// bounded (see reserveTerminalNoEffect).
|
|
5702
|
+
const reservation = await reserveTerminalNoEffect();
|
|
5703
|
+
if (reservation === "conflict") return { ok: false as const, reason: "conflict" as const };
|
|
5704
|
+
if (reservation === "failed") {
|
|
5705
|
+
// Without the durable reservation a same-key retry after
|
|
5706
|
+
// eviction/restart could abort an unrelated later turn, so a
|
|
5707
|
+
// failed reservation must NOT report success.
|
|
5708
|
+
return { ok: false as const, reason: "reservation_failed" as const };
|
|
5709
|
+
}
|
|
5710
|
+
// A SAME-input row/tombstone existed while the reservation awaited
|
|
5711
|
+
// the store: it is durable replay authority — replay its stored
|
|
5712
|
+
// result instead of returning no_active_turn over it (review
|
|
5713
|
+
// thread P2).
|
|
5714
|
+
if (existingReplay) return replayExisting(existingReplay);
|
|
5715
|
+
// RE-SCAN after the async reservation: the requester's prompt may
|
|
5716
|
+
// have moved from preflight to accepted during the filesystem
|
|
5717
|
+
// write. Returning no_active_turn here would leave the accepted
|
|
5718
|
+
// prompt to start (its pending-preflight entry may already be
|
|
5719
|
+
// removed, so the surface cleanup skips both cancellation seams)
|
|
5720
|
+
// and the durable no-effect row would block a same-key retry from
|
|
5721
|
+
// stopping it — fall through to the pre-run / active fencing path
|
|
5722
|
+
// when a submission now exists (review thread P1).
|
|
5723
|
+
active = [...promptSubmissions.entries()].find(
|
|
5724
|
+
([, submission]) => submission.connectionId === connectionId && !submission.terminal,
|
|
5725
|
+
);
|
|
5726
|
+
if (!active) {
|
|
5727
|
+
// Close the remaining acceptance race: cancel the requester's
|
|
5728
|
+
// preflights HERE, in the same synchronous region as the
|
|
5729
|
+
// rescan (no await boundary between the scan and the cancel,
|
|
5730
|
+
// as the surface-level post-check had), so a prompt accepted
|
|
5731
|
+
// in that window cannot start with its preflight entry
|
|
5732
|
+
// already removed (review thread P1).
|
|
5733
|
+
if (preflightCancel?.hasPending()) {
|
|
5734
|
+
preflightCancel.cancel();
|
|
5735
|
+
// The seam cancels the SESSION-WIDE preflight controller:
|
|
5736
|
+
// invoke it only when no OTHER connection has a pending
|
|
5737
|
+
// admission, or this queued requester's abort would fail an
|
|
5738
|
+
// unrelated connection's active preflight (review thread P1).
|
|
5739
|
+
if (noOtherConnectionPreflights?.() !== false)
|
|
5740
|
+
terminalAbortSeams?.cancelPendingPreflightForTerminalAbort?.();
|
|
5741
|
+
}
|
|
5742
|
+
// No prompt won the race: finalize the reserved row so a later
|
|
5743
|
+
// same-key retry (including after restart) replays this
|
|
5744
|
+
// deterministic no_active_turn result instead of reservation
|
|
5745
|
+
// uncertainty (review thread P2).
|
|
5746
|
+
const no_active_turnResult = { ok: true as const, outcome: "no_active_turn" as const };
|
|
5747
|
+
// Hash the PUBLIC disposition the client receives (abortTerminal
|
|
5748
|
+
// transforms the outcome into {ok,selection,turn,terminal}); the
|
|
5749
|
+
// response-state observer hashes response.result, so hashing the
|
|
5750
|
+
// internal outcome shape could never match (review thread P2).
|
|
5751
|
+
await finalizeNoEffectReservation(
|
|
5752
|
+
hashResult({ ok: true, selection: scope, turn: "no_active_turn", terminal: "terminal_no_effect" }),
|
|
5753
|
+
);
|
|
5754
|
+
return no_active_turnResult;
|
|
5755
|
+
}
|
|
5756
|
+
}
|
|
4643
5757
|
const [commandId, turnId] = active[0].split(":", 2);
|
|
4644
|
-
if (commandId
|
|
4645
|
-
await
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
5758
|
+
if (!commandId || !turnId) {
|
|
5759
|
+
const reservation = await reserveTerminalNoEffect();
|
|
5760
|
+
if (reservation === "conflict") return { ok: false as const, reason: "conflict" as const };
|
|
5761
|
+
if (reservation === "failed") {
|
|
5762
|
+
// Same durable-reservation guarantee as the no-active path.
|
|
5763
|
+
return { ok: false as const, reason: "reservation_failed" as const };
|
|
5764
|
+
}
|
|
5765
|
+
if (existingReplay) return replayExisting(existingReplay);
|
|
5766
|
+
const already_terminalResult = { ok: true as const, outcome: "already_terminal" as const };
|
|
5767
|
+
// Hash the PUBLIC disposition the client receives (abortTerminal
|
|
5768
|
+
// transforms the outcome into {ok,selection,turn,terminal}); the
|
|
5769
|
+
// response-state observer hashes response.result, so hashing the
|
|
5770
|
+
// internal outcome shape could never match (review thread P2).
|
|
5771
|
+
await finalizeNoEffectReservation(
|
|
5772
|
+
hashResult({ ok: true, selection: scope, turn: "no_active_turn", terminal: "terminal_no_effect" }),
|
|
4649
5773
|
);
|
|
4650
|
-
|
|
5774
|
+
return already_terminalResult;
|
|
5775
|
+
}
|
|
5776
|
+
// Accepted-but-not-started window: the submission exists but
|
|
5777
|
+
// agent_start has not bound executionHandle yet, and the preflight
|
|
5778
|
+
// cancellation entry was already removed after accept. Cancel the
|
|
5779
|
+
// in-flight session preflight so the pending #promptWithMessage
|
|
5780
|
+
// cannot continue into the agent, and FINALIZE the accepted prompt
|
|
5781
|
+
// as a pre-run client cancellation WITHOUT terminalizing — there is
|
|
5782
|
+
// no run handle to fence, and terminalizePrompt's missing-handle
|
|
5783
|
+
// fail-closed path would wrongly fence the SDK connection and leave
|
|
5784
|
+
// reconciliation unfinalized for a prompt that will never run
|
|
5785
|
+
// (review thread P2).
|
|
5786
|
+
if (!active[1].executionHandle) {
|
|
5787
|
+
// Persist the durable no-effect reservation BEFORE cancelling the
|
|
5788
|
+
// session preflight: a failed reservation must NOT leave the
|
|
5789
|
+
// prompt cancelled with no durable row (a later same-key retry
|
|
5790
|
+
// after eviction/restart could then abort an unrelated turn —
|
|
5791
|
+
// review thread P2).
|
|
5792
|
+
const reservation = await reserveTerminalNoEffect();
|
|
5793
|
+
if (reservation === "conflict") return { ok: false as const, reason: "conflict" as const };
|
|
5794
|
+
if (reservation === "failed") {
|
|
5795
|
+
return { ok: false as const, reason: "reservation_failed" as const };
|
|
5796
|
+
}
|
|
5797
|
+
if (existingReplay) return replayExisting(existingReplay);
|
|
5798
|
+
// RECHCK after the async write: agent_start may have bound the
|
|
5799
|
+
// execution handle during the reservation, so this is no longer
|
|
5800
|
+
// a pre-run cancellation — fall through to the ACTIVE-turn
|
|
5801
|
+
// fencing path below (terminalizePrompt with fence) instead of
|
|
5802
|
+
// finalizing without abortPromptAndWait (review thread P2).
|
|
5803
|
+
if (!active[1].executionHandle) {
|
|
5804
|
+
// REVALIDATE the captured submission: another abort may have
|
|
5805
|
+
// terminalized it during the reservation while a NEW prompt
|
|
5806
|
+
// entered preflight — cancelling the session-global preflight
|
|
5807
|
+
// for the stale capture would abort that unrelated prompt.
|
|
5808
|
+
// Only proceed when this exact nonterminal submission is still
|
|
5809
|
+
// authoritative (review thread P1).
|
|
5810
|
+
const currentSubmission = promptSubmissions.get(promptSubmissionKey({ commandId, turnId }));
|
|
5811
|
+
if (currentSubmission !== active[1] || currentSubmission.terminal) {
|
|
5812
|
+
// The captured submission is no longer authoritative: finalize
|
|
5813
|
+
// our provisional reservation so the durable row reflects the
|
|
5814
|
+
// settled outcome rather than lingering mid-transition (review
|
|
5815
|
+
// thread P2).
|
|
5816
|
+
const already_terminalResult = { ok: true as const, outcome: "already_terminal" as const };
|
|
5817
|
+
// Hash the PUBLIC disposition the client receives (abortTerminal
|
|
5818
|
+
// transforms the outcome into {ok,selection,turn,terminal}); the
|
|
5819
|
+
// response-state observer hashes response.result, so hashing the
|
|
5820
|
+
// internal outcome shape could never match (review thread P2).
|
|
5821
|
+
await finalizeNoEffectReservation(
|
|
5822
|
+
hashResult({
|
|
5823
|
+
ok: true,
|
|
5824
|
+
selection: scope,
|
|
5825
|
+
turn: "no_active_turn",
|
|
5826
|
+
terminal: "terminal_no_effect",
|
|
5827
|
+
}),
|
|
5828
|
+
);
|
|
5829
|
+
return already_terminalResult;
|
|
5830
|
+
}
|
|
5831
|
+
if (preflightCancel?.hasPending()) {
|
|
5832
|
+
preflightCancel.cancel();
|
|
5833
|
+
// The seam cancels the SESSION-WIDE preflight controller:
|
|
5834
|
+
// invoke it only when no OTHER connection has a pending
|
|
5835
|
+
// admission (review thread P1).
|
|
5836
|
+
if (noOtherConnectionPreflights?.() !== false)
|
|
5837
|
+
terminalAbortSeams?.cancelPendingPreflightForTerminalAbort?.();
|
|
5838
|
+
}
|
|
5839
|
+
await terminalizePrompt(
|
|
5840
|
+
{ commandId, turnId },
|
|
5841
|
+
{ kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
|
|
5842
|
+
{},
|
|
5843
|
+
);
|
|
5844
|
+
// No prompt won the race: finalize the reserved row so a later
|
|
5845
|
+
// same-key retry replays this deterministic no_active_turn result
|
|
5846
|
+
// instead of reservation uncertainty (review thread P2).
|
|
5847
|
+
const no_active_turnResult = { ok: true as const, outcome: "no_active_turn" as const };
|
|
5848
|
+
// Hash the PUBLIC disposition the client receives (abortTerminal
|
|
5849
|
+
// transforms the outcome into {ok,selection,turn,terminal}); the
|
|
5850
|
+
// response-state observer hashes response.result, so hashing the
|
|
5851
|
+
// internal outcome shape could never match (review thread P2).
|
|
5852
|
+
await finalizeNoEffectReservation(
|
|
5853
|
+
hashResult({ ok: true, selection: scope, turn: "no_active_turn", terminal: "terminal_no_effect" }),
|
|
5854
|
+
);
|
|
5855
|
+
return no_active_turnResult;
|
|
5856
|
+
}
|
|
5857
|
+
}
|
|
5858
|
+
// Plan ordered step 4: write the bounded INITIAL MARKER (key/input
|
|
5859
|
+
// hashes, pending dispositions, publication false, response pending)
|
|
5860
|
+
// BEFORE any fence/stop/event effect, so a crash between the stop
|
|
5861
|
+
// and the semantic CAS still leaves a same-key retry that replays
|
|
5862
|
+
// deterministically instead of re-running effects. Marker failure is
|
|
5863
|
+
// process-local no-effect (AC 10) — nothing destructive has run yet.
|
|
5864
|
+
const markerEpoch = terminalAbortSeams?.getTerminalTurnEpoch?.();
|
|
5865
|
+
if (markerEpoch === undefined) return { ok: true as const, outcome: "no_effect" as const };
|
|
5866
|
+
let pendingReplay: DurableTerminalScopeRecord | undefined;
|
|
5867
|
+
let tombstoneReplay: EvictedTerminalKeyEntry | undefined;
|
|
5868
|
+
try {
|
|
5869
|
+
// Write the marker and any evicted-row key tombstones in ONE
|
|
5870
|
+
// atomic document transaction so a crash cannot leave the
|
|
5871
|
+
// durable store with neither the row nor its tombstone
|
|
5872
|
+
// (review thread P2). The marker itself stays pending (never
|
|
5873
|
+
// evicted); only completed rows are bounded.
|
|
5874
|
+
await durableStore.transactTerminalState(state => {
|
|
5875
|
+
// Atomic recheck: a concurrent request may have committed a
|
|
5876
|
+
// DIFFERENT input under this key after the earlier snapshot
|
|
5877
|
+
// check (dispatch-cache eviction), and the filter below must
|
|
5878
|
+
// never wipe that row — replacing it would let a later replay
|
|
5879
|
+
// of the SUCCEEDED request report a conflict (review thread
|
|
5880
|
+
// P2). A same-input PENDING row is an in-flight duplicate:
|
|
5881
|
+
// replay it instead of installing a second marker, so the
|
|
5882
|
+
// duplicate cannot race terminalization and flip the row to
|
|
5883
|
+
// uncertain while the original returns stopped (review
|
|
5884
|
+
// thread P2).
|
|
5885
|
+
const conflicting = state.scopes.find(s => keyHash && s.idempotencyKeyHash === keyHash);
|
|
5886
|
+
if (conflicting) {
|
|
5887
|
+
if (conflicting.idempotencyInputHash !== inputHash) throw new TerminalIdempotencyConflictError();
|
|
5888
|
+
if (conflicting.turnDisposition === "pending") {
|
|
5889
|
+
pendingReplay = conflicting;
|
|
5890
|
+
return { scopes: state.scopes, keys: state.keys };
|
|
5891
|
+
}
|
|
5892
|
+
}
|
|
5893
|
+
// A concurrent admission may ALSO have evicted a same-key row
|
|
5894
|
+
// into the tombstone collection after this request's snapshot;
|
|
5895
|
+
// recheck keys so a different input can never install a fresh
|
|
5896
|
+
// marker over existing durable replay authority (review
|
|
5897
|
+
// thread P2). A same-input tombstone already carries replay
|
|
5898
|
+
// authority: never install a second marker here.
|
|
5899
|
+
const tombstone = state.keys.find(s => keyHash && s.keyHash === keyHash);
|
|
5900
|
+
if (tombstone) {
|
|
5901
|
+
if (tombstone.inputHash !== inputHash) throw new TerminalIdempotencyConflictError();
|
|
5902
|
+
tombstoneReplay = tombstone;
|
|
5903
|
+
return { scopes: state.scopes, keys: state.keys };
|
|
5904
|
+
}
|
|
5905
|
+
const retained = state.scopes.filter(s => !(keyHash && s.idempotencyKeyHash === keyHash));
|
|
5906
|
+
const preBound: DurableTerminalScopeRecord[] = [
|
|
5907
|
+
...retained,
|
|
5908
|
+
{
|
|
5909
|
+
selection: scope,
|
|
5910
|
+
...(keyHash ? { idempotencyKeyHash: keyHash, idempotencyInputHash: inputHash } : {}),
|
|
5911
|
+
turnDisposition: "pending",
|
|
5912
|
+
terminalPublished: false,
|
|
5913
|
+
ownedWorkDisposition: "not_requested",
|
|
5914
|
+
automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none",
|
|
5915
|
+
resumeOnOwnedCompletion: scope === "turn",
|
|
5916
|
+
turnContinuationFence: {
|
|
5917
|
+
state: "retained",
|
|
5918
|
+
abortedAttemptEpoch: markerEpoch,
|
|
5919
|
+
blockedContinuationIds: [],
|
|
5920
|
+
predecessorTombstones: [],
|
|
5921
|
+
ownedCompletionPolicy: scope === "turn" ? "enabled" : "disabled",
|
|
5922
|
+
},
|
|
5923
|
+
responseState: "pending",
|
|
5924
|
+
responsePayloadHash: inputHash,
|
|
5925
|
+
acceptedAt: Date.now(),
|
|
5926
|
+
} satisfies DurableTerminalScopeRecord,
|
|
5927
|
+
];
|
|
5928
|
+
return boundTerminalRetentionState(state.keys, preBound);
|
|
5929
|
+
});
|
|
5930
|
+
} catch (error) {
|
|
5931
|
+
if (error instanceof TerminalIdempotencyConflictError) {
|
|
5932
|
+
// A different input won the durable key race inside the
|
|
5933
|
+
// transaction; reject rather than replacing its row.
|
|
5934
|
+
return { ok: false as const, reason: "conflict" as const };
|
|
5935
|
+
}
|
|
5936
|
+
logger.warn(`sdk: terminal initial marker persistence failed: ${String(error)}`);
|
|
5937
|
+
// No durable marker exists: do NOT acknowledge success without a
|
|
5938
|
+
// durable row — a same-key retry after restart/expiry would miss
|
|
5939
|
+
// replay and could abort an unrelated later prompt. Persist a
|
|
5940
|
+
// bounded marker-failure reservation first (review thread P2).
|
|
5941
|
+
const reservation = await reserveTerminalNoEffect("marker_failure");
|
|
5942
|
+
if (reservation === "conflict") return { ok: false as const, reason: "conflict" as const };
|
|
5943
|
+
if (reservation === "failed") {
|
|
5944
|
+
return { ok: false as const, reason: "reservation_failed" as const };
|
|
5945
|
+
}
|
|
5946
|
+
if (existingReplay) return replayExisting(existingReplay);
|
|
5947
|
+
return { ok: true as const, outcome: "no_effect" as const };
|
|
5948
|
+
}
|
|
5949
|
+
if (pendingReplay) {
|
|
5950
|
+
// An in-flight duplicate of this exact key+input was already
|
|
5951
|
+
// admitted; replay its pending row WITHOUT re-running the stop,
|
|
5952
|
+
// cleanup, or event.
|
|
5953
|
+
return {
|
|
5954
|
+
ok: true as const,
|
|
5955
|
+
outcome: "pending_replay" as const,
|
|
5956
|
+
stored: {
|
|
5957
|
+
responseState: pendingReplay.responseState,
|
|
5958
|
+
responsePayloadHash: pendingReplay.responsePayloadHash,
|
|
5959
|
+
terminalPublished: pendingReplay.terminalPublished === true,
|
|
5960
|
+
},
|
|
5961
|
+
};
|
|
5962
|
+
}
|
|
5963
|
+
if (tombstoneReplay) {
|
|
5964
|
+
// The key gained durable replay authority via an eviction
|
|
5965
|
+
// tombstone while this request was in flight; never install a
|
|
5966
|
+
// second marker or run the stop. Reconstruct the ORIGINAL stored
|
|
5967
|
+
// result exactly as the same-key replay path would (review
|
|
5968
|
+
// thread P2).
|
|
5969
|
+
const storedRow = {
|
|
5970
|
+
responseState: tombstoneReplay.responseState ?? "",
|
|
5971
|
+
responsePayloadHash: tombstoneReplay.responsePayloadHash ?? "",
|
|
5972
|
+
terminalPublished: tombstoneReplay.terminalPublished === true,
|
|
5973
|
+
};
|
|
5974
|
+
if (tombstoneReplay.turnDisposition === "stopped") {
|
|
5975
|
+
return {
|
|
5976
|
+
ok: true as const,
|
|
5977
|
+
outcome: (tombstoneReplay.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as
|
|
5978
|
+
| "stopped"
|
|
5979
|
+
| "stopped_owned",
|
|
5980
|
+
stored: storedRow,
|
|
5981
|
+
};
|
|
5982
|
+
}
|
|
5983
|
+
if (tombstoneReplay.turnDisposition === "uncertain") {
|
|
5984
|
+
return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
|
|
5985
|
+
}
|
|
5986
|
+
if (tombstoneReplay.turnDisposition === "no_effect_marker_failure") {
|
|
5987
|
+
return { ok: true as const, outcome: "no_effect" as const, stored: storedRow };
|
|
5988
|
+
}
|
|
5989
|
+
return { ok: true as const, outcome: "no_effect_replay" as const, stored: storedRow };
|
|
5990
|
+
}
|
|
5991
|
+
const captured: {
|
|
5992
|
+
proof?: RunSettlementProof & {
|
|
5993
|
+
terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string };
|
|
5994
|
+
};
|
|
5995
|
+
published?: boolean;
|
|
5996
|
+
terminalized?: boolean;
|
|
5997
|
+
} = {};
|
|
5998
|
+
await terminalizePrompt(
|
|
5999
|
+
{ commandId, turnId },
|
|
6000
|
+
{ kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
|
|
6001
|
+
{
|
|
6002
|
+
fence: true,
|
|
6003
|
+
terminal: { scope, ...(steeringSnapshotToken !== undefined ? { steeringSnapshotToken } : {}) },
|
|
6004
|
+
},
|
|
6005
|
+
undefined,
|
|
6006
|
+
captured,
|
|
6007
|
+
);
|
|
6008
|
+
// Success is decided by the terminalizePrompt outcome, NOT by the
|
|
6009
|
+
// submission record: an already-acknowledged prompt is finalized
|
|
6010
|
+
// (deleted) during emission, so a lookup here can return undefined
|
|
6011
|
+
// even for a landed terminal (P1). fail-closed paths leave
|
|
6012
|
+
// terminalized unset.
|
|
6013
|
+
if (captured.terminalized !== true) {
|
|
6014
|
+
// A CONCURRENT abort won the shared submission: settle THIS
|
|
6015
|
+
// request's pending marker to a completed uncertainty state so
|
|
6016
|
+
// the retention bound can evict it — otherwise concurrent
|
|
6017
|
+
// distinct-key aborts leave pending rows that grow the
|
|
6018
|
+
// reconciliation document unboundedly until restart (review
|
|
6019
|
+
// thread P2).
|
|
6020
|
+
try {
|
|
6021
|
+
await transactBoundedTerminalScopes(scopes =>
|
|
6022
|
+
scopes.map(scopeRecord => {
|
|
6023
|
+
const isMarker =
|
|
6024
|
+
(keyHash !== undefined && scopeRecord.idempotencyKeyHash === keyHash) ||
|
|
6025
|
+
(keyHash === undefined &&
|
|
6026
|
+
scopeRecord.selection === scope &&
|
|
6027
|
+
scopeRecord.turnDisposition === "pending");
|
|
6028
|
+
if (!isMarker) return scopeRecord;
|
|
6029
|
+
const responsePayloadHash = hashPublicUncertain("worker_unsettled");
|
|
6030
|
+
return {
|
|
6031
|
+
...scopeRecord,
|
|
6032
|
+
turnDisposition: "uncertain" as const,
|
|
6033
|
+
ownedWorkDisposition: "uncertain" as const,
|
|
6034
|
+
// Hash the public worker_unsettled disposition the client
|
|
6035
|
+
// receives, or the response can never advance the row
|
|
6036
|
+
// (review thread P2).
|
|
6037
|
+
responsePayloadHash,
|
|
6038
|
+
// A same-key retry delivers the replay envelope (with the
|
|
6039
|
+
// replay reason); store its hash so the written replay can
|
|
6040
|
+
// advance the row (review thread P2).
|
|
6041
|
+
replayPayloadHash: hashResult({
|
|
6042
|
+
ok: true,
|
|
6043
|
+
selection: scope,
|
|
6044
|
+
turn: "uncertain",
|
|
6045
|
+
ownedWork: scope === "turn" ? "left_running" : "uncertain",
|
|
6046
|
+
automaticDelivery: scope === "turn" ? "enabled" : "none",
|
|
6047
|
+
resumeOnOwnedCompletion: scope === "turn",
|
|
6048
|
+
reason: "replay_uncertain",
|
|
6049
|
+
replay: {
|
|
6050
|
+
responseState: "pending",
|
|
6051
|
+
responsePayloadHash,
|
|
6052
|
+
terminalPublished: captured.published === true,
|
|
6053
|
+
},
|
|
6054
|
+
}),
|
|
6055
|
+
// Preserve the captured publication bit: if agent_end
|
|
6056
|
+
// was already published before settlement failed, the
|
|
6057
|
+
// implementation will NOT publish a second event, so the
|
|
6058
|
+
// durable row must not claim publication never occurred
|
|
6059
|
+
// (review thread P2).
|
|
6060
|
+
terminalPublished: captured.published === true,
|
|
6061
|
+
};
|
|
6062
|
+
}),
|
|
6063
|
+
);
|
|
6064
|
+
} catch (error) {
|
|
6065
|
+
logger.warn(`sdk: terminal losing-marker settle failed: ${String(error)}`);
|
|
6066
|
+
}
|
|
6067
|
+
return { ok: false as const, reason: "worker_unsettled" as const };
|
|
6068
|
+
}
|
|
6069
|
+
// For scope:"owned", stop the exact captured owned work and prove
|
|
6070
|
+
// quiescence before claiming stopped. Exactness comes from the
|
|
6071
|
+
// registered five-tuples of this turn's lineage+epoch; foreign or
|
|
6072
|
+
// unclassified work is never swept and yields uncertainty.
|
|
6073
|
+
const terminalScope = captured.proof?.terminalScope;
|
|
6074
|
+
let ownedStopped = true;
|
|
6075
|
+
// Owned settlement failed (incomplete authority, unavailable
|
|
6076
|
+
// manager, or a job that did not unwind within the grace):
|
|
6077
|
+
// transition this request's PENDING marker to completed
|
|
6078
|
+
// uncertainty BEFORE returning — otherwise distinct-key retries
|
|
6079
|
+
// accumulate non-evictable pending rows and same-key retries
|
|
6080
|
+
// misleadingly replay pending (review thread P2).
|
|
6081
|
+
const settleMarkerToUncertain = async (
|
|
6082
|
+
reason: "worker_unsettled" | "owned_unsettled",
|
|
6083
|
+
): Promise<{ ok: false; reason: "worker_unsettled" | "owned_unsettled" }> => {
|
|
6084
|
+
// The EXACT response payload is stored at finalization so the
|
|
6085
|
+
// response-state advance requires equality (review thread P2).
|
|
6086
|
+
// The EXACT PUBLIC payload is stored at finalization: abortTerminal
|
|
6087
|
+
// maps {ok:false, reason} to the uncertain disposition the client
|
|
6088
|
+
// receives, and the response-state observer hashes response.result,
|
|
6089
|
+
// so the internal outcome shape can never match (review thread P2).
|
|
6090
|
+
const result = { ok: false as const, reason };
|
|
6091
|
+
const responsePayloadHash = hashPublicUncertain(reason);
|
|
6092
|
+
// A same-key retry delivers the replay envelope (with the replay
|
|
6093
|
+
// reason); store its hash so the written replay can advance the
|
|
6094
|
+
// row (review thread P2).
|
|
6095
|
+
const replayPayloadHash = hashResult({
|
|
6096
|
+
ok: true,
|
|
6097
|
+
selection: scope,
|
|
6098
|
+
turn: "uncertain",
|
|
6099
|
+
ownedWork: scope === "turn" ? "left_running" : "uncertain",
|
|
6100
|
+
automaticDelivery: scope === "turn" ? "enabled" : "none",
|
|
6101
|
+
resumeOnOwnedCompletion: scope === "turn",
|
|
6102
|
+
reason: "replay_uncertain",
|
|
6103
|
+
replay: {
|
|
6104
|
+
responseState: "pending",
|
|
6105
|
+
responsePayloadHash,
|
|
6106
|
+
terminalPublished: captured.published === true,
|
|
6107
|
+
},
|
|
6108
|
+
});
|
|
6109
|
+
try {
|
|
6110
|
+
await transactBoundedTerminalScopes(scopes =>
|
|
6111
|
+
scopes.map(scopeRecord => {
|
|
6112
|
+
const isMarker =
|
|
6113
|
+
(keyHash !== undefined && scopeRecord.idempotencyKeyHash === keyHash) ||
|
|
6114
|
+
(keyHash === undefined &&
|
|
6115
|
+
scopeRecord.selection === scope &&
|
|
6116
|
+
scopeRecord.turnDisposition === "pending");
|
|
6117
|
+
if (!isMarker) return scopeRecord;
|
|
6118
|
+
return {
|
|
6119
|
+
...scopeRecord,
|
|
6120
|
+
turnDisposition: "uncertain" as const,
|
|
6121
|
+
ownedWorkDisposition: "uncertain" as const,
|
|
6122
|
+
responsePayloadHash,
|
|
6123
|
+
replayPayloadHash,
|
|
6124
|
+
// Preserve the captured publication bit: if agent_end
|
|
6125
|
+
// was already published before settlement failed, the
|
|
6126
|
+
// implementation will NOT publish a second event, so the
|
|
6127
|
+
// durable row must not claim publication never occurred
|
|
6128
|
+
// (review thread P2).
|
|
6129
|
+
terminalPublished: captured.published === true,
|
|
6130
|
+
};
|
|
6131
|
+
}),
|
|
6132
|
+
);
|
|
6133
|
+
} catch (error) {
|
|
6134
|
+
logger.warn(`sdk: terminal owned-uncertain marker settle failed: ${String(error)}`);
|
|
6135
|
+
}
|
|
6136
|
+
return result;
|
|
6137
|
+
};
|
|
6138
|
+
const settleOwnedUncertain = async () => await settleMarkerToUncertain("owned_unsettled");
|
|
6139
|
+
if (!terminalScope) {
|
|
6140
|
+
// The terminal scope was NOT registered (process-wide
|
|
6141
|
+
// saturation refused admission): no continuation fence bounds
|
|
6142
|
+
// the aborted turn. FAIL CLOSED to uncertainty — never report
|
|
6143
|
+
// stopped/stopped_owned without a registered fence (review
|
|
6144
|
+
// thread P2).
|
|
6145
|
+
return await settleMarkerToUncertain("worker_unsettled");
|
|
6146
|
+
}
|
|
6147
|
+
if (scope === "owned") {
|
|
6148
|
+
// The attempt's registration set may be KNOWN incomplete
|
|
6149
|
+
// (registry saturation skipped a tuple under the 8192 cap):
|
|
6150
|
+
// fail closed to uncertainty instead of settling an
|
|
6151
|
+
// incomplete set and claiming stopped_owned while a live job
|
|
6152
|
+
// keeps running (review thread P2).
|
|
6153
|
+
if (
|
|
6154
|
+
terminalScope &&
|
|
6155
|
+
isOwnedAttemptRegistrationIncomplete(terminalScope.lineageIdHash, terminalScope.abortedAttemptEpoch)
|
|
6156
|
+
) {
|
|
6157
|
+
return await settleOwnedUncertain();
|
|
6158
|
+
}
|
|
6159
|
+
const exactJobs = terminalScope
|
|
6160
|
+
? findOwnedRegistrationsForTurn(terminalScope.lineageIdHash, terminalScope.abortedAttemptEpoch)
|
|
6161
|
+
: [];
|
|
6162
|
+
if (exactJobs.length > 0) {
|
|
6163
|
+
// Resolve the manager from the ABORTING ENDPOINT (session)
|
|
6164
|
+
// captured on the registrations — the process-global
|
|
6165
|
+
// instance is the last-created session, so using it could
|
|
6166
|
+
// cancel another session's same-id foreign job and report
|
|
6167
|
+
// stopped_owned while the aborting session's job keeps
|
|
6168
|
+
// running (review thread P1). Fall back to the global for
|
|
6169
|
+
// legacy endpoint-less registrations.
|
|
6170
|
+
const endpointId = exactJobs[0]?.endpointId;
|
|
6171
|
+
const manager = AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
|
|
6172
|
+
if (!manager) {
|
|
6173
|
+
return await settleOwnedUncertain();
|
|
6174
|
+
}
|
|
6175
|
+
ownedStopped = (await settleOwnedWork(manager, exactJobs, OWNED_SETTLEMENT_GRACE_MS)) === "stopped";
|
|
6176
|
+
if (!ownedStopped) return await settleOwnedUncertain();
|
|
6177
|
+
}
|
|
6178
|
+
}
|
|
6179
|
+
// Semantic CAS: advance the INITIAL MARKER (matched by key hash, or
|
|
6180
|
+
// by selection+epoch when keyless) to the final dispositions through
|
|
6181
|
+
// the same full-document owner (plan step 15). The prompt terminal
|
|
6182
|
+
// is already durable; a failed write fails closed to safe
|
|
6183
|
+
// uncertainty — never a stopped disposition the record cannot prove.
|
|
6184
|
+
try {
|
|
6185
|
+
await transactBoundedTerminalScopes(scopes =>
|
|
6186
|
+
scopes.map(scopeRecord => {
|
|
6187
|
+
const isMarker =
|
|
6188
|
+
(keyHash !== undefined && scopeRecord.idempotencyKeyHash === keyHash) ||
|
|
6189
|
+
(keyHash === undefined &&
|
|
6190
|
+
scopeRecord.selection === scope &&
|
|
6191
|
+
scopeRecord.turnDisposition === "pending");
|
|
6192
|
+
if (!isMarker) return scopeRecord;
|
|
6193
|
+
const ownedWorkDisposition =
|
|
6194
|
+
scope === "turn" ? "left_running" : ownedStopped ? "stopped" : "uncertain";
|
|
6195
|
+
// Hash the EXACT public result the client receives: the surface
|
|
6196
|
+
// returns {ok: true, selection, turn, ...} and the delivery
|
|
6197
|
+
// observer hashes response.result, so the stored hash must
|
|
6198
|
+
// include ok or payloadMatches would never match a fresh
|
|
6199
|
+
// stopped/stopped_owned response and the durable responseState
|
|
6200
|
+
// would stay pending after a successful write (review thread
|
|
6201
|
+
// P2).
|
|
6202
|
+
const payloadHash = crypto
|
|
6203
|
+
.createHash("sha256")
|
|
6204
|
+
.update(
|
|
6205
|
+
JSON.stringify({
|
|
6206
|
+
ok: true,
|
|
6207
|
+
selection: scope,
|
|
6208
|
+
turn: "stopped",
|
|
6209
|
+
ownedWork: ownedWorkDisposition,
|
|
6210
|
+
automaticDelivery: scope === "turn" ? "enabled" : "none",
|
|
6211
|
+
resumeOnOwnedCompletion: scope === "turn",
|
|
6212
|
+
}),
|
|
6213
|
+
)
|
|
6214
|
+
.digest("hex");
|
|
6215
|
+
// A same-key retry delivers the replay envelope; store its
|
|
6216
|
+
// hash too so a written replay can advance the pending row
|
|
6217
|
+
// (review thread P2).
|
|
6218
|
+
const replayPayloadHash = crypto
|
|
6219
|
+
.createHash("sha256")
|
|
6220
|
+
.update(
|
|
6221
|
+
JSON.stringify({
|
|
6222
|
+
ok: true,
|
|
6223
|
+
selection: scope,
|
|
6224
|
+
turn: "stopped",
|
|
6225
|
+
ownedWork: ownedWorkDisposition,
|
|
6226
|
+
automaticDelivery: scope === "turn" ? "enabled" : "none",
|
|
6227
|
+
resumeOnOwnedCompletion: scope === "turn",
|
|
6228
|
+
replay: {
|
|
6229
|
+
responseState: "pending",
|
|
6230
|
+
responsePayloadHash: payloadHash,
|
|
6231
|
+
terminalPublished: captured.published === true,
|
|
6232
|
+
},
|
|
6233
|
+
}),
|
|
6234
|
+
)
|
|
6235
|
+
.digest("hex");
|
|
6236
|
+
return {
|
|
6237
|
+
...scopeRecord,
|
|
6238
|
+
turnDisposition: "stopped" as const,
|
|
6239
|
+
terminalPublished: captured.published === true,
|
|
6240
|
+
ownedWorkDisposition,
|
|
6241
|
+
turnContinuationFence: {
|
|
6242
|
+
...scopeRecord.turnContinuationFence,
|
|
6243
|
+
abortedAttemptEpoch:
|
|
6244
|
+
terminalScope?.abortedAttemptEpoch ??
|
|
6245
|
+
scopeRecord.turnContinuationFence.abortedAttemptEpoch,
|
|
6246
|
+
},
|
|
6247
|
+
responsePayloadHash: payloadHash,
|
|
6248
|
+
replayPayloadHash,
|
|
6249
|
+
terminalAt: Date.now(),
|
|
6250
|
+
};
|
|
6251
|
+
}),
|
|
6252
|
+
);
|
|
6253
|
+
} catch (error) {
|
|
6254
|
+
logger.warn(`sdk: terminal scope persistence failed: ${String(error)}`);
|
|
6255
|
+
// The prompt terminalization succeeded but the final marker
|
|
6256
|
+
// write failed: best-effort transition the pending marker to
|
|
6257
|
+
// completed uncertainty so same-key retries do not replay a
|
|
6258
|
+
// stale pending row and repeated failures do not accumulate
|
|
6259
|
+
// non-evictable rows (review thread P2).
|
|
6260
|
+
return await settleMarkerToUncertain("worker_unsettled");
|
|
6261
|
+
}
|
|
6262
|
+
if (scope === "owned") {
|
|
6263
|
+
return { ok: true as const, outcome: "stopped_owned" as const };
|
|
6264
|
+
}
|
|
6265
|
+
return { ok: true as const, outcome: "stopped" as const };
|
|
4651
6266
|
},
|
|
4652
6267
|
{
|
|
4653
6268
|
admit: (clientRef?: string) => kindReconciliation.admit("skill", clientRef),
|
|
4654
6269
|
release: (clientRef?: string) => kindReconciliation.releaseAdmission("skill", clientRef),
|
|
4655
6270
|
noteAccepted: (correlation, clientRef, extra) =>
|
|
4656
6271
|
kindReconciliation.noteAccepted("skill", correlation, clientRef, extra),
|
|
6272
|
+
cancel: async correlation => {
|
|
6273
|
+
const outcome = await kindReconciliation.claimPendingOutcome("skill", correlation, {
|
|
6274
|
+
kind: "stopped",
|
|
6275
|
+
reason: "cancelled",
|
|
6276
|
+
provenance: "client_cancel",
|
|
6277
|
+
});
|
|
6278
|
+
await kindReconciliation.finalizeOutcome("skill", correlation, outcome);
|
|
6279
|
+
},
|
|
4657
6280
|
noteTransition: (correlation, frame) => kindReconciliation.noteTransition("skill", correlation, frame),
|
|
4658
6281
|
lookup: selector => kindReconciliation.lookup("skill", selector),
|
|
6282
|
+
reserveSteer: kindReconciliation.reserveSteer,
|
|
6283
|
+
settleSteer: kindReconciliation.settleSteer,
|
|
4659
6284
|
},
|
|
6285
|
+
terminalAbortSeams,
|
|
4660
6286
|
);
|
|
4661
6287
|
cancelPreflightsForConnection = controlSurface.cancelPendingPreflightsForConnection;
|
|
4662
6288
|
const abandonPromptResponse = (connectionId: string, frame: Record<string, unknown>) => {
|
|
@@ -4677,10 +6303,11 @@ export function createNotificationsExtension(
|
|
|
4677
6303
|
abandonPrompt(submission);
|
|
4678
6304
|
};
|
|
4679
6305
|
|
|
4680
|
-
const sendSdkFrame = (connectionId: string, frame: Record<string, unknown>) => {
|
|
6306
|
+
const sendSdkFrame = (connectionId: string, frame: Record<string, unknown>): "written" | "dropped" => {
|
|
4681
6307
|
if (extensionShuttingDown || runtime?.stopping || runtimes.get(id) !== runtime) {
|
|
6308
|
+
// Deliberate drop (AC 17/20): no write, no post-write hook, no fallback.
|
|
4682
6309
|
abandonPromptResponse(connectionId, frame);
|
|
4683
|
-
return;
|
|
6310
|
+
return "dropped";
|
|
4684
6311
|
}
|
|
4685
6312
|
const json = JSON.stringify(frame);
|
|
4686
6313
|
if (connectionId.startsWith("seam:")) {
|
|
@@ -4697,7 +6324,7 @@ export function createNotificationsExtension(
|
|
|
4697
6324
|
abandonPromptResponse(connectionId, frame);
|
|
4698
6325
|
throw error;
|
|
4699
6326
|
}
|
|
4700
|
-
return;
|
|
6327
|
+
return "written";
|
|
4701
6328
|
}
|
|
4702
6329
|
try {
|
|
4703
6330
|
server.sendTo(connectionId, json);
|
|
@@ -4706,6 +6333,7 @@ export function createNotificationsExtension(
|
|
|
4706
6333
|
abandonPromptResponse(connectionId, frame);
|
|
4707
6334
|
throw error;
|
|
4708
6335
|
}
|
|
6336
|
+
return "written";
|
|
4709
6337
|
};
|
|
4710
6338
|
|
|
4711
6339
|
/**
|
|
@@ -4780,7 +6408,7 @@ export function createNotificationsExtension(
|
|
|
4780
6408
|
},
|
|
4781
6409
|
start: async () => await server.start(),
|
|
4782
6410
|
stop: async () => await server.stopAndWait(),
|
|
4783
|
-
broadcastFrame: frame =>
|
|
6411
|
+
broadcastFrame: frame => broadcastEventFrame(frame),
|
|
4784
6412
|
},
|
|
4785
6413
|
...(preparesExistingThread ? { readiness: "deferred" as const } : {}),
|
|
4786
6414
|
...(activationGate ? { activationGate } : {}),
|
|
@@ -4790,6 +6418,105 @@ export function createNotificationsExtension(
|
|
|
4790
6418
|
installProviderDefinitions,
|
|
4791
6419
|
onProviderDefinitionsRemoved: removeProviderDefinitions,
|
|
4792
6420
|
onRequest: options.onSdkRequest,
|
|
6421
|
+
onControlResponseDelivery: async (_connectionId, request, response, outcome) => {
|
|
6422
|
+
// Terminal abort: persist the monotonic response-state transition
|
|
6423
|
+
// (AC 18) — pending -> sent on a written response, pending -> failed
|
|
6424
|
+
// on a rejected/dropped write. A same-key retry then replays the
|
|
6425
|
+
// stored disposition with the matching response state.
|
|
6426
|
+
if (
|
|
6427
|
+
request.operation === "turn.abort" &&
|
|
6428
|
+
typeof request.input === "object" &&
|
|
6429
|
+
request.input !== null &&
|
|
6430
|
+
(request.input as { mode?: unknown }).mode === "terminal" &&
|
|
6431
|
+
typeof request.idempotencyKey === "string" &&
|
|
6432
|
+
durableStore
|
|
6433
|
+
) {
|
|
6434
|
+
const keyHash = crypto.createHash("sha256").update(request.idempotencyKey).digest("hex");
|
|
6435
|
+
// Match the NORMALIZED terminal input hash too: a same-key
|
|
6436
|
+
// request with a different scope (conflict after in-memory
|
|
6437
|
+
// eviction) must never advance the ORIGINAL pending marker's
|
|
6438
|
+
// response state — only the response for the exact input that
|
|
6439
|
+
// produced the record may transition it (review thread P2).
|
|
6440
|
+
// Strictly validate first: a MALFORMED retry (e.g. scope:"bogus")
|
|
6441
|
+
// rejected by dispatch must not match a prior valid scope:"turn"
|
|
6442
|
+
// row through the "not owned => turn" fallback.
|
|
6443
|
+
const input = request.input as Record<string, unknown>;
|
|
6444
|
+
const mode = input.mode;
|
|
6445
|
+
const rawScope = input.scope;
|
|
6446
|
+
if (mode !== "terminal") return;
|
|
6447
|
+
if (rawScope !== undefined && rawScope !== "turn" && rawScope !== "owned") return;
|
|
6448
|
+
for (const key of Object.keys(input)) if (key !== "mode" && key !== "scope") return;
|
|
6449
|
+
const scopeInput = rawScope === "owned" ? "owned" : "turn";
|
|
6450
|
+
const inputHash = crypto
|
|
6451
|
+
.createHash("sha256")
|
|
6452
|
+
.update(JSON.stringify({ mode: "terminal", scope: scopeInput }))
|
|
6453
|
+
.digest("hex");
|
|
6454
|
+
try {
|
|
6455
|
+
// Same hydration barrier as the abort path: never race a still
|
|
6456
|
+
// pending store load with the response-state transition (P2).
|
|
6457
|
+
await reconciliationReady;
|
|
6458
|
+
// Update the matching LIVE scope OR its evicted compact
|
|
6459
|
+
// tombstone atomically: if the row was moved to
|
|
6460
|
+
// evictedTerminalKeys by the 256-row retention cap while this
|
|
6461
|
+
// write was in flight, scanning only live scope rows would
|
|
6462
|
+
// leave the tombstone responseState:"pending" and a same-key
|
|
6463
|
+
// replay after cache expiry/restart would report a false
|
|
6464
|
+
// durable delivery state (review thread P2).
|
|
6465
|
+
// Hash the ACTUAL written response payload: the durable state may only
|
|
6466
|
+
// advance when the written response corresponds to the row's payload.
|
|
6467
|
+
// When more than 256 concurrent requests evict an in-flight abort from
|
|
6468
|
+
// the dispatch cache, a same-key retry can return pending_replay while
|
|
6469
|
+
// the original is still terminalizing — matching only key+input would
|
|
6470
|
+
// mark the original marker sent for the retry's uncertainty response,
|
|
6471
|
+
// and the original's later stopped CAS would replace the payload hash
|
|
6472
|
+
// without resetting the state, making durable replay claim the stopped
|
|
6473
|
+
// payload was sent when only the pending response was written (review
|
|
6474
|
+
// thread P2). A final non-pending row whose stored hash is the input
|
|
6475
|
+
// placeholder (no_effect/uncertain) still advances: its own response is
|
|
6476
|
+
// the only one written for it.
|
|
6477
|
+
const responsePayloadHash =
|
|
6478
|
+
response && typeof response === "object" && "result" in response
|
|
6479
|
+
? crypto
|
|
6480
|
+
.createHash("sha256")
|
|
6481
|
+
.update(JSON.stringify((response as { result: unknown }).result))
|
|
6482
|
+
.digest("hex")
|
|
6483
|
+
: undefined;
|
|
6484
|
+
// Require EXACT payload equality: finalization now stores the precise
|
|
6485
|
+
// final response hash for every disposition (including uncertainty and
|
|
6486
|
+
// no-effect), so a pending_replay retry whose payload differs can never
|
|
6487
|
+
// mark the durable row sent (review thread P2). A same-key retry
|
|
6488
|
+
// delivers the replay-shaped payload (replay envelope, and the replay
|
|
6489
|
+
// reason for uncertainty), so finalization also stores its hash and the
|
|
6490
|
+
// written replay response advances the row exactly like the original
|
|
6491
|
+
// response would (review thread P2).
|
|
6492
|
+
const payloadMatches = (record: { responsePayloadHash?: string; replayPayloadHash?: string }) =>
|
|
6493
|
+
responsePayloadHash !== undefined &&
|
|
6494
|
+
(record.responsePayloadHash === responsePayloadHash ||
|
|
6495
|
+
record.replayPayloadHash === responsePayloadHash);
|
|
6496
|
+
const nextResponseState: "sent" | "failed" = outcome === "written" ? "sent" : "failed";
|
|
6497
|
+
await durableStore.transactTerminalState(state => ({
|
|
6498
|
+
scopes: state.scopes.map(scope =>
|
|
6499
|
+
scope.idempotencyKeyHash === keyHash &&
|
|
6500
|
+
scope.idempotencyInputHash === inputHash &&
|
|
6501
|
+
scope.responseState === "pending" &&
|
|
6502
|
+
payloadMatches(scope)
|
|
6503
|
+
? { ...scope, responseState: nextResponseState }
|
|
6504
|
+
: scope,
|
|
6505
|
+
),
|
|
6506
|
+
keys: state.keys.map(key =>
|
|
6507
|
+
key.keyHash === keyHash &&
|
|
6508
|
+
key.inputHash === inputHash &&
|
|
6509
|
+
key.responseState === "pending" &&
|
|
6510
|
+
payloadMatches(key)
|
|
6511
|
+
? { ...key, responseState: nextResponseState }
|
|
6512
|
+
: key,
|
|
6513
|
+
),
|
|
6514
|
+
}));
|
|
6515
|
+
} catch (error) {
|
|
6516
|
+
logger.warn(`sdk: terminal response-state persistence failed: ${String(error)}`);
|
|
6517
|
+
}
|
|
6518
|
+
}
|
|
6519
|
+
},
|
|
4793
6520
|
beforeControlResponse: async (_connectionId, request, response, sendTerminal) => {
|
|
4794
6521
|
if (typeof request.operation !== "string" || !identityControlOperations.has(request.operation)) return;
|
|
4795
6522
|
const pending = deferredIdentityRotation;
|
|
@@ -4863,7 +6590,8 @@ export function createNotificationsExtension(
|
|
|
4863
6590
|
if (
|
|
4864
6591
|
(request.operation === "turn.prompt" ||
|
|
4865
6592
|
request.operation === "turn.follow_up" ||
|
|
4866
|
-
request.operation === "turn.abort_and_prompt"
|
|
6593
|
+
request.operation === "turn.abort_and_prompt" ||
|
|
6594
|
+
request.operation === "skill.invoke") &&
|
|
4867
6595
|
response.ok === true &&
|
|
4868
6596
|
response.result &&
|
|
4869
6597
|
typeof response.result === "object" &&
|
|
@@ -4918,7 +6646,15 @@ export function createNotificationsExtension(
|
|
|
4918
6646
|
dispatchControl(
|
|
4919
6647
|
controlSurface,
|
|
4920
6648
|
OPERATIONS.find(row => row.kind === "control" && row.sdkId === operation),
|
|
4921
|
-
|
|
6649
|
+
{
|
|
6650
|
+
id: requestId,
|
|
6651
|
+
operation,
|
|
6652
|
+
input: request.input,
|
|
6653
|
+
expectedRevision:
|
|
6654
|
+
typeof request.expectedRevision === "string" ? request.expectedRevision : undefined,
|
|
6655
|
+
idempotencyKey: typeof request.idempotencyKey === "string" ? request.idempotencyKey : undefined,
|
|
6656
|
+
confirm: request.confirm === true,
|
|
6657
|
+
},
|
|
4922
6658
|
),
|
|
4923
6659
|
);
|
|
4924
6660
|
|
|
@@ -4930,16 +6666,6 @@ export function createNotificationsExtension(
|
|
|
4930
6666
|
},
|
|
4931
6667
|
query: async (connectionId, frame) => {
|
|
4932
6668
|
const request = frame as { id?: unknown; query?: unknown; input?: unknown; cursor?: unknown };
|
|
4933
|
-
// D1: a non-string top-level cursor is rejected, never silently
|
|
4934
|
-
// dropped into a fresh-snapshot query (parity with the loopback
|
|
4935
|
-
// session-runtime adapter).
|
|
4936
|
-
if (request.cursor !== undefined && typeof request.cursor !== "string")
|
|
4937
|
-
return {
|
|
4938
|
-
type: "query_response",
|
|
4939
|
-
id: typeof request.id === "string" ? request.id : undefined,
|
|
4940
|
-
ok: false,
|
|
4941
|
-
error: { code: "invalid_input", message: "cursor must be a non-empty string" },
|
|
4942
|
-
};
|
|
4943
6669
|
const response = await queryHandlers.dispatch({
|
|
4944
6670
|
id: typeof request.id === "string" ? request.id : undefined,
|
|
4945
6671
|
query: typeof request.query === "string" ? request.query : "",
|
|
@@ -4963,11 +6689,13 @@ export function createNotificationsExtension(
|
|
|
4963
6689
|
runtime = {
|
|
4964
6690
|
server,
|
|
4965
6691
|
host,
|
|
6692
|
+
broadcastEventFrame,
|
|
4966
6693
|
revisions,
|
|
4967
6694
|
cursors,
|
|
4968
6695
|
id,
|
|
4969
6696
|
endpointScope: isolateChatEndpoint ? "chat" : "default",
|
|
4970
6697
|
idleSeq: 0,
|
|
6698
|
+
stopSessionNameObserver: () => {},
|
|
4971
6699
|
pendingInteractive,
|
|
4972
6700
|
brokerRegistrationActive: false,
|
|
4973
6701
|
hostStopped: false,
|
|
@@ -4995,21 +6723,33 @@ export function createNotificationsExtension(
|
|
|
4995
6723
|
cancelPostmortemCleanup: () => {},
|
|
4996
6724
|
|
|
4997
6725
|
redact: true,
|
|
4998
|
-
|
|
6726
|
+
// Provisional policy withholds delivery. This is not a committed redaction
|
|
6727
|
+
// decision: terminal text may be retained in the bounded settlement window
|
|
6728
|
+
// until the parsed policy either clears it for redaction or activates it.
|
|
6729
|
+
committedRedact: false,
|
|
4999
6730
|
policySuspended: true,
|
|
5000
6731
|
verbosity: "lean",
|
|
5001
6732
|
stream: false,
|
|
6733
|
+
settlementWindow: 0,
|
|
5002
6734
|
policyGeneration: 0,
|
|
5003
6735
|
workflowGatePublicationEpoch: 0,
|
|
5004
6736
|
sessionTag: tag,
|
|
5005
6737
|
busy: false,
|
|
5006
6738
|
pendingPromptCorrelations,
|
|
6739
|
+
pendingPromptCorrelationsBySdkRunToken,
|
|
5007
6740
|
activePromptCorrelation: undefined,
|
|
5008
6741
|
bindPromptExecutionHandle,
|
|
5009
|
-
peekPromptPendingOutcome: correlation =>
|
|
6742
|
+
peekPromptPendingOutcome: correlation => {
|
|
6743
|
+
const kind =
|
|
6744
|
+
promptSubmissions.get(promptSubmissionKey(correlation))?.reconciliationKind ?? ("prompt" as const);
|
|
6745
|
+
return kindReconciliation.peekPendingOutcome(kind, correlation);
|
|
6746
|
+
},
|
|
5010
6747
|
terminalizePrompt: (correlation, outcome, extra) => terminalizePrompt(correlation, outcome, {}, extra),
|
|
5011
6748
|
notePromptReconciliation: (correlation, frame) => {
|
|
5012
|
-
|
|
6749
|
+
const kind = correlation
|
|
6750
|
+
? (promptSubmissions.get(promptSubmissionKey(correlation))?.reconciliationKind ?? ("prompt" as const))
|
|
6751
|
+
: "prompt";
|
|
6752
|
+
return kindReconciliation.noteTransition(kind, correlation, frame);
|
|
5013
6753
|
},
|
|
5014
6754
|
emitPromptFailure,
|
|
5015
6755
|
emitPromptLifecycle,
|
|
@@ -5020,7 +6760,6 @@ export function createNotificationsExtension(
|
|
|
5020
6760
|
{ toolName: string; args?: unknown; pendingPhase?: "completed" | "failed" | "cancelled" }
|
|
5021
6761
|
>(),
|
|
5022
6762
|
deferredInboundControls: [],
|
|
5023
|
-
notificationRootRegistration: undefined,
|
|
5024
6763
|
};
|
|
5025
6764
|
const initializedRuntime = runtime;
|
|
5026
6765
|
runtimes.set(id, initializedRuntime);
|
|
@@ -5093,6 +6832,26 @@ export function createNotificationsExtension(
|
|
|
5093
6832
|
);
|
|
5094
6833
|
} catch {}
|
|
5095
6834
|
};
|
|
6835
|
+
const sendInboundAck = (
|
|
6836
|
+
connectionId: string,
|
|
6837
|
+
inbound: { sessionId: string; updateId?: number },
|
|
6838
|
+
state: "accepted" | "rejected" | "dropped",
|
|
6839
|
+
reason?: "inbound_fenced" | "policy_suspended" | "invalid_input" | "injection_failed",
|
|
6840
|
+
): void => {
|
|
6841
|
+
if (typeof inbound.updateId !== "number") return;
|
|
6842
|
+
try {
|
|
6843
|
+
server.sendTo(
|
|
6844
|
+
connectionId,
|
|
6845
|
+
JSON.stringify({
|
|
6846
|
+
type: "inbound_ack",
|
|
6847
|
+
sessionId: inbound.sessionId,
|
|
6848
|
+
updateId: inbound.updateId,
|
|
6849
|
+
state,
|
|
6850
|
+
...(reason ? { reason } : {}),
|
|
6851
|
+
}),
|
|
6852
|
+
);
|
|
6853
|
+
} catch {}
|
|
6854
|
+
};
|
|
5096
6855
|
try {
|
|
5097
6856
|
server.onSdkFrame((err, inbound) => {
|
|
5098
6857
|
if (err) {
|
|
@@ -5150,7 +6909,9 @@ export function createNotificationsExtension(
|
|
|
5150
6909
|
});
|
|
5151
6910
|
server.onConnectionClose((_err, connectionId) => {
|
|
5152
6911
|
if (!connectionId) return;
|
|
5153
|
-
controlSurface
|
|
6912
|
+
void controlSurface
|
|
6913
|
+
.cancelPendingPreflightsForConnection(connectionId)
|
|
6914
|
+
.catch(error => logger.warn(`sdk: failed to cancel disconnected preflight: ${String(error)}`));
|
|
5154
6915
|
host.handleDisconnect(connectionId);
|
|
5155
6916
|
hostCapCache.delete(connectionId);
|
|
5156
6917
|
// The socket is gone, so its fence has nothing left to refuse. Dropping the
|
|
@@ -5164,7 +6925,9 @@ export function createNotificationsExtension(
|
|
|
5164
6925
|
// therefore only abandons delivery; ACP rejects its own local waiter once and
|
|
5165
6926
|
// terminal authority stays with the eventual normalized SDK outcome.
|
|
5166
6927
|
for (const submission of promptSubmissions.values())
|
|
5167
|
-
if (submission.connectionId === connectionId)
|
|
6928
|
+
if (submission.connectionId === connectionId) {
|
|
6929
|
+
abandonPrompt(submission);
|
|
6930
|
+
}
|
|
5168
6931
|
});
|
|
5169
6932
|
|
|
5170
6933
|
server.onReply((err, reply) => {
|
|
@@ -5414,34 +7177,60 @@ export function createNotificationsExtension(
|
|
|
5414
7177
|
|
|
5415
7178
|
// Inbound free-text injection / in-thread config command from a session
|
|
5416
7179
|
// thread (forwarded by the daemon over the WS, fail-closed at the daemon).
|
|
5417
|
-
server.onInbound((err, inbound) => {
|
|
7180
|
+
server.onInbound(async (err, inbound) => {
|
|
5418
7181
|
if (err || !inbound) return;
|
|
5419
|
-
|
|
7182
|
+
const notificationOrigin = hostCapCache.get(inbound.connectionId)?.has(ASK_SELECTED_ACK_CAPABILITY);
|
|
7183
|
+
const admission = notificationInboundAdmission({
|
|
7184
|
+
inboundFenced: initializedRuntime.inboundFenced,
|
|
7185
|
+
policySuspended: runtime?.policySuspended ?? false,
|
|
7186
|
+
notificationOrigin: notificationOrigin ?? false,
|
|
7187
|
+
controlCommand: inbound.kind === "control_command",
|
|
7188
|
+
});
|
|
7189
|
+
if (admission.outcome === "drop" && admission.reason === "inbound_fenced") {
|
|
7190
|
+
sendInboundAck(inbound.connectionId, inbound, "dropped", admission.reason);
|
|
7191
|
+
// A fenced predecessor keeps its native server alive for the terminal
|
|
7192
|
+
// response, so the daemon can still deliver here after it has already
|
|
7193
|
+
// ACKed the user's Telegram message. Dropping without a diagnostic
|
|
7194
|
+
// made that loss undiscoverable (2026-08-14 incident).
|
|
7195
|
+
logger.warn(
|
|
7196
|
+
`notifications: inbound ${inbound.kind} dropped: runtime is inbound-fenced (updateId=${String(
|
|
7197
|
+
(inbound as { updateId?: unknown }).updateId ?? "none",
|
|
7198
|
+
)})`,
|
|
7199
|
+
);
|
|
7200
|
+
return;
|
|
7201
|
+
}
|
|
5420
7202
|
const authenticatedInbound = inbound as typeof inbound & {
|
|
5421
7203
|
connectionId: string;
|
|
5422
7204
|
messageId?: number;
|
|
5423
7205
|
reason?: string;
|
|
5424
7206
|
};
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
)
|
|
5439
|
-
|
|
5440
|
-
});
|
|
5441
|
-
}
|
|
7207
|
+
if (admission.outcome === "defer") {
|
|
7208
|
+
// Provisional policy defers valid control commands to activate(); they are
|
|
7209
|
+
// NOT dropped, so no terminal dropped acknowledgement is emitted. The
|
|
7210
|
+
// control_command_result frame after execution is the authoritative reply.
|
|
7211
|
+
const frame = sdkInboundFrame(inbound.commandJson);
|
|
7212
|
+
if (frame) {
|
|
7213
|
+
const suspendedRuntime = runtime;
|
|
7214
|
+
runtime.deferredInboundControls.push(() => {
|
|
7215
|
+
if (
|
|
7216
|
+
runtimes.get(id) === suspendedRuntime &&
|
|
7217
|
+
!suspendedRuntime.stopping &&
|
|
7218
|
+
!suspendedRuntime.policySuspended
|
|
7219
|
+
)
|
|
7220
|
+
inboundSdkFrame?.(`seam:${inbound.requestId ?? "notification"}`, frame);
|
|
7221
|
+
});
|
|
5442
7222
|
}
|
|
5443
7223
|
return;
|
|
5444
7224
|
}
|
|
7225
|
+
if (admission.outcome === "drop") {
|
|
7226
|
+
sendInboundAck(authenticatedInbound.connectionId, authenticatedInbound, "dropped", admission.reason);
|
|
7227
|
+
logger.warn(
|
|
7228
|
+
`notifications: inbound ${inbound.kind} dropped: notification policy is suspended (updateId=${String(
|
|
7229
|
+
(inbound as { updateId?: unknown }).updateId ?? "none",
|
|
7230
|
+
)})`,
|
|
7231
|
+
);
|
|
7232
|
+
return;
|
|
7233
|
+
}
|
|
5445
7234
|
if (inbound.kind === "control_command") {
|
|
5446
7235
|
const frame = sdkInboundFrame(inbound.commandJson);
|
|
5447
7236
|
if (frame) {
|
|
@@ -5472,12 +7261,14 @@ export function createNotificationsExtension(
|
|
|
5472
7261
|
if (inbound.kind === "user_message") {
|
|
5473
7262
|
// Inject as a user turn (steers/continues the agent; the resulting
|
|
5474
7263
|
// turn streams back via the turn_end handler even when not idle).
|
|
5475
|
-
//
|
|
5476
|
-
//
|
|
7264
|
+
// Session-side acceptance is explicit: the daemon must not leave its
|
|
7265
|
+
// optimistic queued reaction in place when this live host rejects/drop.
|
|
5477
7266
|
const text = inbound.text ?? "";
|
|
5478
7267
|
const images = inbound.images ?? [];
|
|
5479
|
-
if (!text && images.length === 0)
|
|
5480
|
-
|
|
7268
|
+
if (!text && images.length === 0) {
|
|
7269
|
+
sendInboundAck(authenticatedInbound.connectionId, authenticatedInbound, "rejected", "invalid_input");
|
|
7270
|
+
return;
|
|
7271
|
+
}
|
|
5481
7272
|
const content: string | (TextContent | ImageContent)[] =
|
|
5482
7273
|
images.length > 0
|
|
5483
7274
|
? [
|
|
@@ -5492,9 +7283,35 @@ export function createNotificationsExtension(
|
|
|
5492
7283
|
),
|
|
5493
7284
|
]
|
|
5494
7285
|
: text;
|
|
7286
|
+
let acceptedSent = false;
|
|
7287
|
+
const acceptAdmission = (): void => {
|
|
7288
|
+
// Fired by AgentSession's preflight acceptance: after admission has
|
|
7289
|
+
// committed but before the turn starts. Registering the update id and
|
|
7290
|
+
// acking here means turn_start can no longer race ahead of the
|
|
7291
|
+
// pendingInbound registration it consumes.
|
|
7292
|
+
if (acceptedSent) return;
|
|
7293
|
+
acceptedSent = true;
|
|
7294
|
+
if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
|
|
7295
|
+
sendInboundAck(authenticatedInbound.connectionId, authenticatedInbound, "accepted");
|
|
7296
|
+
};
|
|
5495
7297
|
try {
|
|
5496
|
-
|
|
7298
|
+
// sendUserMessage is async and settles only after the full prompt, so
|
|
7299
|
+
// acceptance is signalled by the preflight-acceptance callback instead
|
|
7300
|
+
// of after the await; a rejection (preflight, admission, or later)
|
|
7301
|
+
// still reaches the catch and maps to a rejected ack.
|
|
7302
|
+
await api.sendUserMessage(content, {
|
|
7303
|
+
...(runtime?.busy ? { deliverAs: "steer" as const } : {}),
|
|
7304
|
+
onPreflightAcceptCommit: acceptAdmission,
|
|
7305
|
+
onPreflightAccepted: acceptAdmission,
|
|
7306
|
+
});
|
|
7307
|
+
acceptAdmission();
|
|
5497
7308
|
} catch (e) {
|
|
7309
|
+
sendInboundAck(
|
|
7310
|
+
authenticatedInbound.connectionId,
|
|
7311
|
+
authenticatedInbound,
|
|
7312
|
+
"rejected",
|
|
7313
|
+
"injection_failed",
|
|
7314
|
+
);
|
|
5498
7315
|
logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
|
|
5499
7316
|
}
|
|
5500
7317
|
return;
|
|
@@ -5574,44 +7391,42 @@ export function createNotificationsExtension(
|
|
|
5574
7391
|
await cleanupAbandonedStartup();
|
|
5575
7392
|
return { status: "failed" };
|
|
5576
7393
|
}
|
|
5577
|
-
|
|
5578
|
-
//
|
|
5579
|
-
//
|
|
7394
|
+
|
|
7395
|
+
// Fail-closed daemon isolation: the chat daemons (Telegram, Discord,
|
|
7396
|
+
// Slack) are optional notification adapters, never session authority.
|
|
7397
|
+
// No daemon ownership is acquired, awaited, or verified before core
|
|
7398
|
+
// publication — a slow, wedged, blocked, or crashed daemon must degrade
|
|
7399
|
+
// ONLY notification delivery, never an ACP/MCP session open. Native
|
|
7400
|
+
// frames are ephemeral, so publish identity first; late SDK consumers
|
|
7401
|
+
// recover it from event_replay.
|
|
5580
7402
|
const identityHeader = {
|
|
5581
7403
|
type: "identity_header",
|
|
5582
7404
|
sessionId: id,
|
|
5583
|
-
...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
|
|
7405
|
+
...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName(), telegramTopicsEnabled()),
|
|
5584
7406
|
};
|
|
5585
|
-
|
|
5586
|
-
// Core SDK authority is published before optional notification adapters acquire
|
|
5587
|
-
// daemon ownership. A blocked adapter must not make an interactive session
|
|
5588
|
-
// undiscoverable or uncontrollable.
|
|
7407
|
+
emitSessionEvent(initializedRuntime, identityHeader);
|
|
5589
7408
|
const endpoint = await sdkRuntime.startTransport();
|
|
5590
7409
|
initializedRuntime.notificationOwnerState = "ready";
|
|
5591
7410
|
if (notificationsEnabledForSession && settingsAvailable && settings) {
|
|
7411
|
+
// Daemon ownership is acquired AFTER the core SDK endpoint is
|
|
7412
|
+
// published, and is deliberately NOT tracked in
|
|
7413
|
+
// `sessionLifecycleTasks`: shutdown joins that set, so a wedged
|
|
7414
|
+
// ensure tracked there would hang teardown. Every write the settled
|
|
7415
|
+
// outcome performs is identity-guarded instead. Until it settles the
|
|
7416
|
+
// owner state stays "retry", so activate() withholds notification
|
|
7417
|
+
// adapters and a later reconcile re-attempts.
|
|
5592
7418
|
initializedRuntime.notificationOwnerState = "retry";
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
logger.warn(
|
|
5603
|
-
|
|
5604
|
-
if (ownership === "blocked_identity_with_sibling" && !isolateChatEndpoint) {
|
|
5605
|
-
logger.warn(
|
|
5606
|
-
"notifications: Telegram ownership changed after core publication; preserving the canonical endpoint and withholding adapters.",
|
|
5607
|
-
);
|
|
5608
|
-
}
|
|
5609
|
-
} catch (error) {
|
|
5610
|
-
initializedRuntime.notificationOwnerState = "retry";
|
|
5611
|
-
logger.warn(
|
|
5612
|
-
`notifications: provider daemon ownership unavailable; core SDK remains available: ${String(error)}`,
|
|
5613
|
-
);
|
|
5614
|
-
}
|
|
7419
|
+
initializedRuntime.notificationOwnerKey = daemonOwnershipKey(cfg);
|
|
7420
|
+
const ownershipRuntime = initializedRuntime;
|
|
7421
|
+
kickDaemonOwnership(settings, cfg, (outcome, key) => {
|
|
7422
|
+
applyDaemonOwnership(id, ownershipRuntime, outcome, isolateChatEndpoint, key);
|
|
7423
|
+
// Adapters activate only through reconciliation, so re-run it once
|
|
7424
|
+
// ownership is known. Detached: no lifecycle path awaits this.
|
|
7425
|
+
if (runtimes.get(id) !== ownershipRuntime || ownershipRuntime.stopping || extensionShuttingDown) return;
|
|
7426
|
+
void controller
|
|
7427
|
+
.reconcileCurrentSession(ctx)
|
|
7428
|
+
.catch(error => logger.warn(`notifications: post-ownership reconciliation failed: ${String(error)}`));
|
|
7429
|
+
});
|
|
5615
7430
|
}
|
|
5616
7431
|
|
|
5617
7432
|
// The native server owns the only authoritative view of this host's live
|
|
@@ -5620,7 +7435,10 @@ export function createNotificationsExtension(
|
|
|
5620
7435
|
// runtime's alone, so only this runtime's teardown can retract it.
|
|
5621
7436
|
initializedRuntime.evidencePublication = publishSessionHostRuntimeEvidence({
|
|
5622
7437
|
attachedClients: () => server.clientCount(),
|
|
5623
|
-
workInFlight: () =>
|
|
7438
|
+
workInFlight: () =>
|
|
7439
|
+
initializedRuntime.busy ||
|
|
7440
|
+
initializedRuntime.pendingPromptCorrelations.length > 0 ||
|
|
7441
|
+
initializedRuntime.pendingPromptCorrelationsBySdkRunToken.size > 0,
|
|
5624
7442
|
});
|
|
5625
7443
|
ephemeralTurns.configureAuthority({
|
|
5626
7444
|
sessionId: id,
|
|
@@ -5635,6 +7453,35 @@ export function createNotificationsExtension(
|
|
|
5635
7453
|
}
|
|
5636
7454
|
|
|
5637
7455
|
server.pushFrame(JSON.stringify(identityHeader));
|
|
7456
|
+
let publishedSessionName = identityHeader.title;
|
|
7457
|
+
const sessionNameObserver = setInterval(() => {
|
|
7458
|
+
if (runtime?.stopping || runtimes.get(id) !== runtime) return;
|
|
7459
|
+
if (ctx.sessionManager.getSessionId() !== id) return;
|
|
7460
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
7461
|
+
if (!sessionName || sessionName === publishedSessionName) return;
|
|
7462
|
+
publishedSessionName = sessionName;
|
|
7463
|
+
const identity = {
|
|
7464
|
+
type: "identity_header",
|
|
7465
|
+
sessionId: id,
|
|
7466
|
+
...buildIdentity(ctx.cwd, sessionName, telegramTopicsEnabled()),
|
|
7467
|
+
};
|
|
7468
|
+
emitSessionEvent(initializedRuntime, identity);
|
|
7469
|
+
server.pushFrame(JSON.stringify(identity));
|
|
7470
|
+
}, 250);
|
|
7471
|
+
sessionNameObserver.unref?.();
|
|
7472
|
+
runtime.stopSessionNameObserver = () => clearInterval(sessionNameObserver);
|
|
7473
|
+
const sessionNameAfterStartup =
|
|
7474
|
+
ctx.sessionManager.getSessionId() === id ? ctx.sessionManager.getSessionName() : undefined;
|
|
7475
|
+
if (sessionNameAfterStartup && sessionNameAfterStartup !== publishedSessionName) {
|
|
7476
|
+
publishedSessionName = sessionNameAfterStartup;
|
|
7477
|
+
const identity = {
|
|
7478
|
+
type: "identity_header",
|
|
7479
|
+
sessionId: id,
|
|
7480
|
+
...buildIdentity(ctx.cwd, sessionNameAfterStartup, telegramTopicsEnabled()),
|
|
7481
|
+
};
|
|
7482
|
+
emitSessionEvent(initializedRuntime, identity);
|
|
7483
|
+
server.pushFrame(JSON.stringify(identity));
|
|
7484
|
+
}
|
|
5638
7485
|
const agentDir = lifecycleAgentDir ?? settings?.getAgentDir?.();
|
|
5639
7486
|
if (lifecycleRequired && !agentDir) throw new Error("Lifecycle SDK host requires an agent directory.");
|
|
5640
7487
|
|
|
@@ -5854,7 +7701,7 @@ export function createNotificationsExtension(
|
|
|
5854
7701
|
logger.warn(`notifications: failed to start server: ${String(e)}`);
|
|
5855
7702
|
const result = failLifecycleStartup("failed", e);
|
|
5856
7703
|
finishStartup(result);
|
|
5857
|
-
let suppressExtensionError =
|
|
7704
|
+
let suppressExtensionError = false;
|
|
5858
7705
|
let stopped = false;
|
|
5859
7706
|
try {
|
|
5860
7707
|
stopped = await stopSession(id, "session", runtime);
|
|
@@ -5891,23 +7738,18 @@ export function createNotificationsExtension(
|
|
|
5891
7738
|
if (runtime.notificationOwnerState === "retry") {
|
|
5892
7739
|
const { cfg, settings, settingsAvailable } = resolveSettings(options.settings);
|
|
5893
7740
|
if (!settingsAvailable || !settings) return "failed";
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
7741
|
+
// Settle-or-pending: never await ownership on this path. A pending
|
|
7742
|
+
// ensure leaves the state retryable and adapters withheld; its
|
|
7743
|
+
// settle callback applies the outcome and re-reconciles.
|
|
7744
|
+
const settled = peekDaemonOwnership(cfg);
|
|
7745
|
+
const isolated = runtime.endpointScope === "chat";
|
|
7746
|
+
runtime.notificationOwnerKey = daemonOwnershipKey(cfg);
|
|
7747
|
+
if (settled === undefined) {
|
|
7748
|
+
kickDaemonOwnership(settings, cfg, (outcome, key) =>
|
|
7749
|
+
applyDaemonOwnership(binding.sessionId, runtime, outcome, isolated, key),
|
|
5903
7750
|
);
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
(ownership === "blocked_identity_with_sibling" && runtime.endpointScope === "chat")
|
|
5907
|
-
? "ready"
|
|
5908
|
-
: "blocked";
|
|
5909
|
-
} catch {
|
|
5910
|
-
return "failed";
|
|
7751
|
+
} else {
|
|
7752
|
+
applyDaemonOwnership(binding.sessionId, runtime, settled, isolated, runtime.notificationOwnerKey);
|
|
5911
7753
|
}
|
|
5912
7754
|
}
|
|
5913
7755
|
return "started";
|
|
@@ -5958,8 +7800,11 @@ export function createNotificationsExtension(
|
|
|
5958
7800
|
runtime.redact = policy.redact;
|
|
5959
7801
|
runtime.verbosity = policy.verbosity;
|
|
5960
7802
|
runtime.stream = policy.stream;
|
|
5961
|
-
if (redactionEnabled)
|
|
5962
|
-
|
|
7803
|
+
if (redactionEnabled) {
|
|
7804
|
+
runtime.pendingFinal = undefined;
|
|
7805
|
+
runtime.pendingSettled = undefined;
|
|
7806
|
+
terminalizeInFlightTools(runtime, runtime.id, "cancelled", true);
|
|
7807
|
+
} else if (wasPolicySuspended && !policy.redact) settleProvisionalToolTerminals(runtime, runtime.id);
|
|
5963
7808
|
},
|
|
5964
7809
|
activate: binding => {
|
|
5965
7810
|
const runtime = runtimes.get(binding.sessionId);
|
|
@@ -5972,34 +7817,89 @@ export function createNotificationsExtension(
|
|
|
5972
7817
|
runtime.gatePresentations?.setPublicationSuspended(false);
|
|
5973
7818
|
runtime.gatePresentations?.activateDeferred(runtime.workflowGatePublicationEpoch);
|
|
5974
7819
|
flushPendingFinal(runtime, runtime.id);
|
|
7820
|
+
flushPendingSettled(runtime, runtime.id);
|
|
5975
7821
|
for (const processControl of runtime.deferredInboundControls.splice(0)) processControl();
|
|
5976
7822
|
},
|
|
7823
|
+
reproveOwnership: async binding => {
|
|
7824
|
+
// PROVIDER-NEUTRAL ownership re-proof. Reconciliation calls this for any
|
|
7825
|
+
// effective chat provider, not just Telegram: a Discord-only or
|
|
7826
|
+
// Slack-only credential/destination/actor change must withhold adapters
|
|
7827
|
+
// exactly like a Telegram one, or the old identity keeps delivery and
|
|
7828
|
+
// (for Slack) inbound command authority through a stale outcome.
|
|
7829
|
+
//
|
|
7830
|
+
// Never awaits the ensure itself — only the adapter teardown, which is
|
|
7831
|
+
// local. Lifecycle paths must not block on daemon ownership.
|
|
7832
|
+
const { settings, settingsAvailable, cfg } = resolveSettings(options.settings);
|
|
7833
|
+
if (!settingsAvailable || !settings) return;
|
|
7834
|
+
const key = daemonOwnershipKey(cfg);
|
|
7835
|
+
const sessionIdForOwnership = binding.sessionId;
|
|
7836
|
+
// The binding is released when reconciliation returns, so capture the
|
|
7837
|
+
// context eagerly: the settle callback runs strictly later.
|
|
7838
|
+
const ownershipCtx = binding.context;
|
|
7839
|
+
const runtime = runtimes.get(sessionIdForOwnership);
|
|
7840
|
+
if (!runtime || runtime.notificationOwnerKey === key) return;
|
|
7841
|
+
// The ownership-relevant configuration changed under a runtime whose
|
|
7842
|
+
// LOCAL adapters were authorized for the OLD configuration. Withhold
|
|
7843
|
+
// them first (`stop` with reason "notifications" tears down adapters
|
|
7844
|
+
// and answer sources while leaving the core SDK host untouched), then
|
|
7845
|
+
// re-prove.
|
|
7846
|
+
//
|
|
7847
|
+
// SCOPE: this does NOT revoke an already-attached external Discord or
|
|
7848
|
+
// Slack daemon's SessionRouter attachment. Such a daemon can still
|
|
7849
|
+
// observe host events and reach the host's inbound/control paths until
|
|
7850
|
+
// it notices the change itself. Closing that requires one coherent
|
|
7851
|
+
// authenticated chat-attachment authority boundary at the Router layer
|
|
7852
|
+
// (provenance per connection, revocation on re-proof and on
|
|
7853
|
+
// last-provider disable, covering replies and raw v3 frames uniformly).
|
|
7854
|
+
// That is tracked as a follow-up; per-callback heuristics here were
|
|
7855
|
+
// tried and reverted because they were bypassable and, on a blocked
|
|
7856
|
+
// identity, silently refused legitimate Telegram inbound forever.
|
|
7857
|
+
runtime.notificationOwnerKey = key;
|
|
7858
|
+
runtime.notificationOwnerState = "retry";
|
|
7859
|
+
if (runtime.notificationsActive) await stopSession(sessionIdForOwnership, "notifications");
|
|
7860
|
+
kickDaemonOwnership(settings, cfg, (outcome, outcomeKey) => {
|
|
7861
|
+
const current = runtimes.get(sessionIdForOwnership);
|
|
7862
|
+
if (!current) return;
|
|
7863
|
+
applyDaemonOwnership(sessionIdForOwnership, current, outcome, current.endpointScope === "chat", outcomeKey);
|
|
7864
|
+
if (current.stopping || extensionShuttingDown) return;
|
|
7865
|
+
// One detached reconciliation so adapters activate once the new
|
|
7866
|
+
// configuration is proved. No lifecycle path awaits this.
|
|
7867
|
+
void controller
|
|
7868
|
+
.reconcileCurrentSession(ownershipCtx)
|
|
7869
|
+
.catch(error => logger.warn(`notifications: post-ownership reconciliation failed: ${String(error)}`));
|
|
7870
|
+
});
|
|
7871
|
+
},
|
|
5977
7872
|
ensureTelegramDaemon: async binding => {
|
|
5978
|
-
const { settings, settingsAvailable } = resolveSettings(options.settings);
|
|
7873
|
+
const { settings, settingsAvailable, cfg } = resolveSettings(options.settings);
|
|
5979
7874
|
if (!settingsAvailable || !settings) return "blocked_identity";
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
7875
|
+
// Reconciliation runs on awaited session-lifecycle paths (session_start,
|
|
7876
|
+
// switch, branch, fork, resume), so this preflight must NEVER await a
|
|
7877
|
+
// daemon ensure. A pending ensure answers "pending": reconciliation
|
|
7878
|
+
// proceeds without activating adapters, and the settle callback
|
|
7879
|
+
// re-reconciles once ownership is known.
|
|
7880
|
+
const sessionIdForOwnership = binding.sessionId;
|
|
7881
|
+
const ownershipCtx = binding.context;
|
|
7882
|
+
const settled = peekDaemonOwnership(cfg);
|
|
7883
|
+
if (settled === undefined) {
|
|
7884
|
+
kickDaemonOwnership(settings, cfg, (outcome, outcomeKey) => {
|
|
7885
|
+
const current = runtimes.get(sessionIdForOwnership);
|
|
7886
|
+
if (!current) return;
|
|
7887
|
+
applyDaemonOwnership(
|
|
7888
|
+
sessionIdForOwnership,
|
|
7889
|
+
current,
|
|
7890
|
+
outcome,
|
|
7891
|
+
current.endpointScope === "chat",
|
|
7892
|
+
outcomeKey,
|
|
7893
|
+
);
|
|
7894
|
+
if (current.stopping || extensionShuttingDown) return;
|
|
7895
|
+
void controller
|
|
7896
|
+
.reconcileCurrentSession(ownershipCtx)
|
|
7897
|
+
.catch(error => logger.warn(`notifications: post-ownership reconciliation failed: ${String(error)}`));
|
|
5984
7898
|
});
|
|
5985
|
-
|
|
5986
|
-
const configured = isProviderEffectivelyEnabled(resolveSettings(options.settings).cfg, "telegram");
|
|
5987
|
-
if (result === "ready" && runtime && !runtime.stopping && configured && registrationToken !== undefined) {
|
|
5988
|
-
runtime.notificationRootRegistration = { settings, cwd: binding.cwd, registrationToken };
|
|
5989
|
-
} else if (registrationToken !== undefined) {
|
|
5990
|
-
await unregisterNotificationRoot({
|
|
5991
|
-
settings,
|
|
5992
|
-
cwd: binding.cwd,
|
|
5993
|
-
sessionId: binding.sessionId,
|
|
5994
|
-
registrationToken,
|
|
5995
|
-
});
|
|
5996
|
-
}
|
|
5997
|
-
if (runtime?.notificationOwnerState === "blocked")
|
|
5998
|
-
runtime.notificationOwnerState = result === "ready" ? "ready" : "blocked";
|
|
5999
|
-
return result;
|
|
6000
|
-
} catch {
|
|
6001
|
-
return "failed";
|
|
7899
|
+
return "pending";
|
|
6002
7900
|
}
|
|
7901
|
+
if (settled === "failed") return "failed";
|
|
7902
|
+
return settled === "ready" ? "ready" : "blocked_identity";
|
|
6003
7903
|
},
|
|
6004
7904
|
};
|
|
6005
7905
|
controller.attachRuntime(sessionRuntime);
|
|
@@ -6150,6 +8050,7 @@ export function createNotificationsExtension(
|
|
|
6150
8050
|
if (!predecessor || cleanupRetries.has(id))
|
|
6151
8051
|
throw new Error(`notifications: predecessor runtime ${id} is not safely send-capable.`);
|
|
6152
8052
|
predecessor.inboundFenced = true;
|
|
8053
|
+
predecessor.stopSessionNameObserver();
|
|
6153
8054
|
// Release broker/host authority while leaving the native server alive for
|
|
6154
8055
|
// the one accepted terminal response. stopAndWait is deferred to stopSession.
|
|
6155
8056
|
const stopped = await predecessor.host.stop();
|
|
@@ -6293,27 +8194,96 @@ export function createNotificationsExtension(
|
|
|
6293
8194
|
rt.lastLiveText = undefined;
|
|
6294
8195
|
};
|
|
6295
8196
|
|
|
8197
|
+
/**
|
|
8198
|
+
* Session messages historically omitted attribution for direct user prompts;
|
|
8199
|
+
* that wire shape remains user-originated. Explicit agent attribution always
|
|
8200
|
+
* wins, including on a role:"user" internal resource notification.
|
|
8201
|
+
*/
|
|
8202
|
+
const isUserSettlementBoundary = (message: { role?: unknown; attribution?: unknown }): boolean =>
|
|
8203
|
+
message.attribution === "user" || (message.role === "user" && message.attribution === undefined);
|
|
8204
|
+
const isFrameworkPromptContext = (message: { role?: unknown; customType?: unknown }): boolean =>
|
|
8205
|
+
message.role === "custom" && message.customType === "volatile-project-context";
|
|
8206
|
+
|
|
8207
|
+
type SettlementOrigin = "user" | "autonomous" | "continuation";
|
|
8208
|
+
|
|
8209
|
+
const deferLeanReceipt = (
|
|
8210
|
+
rt: SessionRuntime,
|
|
8211
|
+
text: string,
|
|
8212
|
+
messageRef?: string,
|
|
8213
|
+
window: number = rt.currentTurnSettlementWindow ?? rt.settlementWindow,
|
|
8214
|
+
origin: SettlementOrigin = rt.currentTurnSettlementOrigin ?? "continuation",
|
|
8215
|
+
): void => {
|
|
8216
|
+
if (window !== rt.settlementWindow) return;
|
|
8217
|
+
const receipt = { text, ...(messageRef ? { messageRef } : {}), origin };
|
|
8218
|
+
const pending = rt.pendingSettled;
|
|
8219
|
+
if (!pending || pending.window !== window) {
|
|
8220
|
+
rt.pendingSettled = { window, receipts: [receipt] };
|
|
8221
|
+
return;
|
|
8222
|
+
}
|
|
8223
|
+
if (origin === "autonomous") {
|
|
8224
|
+
const prior = pending.receipts[pending.receipts.length - 1];
|
|
8225
|
+
if (prior?.text === text) return;
|
|
8226
|
+
// Keep the user-request receipt plus the newest autonomous outcome.
|
|
8227
|
+
pending.receipts = [pending.receipts[0]!, receipt];
|
|
8228
|
+
return;
|
|
8229
|
+
}
|
|
8230
|
+
if (origin === "continuation" && pending.receipts[0]?.origin === "user") {
|
|
8231
|
+
pending.receipts = [pending.receipts[0]!, receipt];
|
|
8232
|
+
return;
|
|
8233
|
+
}
|
|
8234
|
+
pending.receipts = [receipt];
|
|
8235
|
+
};
|
|
8236
|
+
|
|
8237
|
+
const deferProvisionalReceipt = (rt: SessionRuntime, text: string | undefined, messageRef?: string): void => {
|
|
8238
|
+
if (!text) return;
|
|
8239
|
+
const window = rt.currentTurnSettlementWindow ?? rt.settlementWindow;
|
|
8240
|
+
if (window !== rt.settlementWindow) return;
|
|
8241
|
+
const origin = rt.currentTurnSettlementOrigin ?? "continuation";
|
|
8242
|
+
const receipt = { text, ...(messageRef ? { messageRef } : {}), origin };
|
|
8243
|
+
const pending = rt.pendingFinal;
|
|
8244
|
+
if (!pending || pending.window !== window) {
|
|
8245
|
+
rt.pendingFinal = { window, receipts: [receipt] };
|
|
8246
|
+
return;
|
|
8247
|
+
}
|
|
8248
|
+
if (origin === "autonomous") {
|
|
8249
|
+
if (pending.receipts[pending.receipts.length - 1]?.text === text) return;
|
|
8250
|
+
pending.receipts = [pending.receipts[0]!, receipt];
|
|
8251
|
+
return;
|
|
8252
|
+
}
|
|
8253
|
+
if (origin === "continuation" && pending.receipts[0]?.origin === "user") {
|
|
8254
|
+
pending.receipts = [pending.receipts[0]!, receipt];
|
|
8255
|
+
return;
|
|
8256
|
+
}
|
|
8257
|
+
pending.receipts = [receipt];
|
|
8258
|
+
};
|
|
8259
|
+
|
|
8260
|
+
const hasUserSettlementReceipt = (rt: SessionRuntime): boolean => rt.pendingSettled?.receipts[0]?.origin === "user";
|
|
8261
|
+
|
|
6296
8262
|
const flushPendingFinal = (rt: SessionRuntime, id: string): void => {
|
|
6297
8263
|
const pending = rt.pendingFinal;
|
|
6298
8264
|
if (!pending) return;
|
|
6299
8265
|
rt.pendingFinal = undefined;
|
|
6300
|
-
if (pending.
|
|
8266
|
+
if (pending.window !== rt.settlementWindow) return;
|
|
8267
|
+
if (pending.receipts.length && rt.notificationsActive && !rt.redact) {
|
|
6301
8268
|
// Under lean, hold intermediate finals until agent_end when the agent is still
|
|
6302
8269
|
// running so provisional-policy activation cannot reintroduce per-turn spam.
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
8270
|
+
// A receipt retained from an idle reached during provisional policy has the
|
|
8271
|
+
// same settlement window. Merge it before either path publishes so stable
|
|
8272
|
+
// activation emits one composed terminal receipt, not two messages.
|
|
8273
|
+
if (rt.verbosity === "lean" && (rt.busy || rt.pendingSettled?.window === pending.window)) {
|
|
8274
|
+
for (const receipt of pending.receipts)
|
|
8275
|
+
deferLeanReceipt(rt, receipt.text, receipt.messageRef, pending.window, receipt.origin);
|
|
6308
8276
|
} else {
|
|
8277
|
+
const text = pending.receipts.map(receipt => receipt.text).join("\n\n");
|
|
8278
|
+
const messageRef = pending.receipts.length === 1 ? pending.receipts[0]?.messageRef : undefined;
|
|
6309
8279
|
try {
|
|
6310
8280
|
pushSessionFrame(rt, {
|
|
6311
8281
|
type: "turn_stream",
|
|
6312
8282
|
sessionId: id,
|
|
6313
8283
|
phase: "finalized",
|
|
6314
8284
|
finalAnswer: true,
|
|
6315
|
-
text
|
|
6316
|
-
...(
|
|
8285
|
+
text,
|
|
8286
|
+
...(messageRef ? { messageRef } : {}),
|
|
6317
8287
|
});
|
|
6318
8288
|
} catch (error) {
|
|
6319
8289
|
logger.warn(`notifications: pushFrame (pending turn) failed: ${String(error)}`);
|
|
@@ -6326,38 +8296,53 @@ export function createNotificationsExtension(
|
|
|
6326
8296
|
/** Emit the deferred lean settled answer exactly once (agent_end / idle). */
|
|
6327
8297
|
const flushPendingSettled = (rt: SessionRuntime, id: string): void => {
|
|
6328
8298
|
const settled = rt.pendingSettled;
|
|
8299
|
+
if (!settled?.receipts.length || !rt.notificationsActive || rt.redact || rt.policySuspended) return;
|
|
6329
8300
|
rt.pendingSettled = undefined;
|
|
6330
|
-
|
|
8301
|
+
const text = settled.receipts.map(receipt => receipt.text).join("\n\n");
|
|
8302
|
+
// A composition represents multiple finalized turns. It must be a fresh
|
|
8303
|
+
// terminal message rather than editing either constituent turn's stream.
|
|
8304
|
+
const messageRef = settled.receipts.length === 1 ? settled.receipts[0]?.messageRef : undefined;
|
|
6331
8305
|
const previousLiveRef = rt.liveRef;
|
|
6332
|
-
if (
|
|
8306
|
+
if (messageRef) rt.liveRef = messageRef;
|
|
6333
8307
|
try {
|
|
6334
|
-
flushTurnText(rt, id,
|
|
8308
|
+
flushTurnText(rt, id, text, true);
|
|
6335
8309
|
} finally {
|
|
6336
|
-
if (!
|
|
8310
|
+
if (!messageRef) rt.liveRef = previousLiveRef;
|
|
6337
8311
|
}
|
|
6338
8312
|
};
|
|
6339
8313
|
|
|
6340
8314
|
// Drive the live typing indicator: mark busy when the agent loop starts so
|
|
6341
8315
|
// the daemon shows "typing…" in the thread while the agent is thinking,
|
|
6342
8316
|
// before any turn output exists. Cleared on `agent_end` below.
|
|
6343
|
-
api.on("agent_start", (
|
|
8317
|
+
api.on("agent_start", async (event, ctx) => {
|
|
6344
8318
|
const id = sessionId(ctx);
|
|
6345
8319
|
const rt = runtimes.get(id);
|
|
6346
8320
|
if (!rt) return;
|
|
6347
8321
|
// Streaming state is SDK-visible session truth (context.get isStreaming);
|
|
6348
8322
|
// it is tracked regardless of whether notifications are active.
|
|
6349
8323
|
rt.busy = true;
|
|
8324
|
+
// The first prompt message arrives after turn_start. A pre-existing deferred
|
|
8325
|
+
// receipt means a message-less run is autonomous until a direct user prompt
|
|
8326
|
+
// authoritatively overrides it; otherwise start unbound.
|
|
8327
|
+
if (!rt.currentTurnSettlementOrigin && rt.pendingSettled) {
|
|
8328
|
+
rt.currentTurnSettlementOrigin = "autonomous";
|
|
8329
|
+
rt.currentTurnSettlementWindow = rt.settlementWindow;
|
|
8330
|
+
} else if (!rt.currentTurnSettlementOrigin) rt.currentTurnSettlementWindow = undefined;
|
|
6350
8331
|
// A continuation re-enters the agent loop inside the same prompt and emits
|
|
6351
|
-
// another `agent_start`.
|
|
6352
|
-
//
|
|
6353
|
-
|
|
6354
|
-
|
|
6355
|
-
|
|
8332
|
+
// another `agent_start`. Only a queued follow-up's exact SDK token may
|
|
8333
|
+
// claim its correlation; unrelated queue work must not consume it.
|
|
8334
|
+
const sdkRunToken = event.sdkRunToken;
|
|
8335
|
+
const correlation =
|
|
8336
|
+
rt.activePromptCorrelation ??
|
|
8337
|
+
(sdkRunToken
|
|
8338
|
+
? rt.pendingPromptCorrelationsBySdkRunToken.get(sdkRunToken)
|
|
8339
|
+
: rt.pendingPromptCorrelations.shift());
|
|
8340
|
+
if (sdkRunToken && correlation) rt.pendingPromptCorrelationsBySdkRunToken.delete(sdkRunToken);
|
|
6356
8341
|
const continuation = rt.activePromptCorrelation !== undefined;
|
|
6357
8342
|
rt.activePromptCorrelation = correlation;
|
|
6358
8343
|
if (correlation && !continuation) rt.bindPromptExecutionHandle(correlation, ctx.getActivePromptHandle());
|
|
6359
8344
|
if (continuation) return;
|
|
6360
|
-
rt.notePromptReconciliation(correlation, { type: "agent_start" });
|
|
8345
|
+
await rt.notePromptReconciliation(correlation, { type: "agent_start" });
|
|
6361
8346
|
rt.emitPromptLifecycle(correlation, { type: "agent_start", sessionId: id, ...correlation });
|
|
6362
8347
|
try {
|
|
6363
8348
|
// `activity` is the native live-host lifecycle surface. The separately
|
|
@@ -6376,6 +8361,9 @@ export function createNotificationsExtension(
|
|
|
6376
8361
|
const rt = runtimes.get(id);
|
|
6377
8362
|
if (!rt) return;
|
|
6378
8363
|
rt.turnSeq = (rt.turnSeq ?? 0) + 1;
|
|
8364
|
+
// Agent-loop prompts arrive after turn_start. Retain already-bound logical
|
|
8365
|
+
// provenance across tool/message-less continuation turns; a user prompt can
|
|
8366
|
+
// still authoritatively override framework-injected context below.
|
|
6379
8367
|
if (!rt.notificationsActive) return;
|
|
6380
8368
|
// A new turn is live: re-open the live-stream window (see turnClosed).
|
|
6381
8369
|
rt.turnClosed = false;
|
|
@@ -6394,7 +8382,7 @@ export function createNotificationsExtension(
|
|
|
6394
8382
|
// per `turn_end`. turn_end fires once per turn iteration, so a single
|
|
6395
8383
|
// user-visible idle previously produced many idle pings (the flood); agent_end
|
|
6396
8384
|
// fires exactly once per settle, yielding exactly one idle notification.
|
|
6397
|
-
api.on("agent_end", (event, ctx) => {
|
|
8385
|
+
api.on("agent_end", async (event, ctx) => {
|
|
6398
8386
|
const id = sessionId(ctx);
|
|
6399
8387
|
const rt = runtimes.get(id);
|
|
6400
8388
|
if (!rt) return;
|
|
@@ -6452,7 +8440,7 @@ export function createNotificationsExtension(
|
|
|
6452
8440
|
// contract (see `sanitizePromptFailure`). Assistant failure messages may
|
|
6453
8441
|
// still remain in the local session transcript; terminalization copies only
|
|
6454
8442
|
// a bounded reason into the local operator log.
|
|
6455
|
-
|
|
8443
|
+
await rt.terminalizePrompt(correlation, outcome, {
|
|
6456
8444
|
...(finalText ? { finalText } : {}),
|
|
6457
8445
|
...(outcome.kind === "failed"
|
|
6458
8446
|
? {
|
|
@@ -6497,7 +8485,7 @@ export function createNotificationsExtension(
|
|
|
6497
8485
|
pushSessionFrame(rt, {
|
|
6498
8486
|
type: "identity_header",
|
|
6499
8487
|
sessionId: id,
|
|
6500
|
-
...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
|
|
8488
|
+
...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName(), telegramTopicsEnabled()),
|
|
6501
8489
|
});
|
|
6502
8490
|
} catch {}
|
|
6503
8491
|
try {
|
|
@@ -6522,6 +8510,10 @@ export function createNotificationsExtension(
|
|
|
6522
8510
|
// Intermediate tool-turn narration was held on turn_end; ask lead-ins were
|
|
6523
8511
|
// already flushed immediately and deduped via preAskFlushedText.
|
|
6524
8512
|
flushPendingSettled(rt, id);
|
|
8513
|
+
// A terminal idle closes the logical settlement run. The next agent_start
|
|
8514
|
+
// must establish provenance from its own prompt messages.
|
|
8515
|
+
rt.currentTurnSettlementOrigin = undefined;
|
|
8516
|
+
rt.currentTurnSettlementWindow = undefined;
|
|
6525
8517
|
|
|
6526
8518
|
// On idle, stream a context update with metadata (token/model usage +
|
|
6527
8519
|
// working-tree diff) unless redaction is on. Under verbose the agent's last
|
|
@@ -6567,7 +8559,8 @@ export function createNotificationsExtension(
|
|
|
6567
8559
|
rt.preAskFlushedText = text;
|
|
6568
8560
|
// Ask lead-ins supersede any deferred lean settled answer from earlier turns
|
|
6569
8561
|
// so agent_end does not re-emit stale intermediate narration (#2863 review).
|
|
6570
|
-
if (!finalAnswer
|
|
8562
|
+
if (!finalAnswer && rt.currentTurnSettlementOrigin !== "autonomous" && !hasUserSettlementReceipt(rt))
|
|
8563
|
+
rt.pendingSettled = undefined;
|
|
6571
8564
|
// Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
|
|
6572
8565
|
// live message, never a fresh (rich-promotable) send. If live frames were
|
|
6573
8566
|
// async-queued and none landed before this flush, reuse the per-turn ref
|
|
@@ -6602,6 +8595,8 @@ export function createNotificationsExtension(
|
|
|
6602
8595
|
rt?.emitPromptEvent(event);
|
|
6603
8596
|
if (event.toolName === "ask") {
|
|
6604
8597
|
if (!rt?.notificationsActive || rt.redact) return;
|
|
8598
|
+
if (rt.currentTurnSettlementWindow !== undefined && rt.currentTurnSettlementWindow !== rt.settlementWindow)
|
|
8599
|
+
return;
|
|
6605
8600
|
flushTurnText(rt, id, rt.currentTurnText, false);
|
|
6606
8601
|
}
|
|
6607
8602
|
if (!rt?.notificationsActive || rt.redact) return;
|
|
@@ -6714,7 +8709,7 @@ export function createNotificationsExtension(
|
|
|
6714
8709
|
api.on("turn_end", (event, ctx) => {
|
|
6715
8710
|
const id = sessionId(ctx);
|
|
6716
8711
|
const rt = runtimes.get(id);
|
|
6717
|
-
if (!rt
|
|
8712
|
+
if (!rt || (!rt.notificationsActive && !rt.policySuspended)) return;
|
|
6718
8713
|
const text = rt.policySuspended
|
|
6719
8714
|
? rt.committedRedact
|
|
6720
8715
|
? undefined
|
|
@@ -6723,25 +8718,27 @@ export function createNotificationsExtension(
|
|
|
6723
8718
|
? undefined
|
|
6724
8719
|
: summaryFromMessage(event.message, turnTextMax());
|
|
6725
8720
|
if (rt.policySuspended) {
|
|
6726
|
-
rt
|
|
8721
|
+
deferProvisionalReceipt(rt, text, rt.liveRef);
|
|
6727
8722
|
rt.turnClosed = true;
|
|
6728
8723
|
return;
|
|
6729
8724
|
}
|
|
6730
8725
|
if (text) {
|
|
8726
|
+
if (rt.currentTurnSettlementWindow !== undefined && rt.currentTurnSettlementWindow !== rt.settlementWindow) {
|
|
8727
|
+
resetTurnStreamState(rt);
|
|
8728
|
+
return;
|
|
8729
|
+
}
|
|
6731
8730
|
if (rt.verbosity === "verbose") {
|
|
6732
8731
|
// Verbose: one finalized turn_stream per turn with assistant text.
|
|
6733
8732
|
flushTurnText(rt, id, text, true);
|
|
6734
8733
|
} else if (text !== rt.preAskFlushedText) {
|
|
6735
8734
|
// Lean: hold the latest answer until agent_end. Skip when this turn
|
|
6736
8735
|
// already flushed the same text as an ask lead-in (no duplicate at idle).
|
|
6737
|
-
rt
|
|
6738
|
-
text,
|
|
6739
|
-
...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
|
|
6740
|
-
};
|
|
8736
|
+
deferLeanReceipt(rt, text, rt.liveRef);
|
|
6741
8737
|
} else {
|
|
6742
8738
|
// Lead-in already flushed: drop any older deferred settled text so idle
|
|
6743
8739
|
// does not re-emit intermediate narration after the ask prompt (#2863).
|
|
6744
|
-
rt.
|
|
8740
|
+
if (rt.currentTurnSettlementOrigin === "user" || !hasUserSettlementReceipt(rt))
|
|
8741
|
+
rt.pendingSettled = undefined;
|
|
6745
8742
|
}
|
|
6746
8743
|
}
|
|
6747
8744
|
resetTurnStreamState(rt);
|
|
@@ -6781,6 +8778,33 @@ export function createNotificationsExtension(
|
|
|
6781
8778
|
const id = sessionId(ctx);
|
|
6782
8779
|
const rt = runtimes.get(id);
|
|
6783
8780
|
rt?.emitPromptEvent(event);
|
|
8781
|
+
if (rt) {
|
|
8782
|
+
const message = event.message as { role?: unknown; attribution?: unknown; customType?: unknown };
|
|
8783
|
+
if (isUserSettlementBoundary(message)) {
|
|
8784
|
+
const openingTurn = rt.currentTurnSettlementOrigin !== "user";
|
|
8785
|
+
const supersedingSettledTurn =
|
|
8786
|
+
rt.currentTurnSettlementOrigin === "user" &&
|
|
8787
|
+
(rt.pendingSettled !== undefined || rt.pendingFinal !== undefined);
|
|
8788
|
+
// A provider batch can carry several user messages after one turn_start.
|
|
8789
|
+
// They share that turn's boundary. A later user submission after the old
|
|
8790
|
+
// turn has produced a receipt opens a new window but must not relabel the
|
|
8791
|
+
// already-running turn as its response.
|
|
8792
|
+
if (openingTurn || supersedingSettledTurn) rt.settlementWindow++;
|
|
8793
|
+
if (openingTurn || supersedingSettledTurn) {
|
|
8794
|
+
rt.currentTurnSettlementWindow = rt.settlementWindow;
|
|
8795
|
+
rt.currentTurnSettlementOrigin = "user";
|
|
8796
|
+
}
|
|
8797
|
+
rt.pendingSettled = undefined;
|
|
8798
|
+
rt.pendingFinal = undefined;
|
|
8799
|
+
} else if (message.role !== "assistant") {
|
|
8800
|
+
// The assembled direct prompt can contain framework context after its
|
|
8801
|
+
// user message. A user boundary is authoritative for this turn.
|
|
8802
|
+
if (rt.currentTurnSettlementOrigin !== "user" || !isFrameworkPromptContext(message)) {
|
|
8803
|
+
rt.currentTurnSettlementWindow = rt.settlementWindow;
|
|
8804
|
+
rt.currentTurnSettlementOrigin = "autonomous";
|
|
8805
|
+
}
|
|
8806
|
+
}
|
|
8807
|
+
}
|
|
6784
8808
|
if (!rt?.notificationsActive || rt.redact) return;
|
|
6785
8809
|
// Capture the in-flight ASSISTANT text here (message_end is on the awaited
|
|
6786
8810
|
// extension path and ordered before tool_execution_start) so the pre-ask
|