agentengine-sdk-python 0.8.0__py3-none-any.whl → 0.8.2__py3-none-any.whl
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.
- {agentengine_sdk_python-0.8.0.dist-info → agentengine_sdk_python-0.8.2.dist-info}/METADATA +43 -7
- agentengine_sdk_python-0.8.2.dist-info/RECORD +778 -0
- {agentengine_sdk_python-0.8.0.dist-info → agentengine_sdk_python-0.8.2.dist-info}/WHEEL +1 -1
- ksadk/a2a/__init__.py +2 -0
- ksadk/a2a/_space_client_events.py +381 -0
- ksadk/a2a/bootstrap.py +0 -2
- ksadk/a2a/card.py +6 -111
- ksadk/a2a/control_plane.py +239 -1
- ksadk/a2a/event_adapter.py +156 -64
- ksadk/a2a/executor.py +195 -172
- ksadk/a2a/routes.py +12 -5
- ksadk/a2a/server.py +1 -8
- ksadk/a2a/service_env.py +33 -0
- ksadk/a2a/space_client.py +43 -349
- ksadk/a2a_card.py +101 -0
- ksadk/a2ui/core.py +222 -53
- ksadk/agui/_agent_helpers.py +243 -0
- ksadk/agui/a2ui_projection.py +12 -5
- ksadk/agui/agent.py +474 -275
- ksadk/agui/config.py +11 -5
- ksadk/agui/routes.py +7 -15
- ksadk/api/client.py +530 -40
- ksadk/api/environment.py +27 -0
- ksadk/builders/code_builder.py +352 -53
- ksadk/builders/container_builder.py +71 -44
- ksadk/builders/framework_requirements.py +2 -1
- ksadk/builders/ks3_uploader.py +2 -2
- ksadk/builders/managed_runtime_builder.py +60 -28
- ksadk/cli/__init__.py +25 -0
- ksadk/cli/cmd_a2a.py +18 -31
- ksadk/cli/cmd_create.py +13 -26
- ksadk/cli/cmd_dashboard.py +15 -1
- ksadk/cli/cmd_deploy.py +28 -3
- ksadk/cli/cmd_eval.py +443 -0
- ksadk/cli/cmd_evalset.py +284 -0
- ksadk/cli/cmd_files.py +5 -1
- ksadk/cli/cmd_hermes.py +137 -163
- ksadk/cli/cmd_invoke.py +28 -38
- ksadk/cli/cmd_launch.py +12 -0
- ksadk/cli/cmd_managed_runtime.py +95 -0
- ksadk/cli/cmd_observe.py +85 -0
- ksadk/cli/cmd_openclaw.py +85 -479
- ksadk/cli/cmd_replay.py +28 -4
- ksadk/cli/cmd_run.py +29 -67
- ksadk/cli/cmd_studio.py +201 -0
- ksadk/cli/cmd_web.py +15 -6
- ksadk/cli/dry_run.py +2 -1
- ksadk/cli/env_options.py +83 -0
- ksadk/cli/global_options.py +3 -1
- ksadk/cli/hermes_env.py +151 -0
- ksadk/cli/invoke_payload.py +49 -0
- ksadk/cli/network_options.py +7 -5
- ksadk/cli/openclaw_env.py +458 -0
- ksadk/cli/runtime_bootstrap.py +51 -0
- ksadk/cli/ui.py +6 -0
- ksadk/cli/workflow_common.py +5 -5
- ksadk/codex/__init__.py +3 -3
- ksadk/codex/client.py +833 -49
- ksadk/codex/phase.py +1 -1
- ksadk/codex/runtime.py +720 -127
- ksadk/common/kop_client.py +232 -0
- ksadk/configs/__init__.py +1 -3
- ksadk/configs/env_registry.py +244 -45
- ksadk/configs/env_registry_pcm.py +181 -0
- ksadk/configs/env_var_spec.py +18 -0
- ksadk/configs/global_config.py +6 -1
- ksadk/configs/settings.py +5 -108
- ksadk/context_engine/__init__.py +98 -0
- ksadk/context_engine/assembler.py +176 -0
- ksadk/context_engine/baseline.py +419 -0
- ksadk/context_engine/cache_observability.py +228 -0
- ksadk/context_engine/capabilities.py +443 -0
- ksadk/context_engine/contributors.py +344 -0
- ksadk/context_engine/hosted_pipeline.py +417 -0
- ksadk/context_engine/models.py +117 -0
- ksadk/context_engine/planner.py +698 -0
- ksadk/context_engine/policies.py +319 -0
- ksadk/context_engine/projection.py +31 -0
- ksadk/context_engine/shadow_plan.py +266 -0
- ksadk/context_engine/tokenizer.py +164 -0
- ksadk/conversations/__init__.py +85 -94
- ksadk/conversations/context.py +126 -6
- ksadk/conversations/message_projection.py +292 -28
- ksadk/conversations/model_context.py +49 -3
- ksadk/conversations/runtime_compaction.py +207 -5
- ksadk/conversations/runtime_input.py +193 -3
- ksadk/conversations/runtime_invocation.py +81 -3
- ksadk/conversations/runtime_observability.py +157 -4
- ksadk/conversations/runtime_payloads.py +42 -0
- ksadk/conversations/runtime_preparation.py +280 -2
- ksadk/conversations/runtime_resume.py +71 -32
- ksadk/conversations/runtime_stream_events.py +94 -9
- ksadk/conversations/runtime_streaming.py +175 -22
- ksadk/conversations/semantic_summary.py +311 -1
- ksadk/conversations/session_lock.py +51 -0
- ksadk/deployment/env_forward.py +72 -0
- ksadk/deployment/managed_runtime.py +38 -0
- ksadk/deployment/manager.py +24 -8
- ksadk/deployment/providers/serverless.py +228 -92
- ksadk/evaluation/__init__.py +116 -0
- ksadk/evaluation/a2a_adapter.py +452 -0
- ksadk/evaluation/adapters.py +67 -0
- ksadk/evaluation/agent_eval_client.py +395 -0
- ksadk/evaluation/cloud_binding.py +80 -0
- ksadk/evaluation/cloud_converter.py +194 -0
- ksadk/evaluation/cloud_service.py +199 -0
- ksadk/evaluation/contracts.py +478 -0
- ksadk/evaluation/evalset.py +351 -0
- ksadk/evaluation/evaluators.py +627 -0
- ksadk/evaluation/evidence.py +258 -0
- ksadk/evaluation/executor.py +143 -0
- ksadk/evaluation/local_adapter.py +550 -0
- ksadk/evaluation/service_env.py +27 -0
- ksadk/evaluation/storage.py +68 -0
- ksadk/evaluation/studio_build_adapter.py +253 -0
- ksadk/evaluation/target.py +82 -0
- ksadk/events/__init__.py +13 -13
- ksadk/events/_v1_compat/__init__.py +1 -0
- ksadk/events/_v1_compat/models.py +299 -0
- ksadk/events/_v1_compat/parser.py +247 -0
- ksadk/events/_v1_compat/projection.py +753 -0
- ksadk/events/adapters/__init__.py +34 -0
- ksadk/events/adapters/_a2a_snapshot.py +622 -0
- ksadk/events/adapters/_a2a_support.py +275 -0
- ksadk/events/adapters/_codex_interactions.py +375 -0
- ksadk/events/adapters/_codex_items.py +801 -0
- ksadk/events/adapters/_codex_validators.py +311 -0
- ksadk/events/adapters/_langgraph_support.py +785 -0
- ksadk/events/adapters/a2a.py +841 -0
- ksadk/events/adapters/adk.py +503 -0
- ksadk/events/adapters/codex.py +717 -0
- ksadk/events/adapters/langgraph.py +745 -0
- ksadk/events/canonical.py +440 -0
- ksadk/events/canonical_replay.py +497 -0
- ksadk/events/canonical_store.py +468 -0
- ksadk/events/cold_recovery.py +257 -0
- ksadk/events/content.py +89 -0
- ksadk/events/identity.py +86 -0
- ksadk/events/pipeline.py +455 -0
- ksadk/events/projections.py +66 -0
- ksadk/events/reducer.py +661 -0
- ksadk/events/replay.py +3 -36
- ksadk/events/runtime_event.py +20 -256
- ksadk/events/session_event.py +324 -0
- ksadk/events/store.py +3 -316
- ksadk/events/v1_compat.py +41 -0
- ksadk/harness/__init__.py +3 -2
- ksadk/harness/app.py +114 -123
- ksadk/harness/config.py +1 -1
- ksadk/harness/runtime.py +467 -0
- ksadk/hermes_terminal.py +5 -0
- ksadk/identity/resolver.py +4 -3
- ksadk/interaction/__init__.py +28 -0
- ksadk/interaction/contracts.py +136 -0
- ksadk/interaction/ledger.py +189 -0
- ksadk/interaction/provider.py +122 -0
- ksadk/interaction/providers/__init__.py +52 -0
- ksadk/interaction/providers/adk.py +40 -0
- ksadk/interaction/providers/codex.py +94 -0
- ksadk/interaction/providers/langgraph.py +58 -0
- ksadk/kernel/__init__.py +117 -0
- ksadk/kernel/authorization.py +258 -0
- ksadk/kernel/bootstrap.py +1074 -0
- ksadk/kernel/contract_fingerprints.py +61 -0
- ksadk/kernel/contracts.py +362 -0
- ksadk/kernel/control.py +219 -0
- ksadk/kernel/errors.py +79 -0
- ksadk/kernel/ingress.py +1102 -0
- ksadk/kernel/mapping.py +111 -0
- ksadk/kernel/memory_store.py +1133 -0
- ksadk/kernel/postgres_store.py +1756 -0
- ksadk/kernel/recovery.py +452 -0
- ksadk/kernel/runtime_identity.py +103 -0
- ksadk/kernel/sql/001_agent_kernel.sql +120 -0
- ksadk/kernel/sqlite_store.py +1410 -0
- ksadk/kernel/state.py +145 -0
- ksadk/kernel/store.py +232 -0
- ksadk/kernel/worker.py +953 -0
- ksadk/knowledge_base/client.py +22 -4
- ksadk/knowledge_base/service.py +32 -5
- ksadk/managed_a2a_card.py +160 -0
- ksadk/managed_runtime.py +16 -9
- ksadk/memory/__init__.py +61 -0
- ksadk/memory/adk/backends/base_ltm_backend.py +96 -1
- ksadk/memory/adk/backends/http_ltm_backend.py +14 -5
- ksadk/memory/adk/backends/inmemory_ltm_backend.py +136 -3
- ksadk/memory/adk/backends/sdk_ltm_backend.py +394 -5
- ksadk/memory/adk/backends/sqlite_ltm_backend.py +95 -0
- ksadk/memory/adk/long_term_memory.py +3 -2
- ksadk/memory/coordinator.py +461 -0
- ksadk/memory/events.py +202 -0
- ksadk/memory/extraction.py +228 -0
- ksadk/memory/ltm_backend_factory.py +16 -3
- ksadk/memory/models.py +316 -0
- ksadk/memory/policy.py +215 -0
- ksadk/memory/provider.py +47 -0
- ksadk/memory/provider_adapter.py +159 -0
- ksadk/memory/provider_resolver.py +71 -0
- ksadk/memory/providers/__init__.py +5 -0
- ksadk/memory/providers/local_sqlite.py +490 -0
- ksadk/memory/resolved_policy.py +145 -0
- ksadk/memory/service.py +198 -8
- ksadk/memory/tool.py +4 -0
- ksadk/model_proxy/bootstrap.py +16 -9
- ksadk/model_proxy/config.py +22 -1
- ksadk/model_proxy/detect.py +202 -9
- ksadk/model_proxy/namespace.py +67 -15
- ksadk/model_proxy/server.py +168 -6
- ksadk/model_proxy/transform.py +105 -3
- ksadk/observability/__init__.py +25 -0
- ksadk/observability/session_log.py +420 -0
- ksadk/observability/trajectory.py +105 -0
- ksadk/prompts/__init__.py +71 -0
- ksadk/prompts/compiler.py +215 -0
- ksadk/prompts/models.py +82 -0
- ksadk/prompts/projection.py +89 -0
- ksadk/prompts/resolved.py +137 -0
- ksadk/prompts/sources.py +233 -0
- ksadk/runners/_langgraph_runner_streams.py +822 -0
- ksadk/runners/adk_runner.py +14 -31
- ksadk/runners/base_runner.py +44 -3
- ksadk/runners/factory.py +3 -3
- ksadk/runners/langgraph_runner.py +252 -433
- ksadk/runners/utils/__init__.py +0 -10
- ksadk/runners/utils/loader.py +16 -0
- ksadk/runtime/__init__.py +67 -41
- ksadk/runtime/_runner_adapter/__init__.py +1 -0
- ksadk/runtime/_runner_adapter/stream_mapping.py +788 -0
- ksadk/runtime/adapter.py +199 -16
- ksadk/runtime/conversation_execution.py +687 -0
- ksadk/runtime/executor.py +392 -0
- ksadk/runtime/factory.py +342 -0
- ksadk/runtime/framework_adapters.py +6 -12
- ksadk/runtime/hosted_finalizer.py +268 -0
- ksadk/runtime/launch.py +61 -0
- ksadk/runtime/preprocessing.py +49 -14
- ksadk/runtime/runner_adapter.py +330 -391
- ksadk/runtime/runner_loading.py +36 -0
- ksadk/runtime/usage.py +33 -0
- ksadk/sandbox/backends/e2b.py +36 -3
- ksadk/server/__init__.py +39 -4
- ksadk/server/app.py +18 -492
- ksadk/server/composition.py +36 -2
- ksadk/server/factory.py +210 -149
- ksadk/server/routes/common.py +15 -46
- ksadk/server/routes/control.py +13 -9
- ksadk/server/routes/kernel_ingress.py +258 -0
- ksadk/server/routes/misc.py +10 -33
- ksadk/server/routes/models.py +14 -6
- ksadk/server/routes/openai_compat.py +129 -27
- ksadk/server/routes/projection.py +84 -16
- ksadk/server/routes/run.py +347 -460
- ksadk/server/routes/sessions.py +37 -40
- ksadk/server/routes/streaming.py +52 -7
- ksadk/server/routes/workspace.py +54 -21
- ksadk/server/static/assets/{ArtifactsPanel-Cjnqt-8W.js → ArtifactsPanel-DSU1sWD_.js} +1 -1
- ksadk/server/static/assets/{CodeBlock-BqyqZfuB.js → CodeBlock-DOH6MVcn.js} +4 -4
- ksadk/server/static/assets/{MathMessageMarkdown-BpH7jiFM.js → MathMessageMarkdown-BL8my1R2.js} +4 -4
- ksadk/server/static/assets/MathMessageMarkdown-BorAY7qD.css +1 -0
- ksadk/server/static/assets/MermaidBlock-Dz4IP-Tx.js +312 -0
- ksadk/server/static/assets/NativeTerminalPanel-DUrK0JpZ.js +1 -0
- ksadk/server/static/assets/abnfDiagram-VCTEODGH-g20pFzNV.js +1 -0
- ksadk/server/static/assets/arc-C4FzinUA.js +1 -0
- ksadk/server/static/assets/architecture-7GRP2DOG-DjKratdf.js +1 -0
- ksadk/server/static/assets/architectureDiagram-5GKGNRK7-DpIqL5h4.js +36 -0
- ksadk/server/static/assets/blockDiagram-NRAW4CY4-BjkljTNz.js +129 -0
- ksadk/server/static/assets/c4Diagram-UCG6FXSJ-Dk_ieq2X.js +38 -0
- ksadk/server/static/assets/channel-4cQHKtx1.js +1 -0
- ksadk/server/static/assets/chunk-2Q5K7J3B-DmgWkESh.js +1 -0
- ksadk/server/static/assets/{chunk-FMBD7UC4-BGhvzDYt.js → chunk-5VM5RSS4-BVHAchFb.js} +1 -1
- ksadk/server/static/assets/{chunk-ND2GUHAM-D98vPd-E.js → chunk-F27PBJKO-C-ipQzuS.js} +1 -1
- ksadk/server/static/assets/chunk-G27WJ6UU-C2EyJbIK.js +231 -0
- ksadk/server/static/assets/{chunk-4BX2VUAB-CsWIQRhc.js → chunk-JWPE2WC7-vYvVJb_M.js} +1 -1
- ksadk/server/static/assets/chunk-LCL6LL3I-BcI6ysPT.js +206 -0
- ksadk/server/static/assets/chunk-POPQ4Y6H-C030x_Z1.js +1 -0
- ksadk/server/static/assets/chunk-RHFEMEQ7-yyGpsz4n.js +168 -0
- ksadk/server/static/assets/chunk-SVP7TREG-BLTlmMU7.js +88 -0
- ksadk/server/static/assets/chunk-XXDRQBXY-C_32ArgP.js +1 -0
- ksadk/server/static/assets/classDiagram-DTDB5LWJ-DLFd9Xi0.js +1 -0
- ksadk/server/static/assets/classDiagram-v2-JRS7N3AN-DLFd9Xi0.js +1 -0
- ksadk/server/static/assets/{cose-bilkent-S5V4N54A-CX3yotHq.js → cose-bilkent-JH36ORCC-9YQYwdl0.js} +1 -1
- ksadk/server/static/assets/cynefin-OW5HDTMX-DjEM48Qp.js +1 -0
- ksadk/server/static/assets/cynefinDiagram-5FMLGOSQ-ErLF5N13.js +62 -0
- ksadk/server/static/assets/cytoscape.esm-CyCl8rPi.js +321 -0
- ksadk/server/static/assets/dagre-3AP2YEHR-DFiQF-6f.js +4 -0
- ksadk/server/static/assets/dagre-BuhGnRI1.js +1 -0
- ksadk/server/static/assets/diagram-S7CK7UJ4-Bt1v8-GC.js +30 -0
- ksadk/server/static/assets/diagram-UQ7AKVKN-DSCxJdBK.js +41 -0
- ksadk/server/static/assets/diagram-VSXAHHWV-DHfYwp_9.js +3 -0
- ksadk/server/static/assets/diagram-VX7I27RA-DdzD-4Le.js +24 -0
- ksadk/server/static/assets/diagram-Z3DM3KII-Du-nAJ9F.js +24 -0
- ksadk/server/static/assets/{dist-D82mtD2s.js → dist-8hqcx0sU.js} +1 -1
- ksadk/server/static/assets/dist-B7seoj3d.js +1 -0
- ksadk/server/static/assets/{dist-DHn6nBzr.js → dist-BE7bu-DL.js} +1 -1
- ksadk/server/static/assets/{dist-eoW6tZXA.js → dist-BVDffZ0U.js} +1 -1
- ksadk/server/static/assets/{dist-C2xjE7cO.js → dist-BX8z72IC.js} +1 -1
- ksadk/server/static/assets/{dist-CgF3fj15.js → dist-Bf2M8m3N.js} +1 -1
- ksadk/server/static/assets/{dist-Bs-jQcRX.js → dist-BjU6y-A2.js} +1 -1
- ksadk/server/static/assets/{dist-Djgl2wVZ.js → dist-BytlxIDT.js} +1 -1
- ksadk/server/static/assets/{dist-CVQRZjPu.js → dist-CSh_DE14.js} +1 -1
- ksadk/server/static/assets/{dist-6CVKuTAe.js → dist-CXYYBw3_.js} +1 -1
- ksadk/server/static/assets/{dist-DlzCRy0S.js → dist-C_wsv-Qd.js} +1 -1
- ksadk/server/static/assets/{dist-DF06ORQo.js → dist-ClxGviKw.js} +1 -1
- ksadk/server/static/assets/{dist-Cf23NY4L.js → dist-CttYUqzS.js} +1 -1
- ksadk/server/static/assets/{dist-ivOya1LD.js → dist-DNp6rLRA.js} +1 -1
- ksadk/server/static/assets/{dist-BibcG1V9.js → dist-DUX-id_F.js} +1 -1
- ksadk/server/static/assets/{dist-BUHRxvDG.js → dist-DmldrHd_.js} +1 -1
- ksadk/server/static/assets/{dist-BZ9n9AjO.js → dist-DnfXs8Vn.js} +2 -2
- ksadk/server/static/assets/{dist-DqNnNFrw.js → dist-Dq9gLD7L.js} +1 -1
- ksadk/server/static/assets/{dist-CGcp2ex8.js → dist-W-TIVntx.js} +1 -1
- ksadk/server/static/assets/{dist-Cwt7koer.js → dist-dDdADKFA.js} +1 -1
- ksadk/server/static/assets/{dist-C7-3jInC.js → dist-dydu68Fl.js} +1 -1
- ksadk/server/static/assets/{dist-C99THtIA.js → dist-ngtN0DJB.js} +1 -1
- ksadk/server/static/assets/ebnfDiagram-PWID7BFC-Da9C9NO1.js +1 -0
- ksadk/server/static/assets/erDiagram-SSCWMZ5O-EAGqqR79.js +99 -0
- ksadk/server/static/assets/eventmodeling-NTZA5JFV-CMgMaJrC.js +1 -0
- ksadk/server/static/assets/flowDiagram-A5DVABFB-BBKL0x3P.js +1 -0
- ksadk/server/static/assets/ganttDiagram-EL5Y4UJY-DqJsKb59.js +292 -0
- ksadk/server/static/assets/gitGraph-4MIJSDKK-Dw63mrhw.js +1 -0
- ksadk/server/static/assets/gitGraphDiagram-WWUBYQGX-B6g4dtDi.js +106 -0
- ksadk/server/static/assets/index-8ipRcQ-M.js +240 -0
- ksadk/server/static/assets/index-ByFCqjlh.css +2 -0
- ksadk/server/static/assets/info-A6RAGUB7-B6iAblII.js +1 -0
- ksadk/server/static/assets/infoDiagram-RXCK75RN-Djm_nbd5.js +2 -0
- ksadk/server/static/assets/{ishikawaDiagram-YF4QCWOH-DLOHGvWw.js → ishikawaDiagram-5VMMS53U-DWya9SG2.js} +6 -6
- ksadk/server/static/assets/{journeyDiagram-JHISSGLW-BIzKeejT.js → journeyDiagram-EYS64GPL-DutxhSNI.js} +6 -6
- ksadk/server/static/assets/{kanban-definition-UN3LZRKU-BWHoFSw2.js → kanban-definition-3QL26DDD-DJdOF8Fm.js} +10 -10
- ksadk/server/static/assets/katex-vFWytM5c.js +257 -0
- ksadk/server/static/assets/{linear-CvMBw9cA.js → linear-BHjG8b-J.js} +1 -1
- ksadk/server/static/assets/mermaid-parser.core-KGSy4jWT.js +166 -0
- ksadk/server/static/assets/{mindmap-definition-RKZ34NQL-CSAQxEAe.js → mindmap-definition-FBJOCRG2-9-HC88D4.js} +29 -29
- ksadk/server/static/assets/packet-AYTQ26CC-BOYAipuq.js +1 -0
- ksadk/server/static/assets/pegDiagram-XKGWAZYB-DrEZd4fD.js +1 -0
- ksadk/server/static/assets/pie-WAS4IAKB-Ctq0Kbku.js +1 -0
- ksadk/server/static/assets/pieDiagram-E7YTZNPT-DpjqOFFp.js +39 -0
- ksadk/server/static/assets/{quadrantDiagram-W4KKPZXB-CxIR4JXI.js → quadrantDiagram-AXDQQJYC-BiNhgOXX.js} +6 -6
- ksadk/server/static/assets/radar-RG4KPBEZ-DN0-ul_0.js +1 -0
- ksadk/server/static/assets/railroad-74A4TZTK-C6cC_to0.js +1 -0
- ksadk/server/static/assets/railroad-abnf-HS5TGJTU-BimjIuRc.js +1 -0
- ksadk/server/static/assets/railroad-ebnf-LZEXJU2U-CvY65ABq.js +1 -0
- ksadk/server/static/assets/railroad-peg-WCYAUIDC-D72Iq_Tv.js +1 -0
- ksadk/server/static/assets/railroadDiagram-O6MQD6OU-_Y3Ojg6G.js +1 -0
- ksadk/server/static/assets/requirementDiagram-EFPCY7ZU-DeCFdg9K.js +84 -0
- ksadk/server/static/assets/{sankeyDiagram-5OEKKPKP-DpGa6EAV.js → sankeyDiagram-P5KCCOFB-CPH75rhw.js} +7 -7
- ksadk/server/static/assets/{sequenceDiagram-3UESZ5HK-NTKNGGe5.js → sequenceDiagram-WJ2MYXX4-OEpQQdr4.js} +10 -10
- ksadk/server/static/assets/sizeCapture-X5ZJPWSS-heUErzhI.js +1 -0
- ksadk/server/static/assets/stateDiagram-HBIQ2CUA-DvA3jSMB.js +1 -0
- ksadk/server/static/assets/stateDiagram-v2-4QOOHH4V-BgQY03nz.js +1 -0
- ksadk/server/static/assets/swimlanes-XN3QIQJK-CR-dfN_t.js +1 -0
- ksadk/server/static/assets/swimlanesDiagram-VK2B7HYN-DLVw3q5Q.js +8 -0
- ksadk/server/static/assets/{timeline-definition-PNZ67QCA-jqoYs3jQ.js → timeline-definition-24CTP7MA-DAVJf1TX.js} +7 -7
- ksadk/server/static/assets/treeView-Q6P3EWNA-BMpO0wsB.js +1 -0
- ksadk/server/static/assets/treemap-WGGIJYW6-Et0aqzU3.js +1 -0
- ksadk/server/static/assets/vennDiagram-4TSXK5OY-DwM8eDkh.js +34 -0
- ksadk/server/static/assets/wardley-WFR3VGLG-CbY0mhpl.js +1 -0
- ksadk/server/static/assets/wardleyDiagram-VM6X3IG4-DZL-Zz2t.js +78 -0
- ksadk/server/static/assets/xychartDiagram-S5SC5T6Z-Dy6Yh_hu.js +7 -0
- ksadk/server/static/index.html +2 -2
- ksadk/sessions/__init__.py +26 -9
- ksadk/sessions/_local_service_sync.py +688 -0
- ksadk/sessions/_local_tables.py +21 -0
- ksadk/sessions/_postgres_schema.py +305 -0
- ksadk/sessions/_postgres_tables.py +15 -0
- ksadk/sessions/base.py +80 -1
- ksadk/sessions/in_memory.py +53 -2
- ksadk/sessions/local_service.py +63 -604
- ksadk/sessions/postgres_service.py +58 -148
- ksadk/sessions/resilient.py +35 -1
- ksadk/skills/runtime/backends/e2b.py +1 -1
- ksadk/skills/service_client.py +13 -3
- ksadk/studio/__init__.py +5 -0
- ksadk/studio/agent_avatar_assets.py +120 -0
- ksadk/studio/agent_lifecycle.py +51 -0
- ksadk/studio/api.py +1598 -0
- ksadk/studio/api_catalog_routes.py +324 -0
- ksadk/studio/api_contracts.py +286 -0
- ksadk/studio/api_helpers.py +132 -0
- ksadk/studio/api_memory_routes.py +82 -0
- ksadk/studio/authoring.py +749 -0
- ksadk/studio/authoring_coordinator.py +1017 -0
- ksadk/studio/builder.py +397 -0
- ksadk/studio/capabilities.py +368 -0
- ksadk/studio/cloud.py +2099 -0
- ksadk/studio/codex_agent_service.py +694 -0
- ksadk/studio/codex_authoring.py +469 -0
- ksadk/studio/codex_builder.py +437 -0
- ksadk/studio/codex_manifest.py +307 -0
- ksadk/studio/codex_run.py +395 -0
- ksadk/studio/compiler.py +190 -0
- ksadk/studio/contracts.py +755 -0
- ksadk/studio/errors.py +47 -0
- ksadk/studio/event_store.py +214 -0
- ksadk/studio/framework_run.py +342 -0
- ksadk/studio/hosted_kernel.py +317 -0
- ksadk/studio/identifiers.py +33 -0
- ksadk/studio/manifest_resolver.py +131 -0
- ksadk/studio/mcp_runtime.py +229 -0
- ksadk/studio/model_client.py +629 -0
- ksadk/studio/model_profile_service.py +214 -0
- ksadk/studio/operations.py +275 -0
- ksadk/studio/otel_trace.py +1060 -0
- ksadk/studio/pagination.py +182 -0
- ksadk/studio/pcm_memory.py +85 -0
- ksadk/studio/python_tool_inspection.py +183 -0
- ksadk/studio/repository.py +327 -0
- ksadk/studio/resource_catalog.py +1260 -0
- ksadk/studio/run_service.py +1133 -0
- ksadk/studio/runtime_catalog.py +74 -0
- ksadk/studio/runtime_source.py +365 -0
- ksadk/studio/service.py +2249 -0
- ksadk/studio/shared_web.py +1004 -0
- ksadk/studio/skill_discovery.py +628 -0
- ksadk/studio/static/assets/PrismRenderer-IYN-ffww.js +3 -0
- ksadk/studio/static/assets/index-BLpcdrgW.css +1 -0
- ksadk/studio/static/assets/index-CYekR2Xv.js +260 -0
- ksadk/studio/static/assets/react-vendor-BNmTiJ-I.js +9 -0
- ksadk/studio/static/assets/rolldown-runtime-hePW80VL.js +1 -0
- ksadk/studio/static/index.html +15 -0
- ksadk/studio/templates.py +722 -0
- ksadk/studio/validator.py +265 -0
- ksadk/studio/workspace.py +135 -0
- ksadk/tools/gateway.py +9 -0
- ksadk/toolsets/__init__.py +27 -1
- ksadk/toolsets/a2a.py +426 -0
- ksadk/tracing/__init__.py +19 -21
- ksadk/tracing/setup.py +317 -403
- ksadk/tracing/span_utils.py +0 -44
- ksadk/version.py +1 -1
- ksadk_runtime_common/memory_backend/registry.py +56 -56
- ksadk_runtime_common/schemas/runtime_event_v1.json +37 -0
- ksadk_runtime_common/schemas/runtime_event_v2.json +271 -0
- ksadk_runtime_common/workspace_files/router.py +8 -2
- agentengine_sdk_python-0.8.0.dist-info/RECORD +0 -578
- ksadk/a2a/card_builder.py +0 -64
- ksadk/a2a/client.py +0 -309
- ksadk/events/parser.py +0 -191
- ksadk/harness/runner.py +0 -211
- ksadk/runners/codex_runner.py +0 -132
- ksadk/runners/utils/langfuse.py +0 -230
- ksadk/server/static/assets/MathMessageMarkdown-DeYh8QPr.css +0 -1
- ksadk/server/static/assets/MermaidBlock-DWuxETaJ.js +0 -303
- ksadk/server/static/assets/NativeTerminalPanel-MDTopWMO.js +0 -1
- ksadk/server/static/assets/arc-CFKwSk_B.js +0 -1
- ksadk/server/static/assets/architecture-7EHR7CIX-CynqOWjZ.js +0 -1
- ksadk/server/static/assets/architectureDiagram-3BPJPVTR-Dej8jX8c.js +0 -36
- ksadk/server/static/assets/blockDiagram-GPEHLZMM-1F64jqvy.js +0 -132
- ksadk/server/static/assets/c4Diagram-AAUBKEIU-9q7AUAKb.js +0 -10
- ksadk/server/static/assets/channel-iUGv2N24.js +0 -1
- ksadk/server/static/assets/chunk-2J33WTMH-6qgwJm7Q.js +0 -1
- ksadk/server/static/assets/chunk-55IACEB6-BK8paA20.js +0 -1
- ksadk/server/static/assets/chunk-727SXJPM-B-JEhy7h.js +0 -206
- ksadk/server/static/assets/chunk-AQP2D5EJ-DYBvMHAa.js +0 -231
- ksadk/server/static/assets/chunk-QZHKN3VN-YQIEXeF6.js +0 -1
- ksadk/server/static/assets/classDiagram-4FO5ZUOK-CDAUuoXK.js +0 -1
- ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-CDAUuoXK.js +0 -1
- ksadk/server/static/assets/cytoscape.esm-DelgaX4f.js +0 -321
- ksadk/server/static/assets/dagre-BM42HDAG-C7TguAWo.js +0 -4
- ksadk/server/static/assets/dagre-Bx709z4p.js +0 -1
- ksadk/server/static/assets/diagram-2AECGRRQ-Dd17_kws.js +0 -43
- ksadk/server/static/assets/diagram-5GNKFQAL-DVIClIil.js +0 -10
- ksadk/server/static/assets/diagram-KO2AKTUF-Df_3gYQa.js +0 -3
- ksadk/server/static/assets/diagram-LMA3HP47-BhLk6ZtA.js +0 -24
- ksadk/server/static/assets/diagram-OG6HWLK6-D4GNGvuu.js +0 -24
- ksadk/server/static/assets/dist-DyvYLMW9.js +0 -1
- ksadk/server/static/assets/erDiagram-TEJ5UH35-CnfRcY2M.js +0 -85
- ksadk/server/static/assets/eventmodeling-FCH6USID-ChYO-ijM.js +0 -1
- ksadk/server/static/assets/flowDiagram-I6XJVG4X-DGaJjGNJ.js +0 -162
- ksadk/server/static/assets/ganttDiagram-6RSMTGT7-DpCoB2g9.js +0 -292
- ksadk/server/static/assets/gitGraph-WXDBUCRP-BN_0Fk-j.js +0 -1
- ksadk/server/static/assets/gitGraphDiagram-PVQCEYII-9IKVFBVT.js +0 -106
- ksadk/server/static/assets/graphlib-B8gBHxth.js +0 -1
- ksadk/server/static/assets/index-QSwCHuB8.js +0 -177
- ksadk/server/static/assets/index-jOPmTOZH.css +0 -2
- ksadk/server/static/assets/info-J43DQDTF-Cp6o3vId.js +0 -1
- ksadk/server/static/assets/infoDiagram-5YYISTIA-CYIIsb8y.js +0 -2
- ksadk/server/static/assets/katex-DATssjp1.js +0 -265
- ksadk/server/static/assets/mermaid-parser.core-mGAcEwcq.js +0 -161
- ksadk/server/static/assets/packet-YPE3B663-wQzct3cT.js +0 -1
- ksadk/server/static/assets/pie-LRSECV5Y-BgTQiv-9.js +0 -1
- ksadk/server/static/assets/pieDiagram-4H26LBE5-By-ra07M.js +0 -30
- ksadk/server/static/assets/radar-GUYGQ44K-BIq5fXsn.js +0 -1
- ksadk/server/static/assets/requirementDiagram-4Y6WPE33-DpW90iu3.js +0 -84
- ksadk/server/static/assets/stateDiagram-AJRCARHV-DGpd9yFG.js +0 -1
- ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-B3o579IQ.js +0 -1
- ksadk/server/static/assets/treeView-BLDUP644-Dqi-E0EM.js +0 -1
- ksadk/server/static/assets/treemap-LRROVOQU-ClQP9CJ9.js +0 -1
- ksadk/server/static/assets/vennDiagram-CIIHVFJN-l3Z7-wbP.js +0 -34
- ksadk/server/static/assets/wardley-L42UT6IY-8XLvnNIs.js +0 -1
- ksadk/server/static/assets/wardleyDiagram-YWT4CUSO-BR54GPmr.js +0 -78
- ksadk/server/static/assets/xychartDiagram-2RQKCTM6-SkiH8GsK.js +0 -7
- ksadk/tracing/exporters/langfuse_exporter.py +0 -460
- {agentengine_sdk_python-0.8.0.dist-info → agentengine_sdk_python-0.8.2.dist-info}/entry_points.txt +0 -0
- {agentengine_sdk_python-0.8.0.dist-info → agentengine_sdk_python-0.8.2.dist-info}/licenses/LICENSE +0 -0
- {agentengine_sdk_python-0.8.0.dist-info → agentengine_sdk_python-0.8.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1756 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""PostgreSQL ``AgentKernelStore``(Phase 1 Task 4)。
|
|
3
|
+
|
|
4
|
+
预发多写者 durable Inbox / Run / ActivationLease 存储:
|
|
5
|
+
- schema 见 ``ksadk/kernel/sql/001_agent_kernel.sql``(BIGINT fencing_token、
|
|
6
|
+
TIMESTAMPTZ lease、JSONB payload、``(tenant_id, session_id, idempotency_key)`` 唯一);
|
|
7
|
+
- claim 用 ``FOR UPDATE SKIP LOCKED`` 且仍按 ``accepted_seq`` 排序;
|
|
8
|
+
- activation takeover 用单条 ``INSERT .. ON CONFLICT .. DO UPDATE .. WHERE
|
|
9
|
+
lease_expires_at <= now()``(或 released / 同 activation)原子 ``fencing_token + 1``;
|
|
10
|
+
- 每个 writer 事务的第一步是对 activation 行做 ``FOR SHARE`` compare-fence
|
|
11
|
+
(:meth:`_assert_fence`),token/expiry 不匹配抛 :class:`StaleFenceError`
|
|
12
|
+
并回滚整个事务;
|
|
13
|
+
- 与 SQLite 版不同(Task 3 把 ControlEvent 放事务外),这里的 lease CAS、
|
|
14
|
+
Inbox claim/complete、Run transition 与 SessionEvent append 都发生在
|
|
15
|
+
**同一个** PostgreSQL 事务里,commit 前被 kill 不会留下半状态。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from contextlib import asynccontextmanager
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ksadk.events.session_event import (
|
|
27
|
+
_timestamp_to_float,
|
|
28
|
+
envelope_to_session_event,
|
|
29
|
+
session_event_storage_id,
|
|
30
|
+
session_event_to_envelope,
|
|
31
|
+
validate_write_guard,
|
|
32
|
+
)
|
|
33
|
+
from ksadk.interaction.contracts import (
|
|
34
|
+
InteractionRecord,
|
|
35
|
+
InteractionReceipt,
|
|
36
|
+
InteractionSubmission,
|
|
37
|
+
is_terminal,
|
|
38
|
+
)
|
|
39
|
+
from ksadk.interaction.ledger import (
|
|
40
|
+
ALREADY_RESOLVED,
|
|
41
|
+
REVISION_MISMATCH,
|
|
42
|
+
REQUEST_CONFLICT,
|
|
43
|
+
interaction_event,
|
|
44
|
+
request_digest,
|
|
45
|
+
requested_event_payload,
|
|
46
|
+
resolve_outcome,
|
|
47
|
+
submission_digest,
|
|
48
|
+
)
|
|
49
|
+
from ksadk.kernel.contracts import (
|
|
50
|
+
ActivationLease,
|
|
51
|
+
ActivationWriteGuard,
|
|
52
|
+
AdmissionWriteGuard,
|
|
53
|
+
AgentControlCommand,
|
|
54
|
+
AgentControlReceipt,
|
|
55
|
+
ControlError,
|
|
56
|
+
SessionEventEnvelope,
|
|
57
|
+
)
|
|
58
|
+
from ksadk.kernel.errors import InvalidCommandError, StaleFenceError
|
|
59
|
+
from ksadk.kernel.state import (
|
|
60
|
+
InboxState,
|
|
61
|
+
RunState,
|
|
62
|
+
assert_inbox_transition,
|
|
63
|
+
assert_run_transition,
|
|
64
|
+
is_active_run,
|
|
65
|
+
)
|
|
66
|
+
from ksadk.kernel.store import (
|
|
67
|
+
ActivationLeaseRequest,
|
|
68
|
+
InboxMessage,
|
|
69
|
+
RunRecord,
|
|
70
|
+
command_digest,
|
|
71
|
+
control_event,
|
|
72
|
+
new_message_id,
|
|
73
|
+
now_iso,
|
|
74
|
+
)
|
|
75
|
+
from ksadk.sessions._postgres_tables import (
|
|
76
|
+
KSADK_PG_EVENTS_TABLE,
|
|
77
|
+
KSADK_PG_SESSIONS_TABLE,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
SCHEMA_PATH = Path(__file__).parent / "sql" / "001_agent_kernel.sql"
|
|
81
|
+
|
|
82
|
+
NONCE_RETENTION_SECONDS = 24 * 3600.0
|
|
83
|
+
|
|
84
|
+
ACTIVATION_FOR_SHARE_SQL = (
|
|
85
|
+
"SELECT activation_id, fencing_token, lease_expires_at, released, runtime_type,"
|
|
86
|
+
" bundle_digest, capability_digest, agent_instance_id, session_id"
|
|
87
|
+
" FROM kernel_activations"
|
|
88
|
+
" WHERE agent_instance_id = $1 AND session_id = $2"
|
|
89
|
+
" FOR SHARE"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _now() -> datetime:
|
|
94
|
+
return datetime.now(timezone.utc)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_ts(value: str | None):
|
|
98
|
+
if value is None:
|
|
99
|
+
return None
|
|
100
|
+
parsed = datetime.fromisoformat(value)
|
|
101
|
+
if parsed.tzinfo is None:
|
|
102
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
103
|
+
return parsed
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PostgresKernelEventLog:
|
|
107
|
+
"""把 ``SessionEventEnvelope`` 写进共享 session log(ksadk_events)。
|
|
108
|
+
|
|
109
|
+
``append_on(connection, ...)`` 允许调用方把 event insert 并入一个已经
|
|
110
|
+
打开的 kernel writer 事务;``append`` 则自开事务。
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
pool: Any,
|
|
116
|
+
*,
|
|
117
|
+
namespace: str = "default",
|
|
118
|
+
tenant_id: str = "default",
|
|
119
|
+
workspace_id: str = "default",
|
|
120
|
+
) -> None:
|
|
121
|
+
self._pool = pool
|
|
122
|
+
self._namespace = namespace.strip() or "default"
|
|
123
|
+
self._tenant_id = tenant_id.strip() or "default"
|
|
124
|
+
self._workspace_id = workspace_id.strip() or "default"
|
|
125
|
+
|
|
126
|
+
@asynccontextmanager
|
|
127
|
+
async def _connection(self):
|
|
128
|
+
if hasattr(self._pool, "acquire"):
|
|
129
|
+
async with self._pool.acquire() as conn:
|
|
130
|
+
yield conn
|
|
131
|
+
else:
|
|
132
|
+
yield self._pool
|
|
133
|
+
|
|
134
|
+
async def append_on(
|
|
135
|
+
self,
|
|
136
|
+
connection: Any,
|
|
137
|
+
envelope: SessionEventEnvelope,
|
|
138
|
+
guard: Any | None = None,
|
|
139
|
+
) -> SessionEventEnvelope:
|
|
140
|
+
if guard is not None:
|
|
141
|
+
validate_write_guard(envelope, guard)
|
|
142
|
+
if not envelope.session_id.strip():
|
|
143
|
+
raise ValueError("session_id must be nonempty")
|
|
144
|
+
packed = envelope_to_session_event(envelope)
|
|
145
|
+
storage_id = session_event_storage_id(envelope.session_id, str(envelope.event_id))
|
|
146
|
+
# 锁 session 行串行化 seq 分配,与 PostgresSessionService.append_event 相同。
|
|
147
|
+
session_row = await connection.fetchrow(
|
|
148
|
+
f"SELECT id FROM {KSADK_PG_SESSIONS_TABLE} WHERE namespace=$1 AND id=$2 FOR UPDATE",
|
|
149
|
+
self._namespace,
|
|
150
|
+
envelope.session_id,
|
|
151
|
+
)
|
|
152
|
+
if session_row is None:
|
|
153
|
+
raise InvalidCommandError(
|
|
154
|
+
f"session {envelope.session_id!r} does not exist in the shared event log"
|
|
155
|
+
)
|
|
156
|
+
next_seq = await connection.fetchval(
|
|
157
|
+
f"SELECT COALESCE(MAX(seq_id), 0) + 1 FROM {KSADK_PG_EVENTS_TABLE}"
|
|
158
|
+
" WHERE namespace=$1 AND session_id=$2",
|
|
159
|
+
self._namespace,
|
|
160
|
+
envelope.session_id,
|
|
161
|
+
)
|
|
162
|
+
seq = int(next_seq or 1)
|
|
163
|
+
packed.bind_seq_id(seq) # 把物理 seq 绑定回 runtime/session envelope 内容
|
|
164
|
+
await connection.execute(
|
|
165
|
+
f"""
|
|
166
|
+
INSERT INTO {KSADK_PG_EVENTS_TABLE} (
|
|
167
|
+
namespace, tenant_id, workspace_id, id, session_id, author,
|
|
168
|
+
event_type, content_json, timestamp, state_delta_json,
|
|
169
|
+
seq_id, invocation_id, metadata_json
|
|
170
|
+
) VALUES (
|
|
171
|
+
$1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9,
|
|
172
|
+
'{{}}'::jsonb, $10, $11, $12::jsonb
|
|
173
|
+
)
|
|
174
|
+
ON CONFLICT (namespace, id) DO NOTHING
|
|
175
|
+
""",
|
|
176
|
+
self._namespace,
|
|
177
|
+
self._tenant_id,
|
|
178
|
+
self._workspace_id,
|
|
179
|
+
storage_id,
|
|
180
|
+
envelope.session_id,
|
|
181
|
+
packed.author,
|
|
182
|
+
packed.event_type,
|
|
183
|
+
json.dumps(packed.content, ensure_ascii=False),
|
|
184
|
+
_timestamp_to_float(envelope.timestamp),
|
|
185
|
+
int(next_seq or 1),
|
|
186
|
+
packed.invocation_id,
|
|
187
|
+
json.dumps(packed.metadata, ensure_ascii=False),
|
|
188
|
+
)
|
|
189
|
+
stored = await self._fetch_event(connection, envelope.session_id, storage_id)
|
|
190
|
+
if stored is not None:
|
|
191
|
+
return stored
|
|
192
|
+
raise RuntimeError("kernel event insert did not persist") # pragma: no cover
|
|
193
|
+
|
|
194
|
+
async def _fetch_event(
|
|
195
|
+
self, connection: Any, session_id: str, storage_id: str
|
|
196
|
+
) -> SessionEventEnvelope | None:
|
|
197
|
+
row = await connection.fetchrow(
|
|
198
|
+
f"SELECT content_json, metadata_json, seq_id, timestamp FROM {KSADK_PG_EVENTS_TABLE}"
|
|
199
|
+
" WHERE namespace=$1 AND session_id=$2 AND id=$3",
|
|
200
|
+
self._namespace,
|
|
201
|
+
session_id,
|
|
202
|
+
storage_id,
|
|
203
|
+
)
|
|
204
|
+
if row is None:
|
|
205
|
+
return None
|
|
206
|
+
from ksadk.sessions.base import SessionEvent
|
|
207
|
+
|
|
208
|
+
event = SessionEvent(
|
|
209
|
+
id=storage_id,
|
|
210
|
+
session_id=session_id,
|
|
211
|
+
author="",
|
|
212
|
+
event_type="",
|
|
213
|
+
content=json.loads(row["content_json"]),
|
|
214
|
+
timestamp=row["timestamp"],
|
|
215
|
+
seq_id=row["seq_id"],
|
|
216
|
+
metadata=json.loads(row["metadata_json"]),
|
|
217
|
+
)
|
|
218
|
+
return session_event_to_envelope(event)
|
|
219
|
+
|
|
220
|
+
async def append(
|
|
221
|
+
self, envelope: SessionEventEnvelope, *, guard: Any | None = None
|
|
222
|
+
) -> SessionEventEnvelope:
|
|
223
|
+
async with self._connection() as connection:
|
|
224
|
+
async with connection.transaction():
|
|
225
|
+
return await self.append_on(connection, envelope, guard)
|
|
226
|
+
|
|
227
|
+
async def read(self, session_id: str, after_seq: int, limit: int) -> list[SessionEventEnvelope]:
|
|
228
|
+
if limit < 1:
|
|
229
|
+
raise ValueError("limit must be positive")
|
|
230
|
+
async with self._connection() as connection:
|
|
231
|
+
rows = await connection.fetch(
|
|
232
|
+
f"SELECT id, content_json, metadata_json, seq_id, timestamp, author,"
|
|
233
|
+
f" event_type, invocation_id FROM {KSADK_PG_EVENTS_TABLE}"
|
|
234
|
+
" WHERE namespace=$1 AND session_id=$2 AND seq_id > $3"
|
|
235
|
+
" ORDER BY seq_id LIMIT $4",
|
|
236
|
+
self._namespace,
|
|
237
|
+
session_id,
|
|
238
|
+
int(after_seq),
|
|
239
|
+
int(limit),
|
|
240
|
+
)
|
|
241
|
+
from ksadk.sessions.base import SessionEvent
|
|
242
|
+
|
|
243
|
+
envelopes: list[SessionEventEnvelope] = []
|
|
244
|
+
for row in rows:
|
|
245
|
+
event = SessionEvent(
|
|
246
|
+
id=row["id"],
|
|
247
|
+
session_id=session_id,
|
|
248
|
+
author=row["author"],
|
|
249
|
+
event_type=row["event_type"],
|
|
250
|
+
content=json.loads(row["content_json"]),
|
|
251
|
+
timestamp=row["timestamp"],
|
|
252
|
+
seq_id=row["seq_id"],
|
|
253
|
+
invocation_id=row["invocation_id"],
|
|
254
|
+
metadata=json.loads(row["metadata_json"]),
|
|
255
|
+
)
|
|
256
|
+
envelope = session_event_to_envelope(event)
|
|
257
|
+
if envelope is not None:
|
|
258
|
+
envelopes.append(envelope)
|
|
259
|
+
return envelopes
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class PostgresNonceStore:
|
|
263
|
+
"""跨 Pod / 重启 durable 的 mutation nonce 单次使用存储。
|
|
264
|
+
|
|
265
|
+
单条 ``INSERT .. ON CONFLICT (nonce) DO NOTHING`` 原子占位;冲突时读回
|
|
266
|
+
既有 identity 比较:同 ``(command_id, idempotency_key)`` 是网络重试,
|
|
267
|
+
否则判为重放(返回 False)。注册成功时顺带清理超过 retention 的旧行。
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def __init__(self, pool: Any, *, retention_seconds: float = NONCE_RETENTION_SECONDS) -> None:
|
|
271
|
+
self._pool = pool
|
|
272
|
+
self._retention = float(retention_seconds)
|
|
273
|
+
|
|
274
|
+
@asynccontextmanager
|
|
275
|
+
async def _connection(self):
|
|
276
|
+
if hasattr(self._pool, "acquire"):
|
|
277
|
+
async with self._pool.acquire() as conn:
|
|
278
|
+
yield conn
|
|
279
|
+
else:
|
|
280
|
+
yield self._pool
|
|
281
|
+
|
|
282
|
+
async def register(
|
|
283
|
+
self, nonce: str, command_id: str, idempotency_key: str
|
|
284
|
+
) -> bool:
|
|
285
|
+
async with self._connection() as connection:
|
|
286
|
+
async with connection.transaction():
|
|
287
|
+
inserted = await connection.fetchval(
|
|
288
|
+
"INSERT INTO kernel_permit_nonces (nonce, command_id,"
|
|
289
|
+
" idempotency_key) VALUES ($1, $2, $3)"
|
|
290
|
+
" ON CONFLICT (nonce) DO NOTHING RETURNING nonce",
|
|
291
|
+
nonce,
|
|
292
|
+
command_id,
|
|
293
|
+
idempotency_key,
|
|
294
|
+
)
|
|
295
|
+
if inserted is not None:
|
|
296
|
+
await connection.execute(
|
|
297
|
+
"DELETE FROM kernel_permit_nonces"
|
|
298
|
+
" WHERE created_at < now() - make_interval(secs => $1)",
|
|
299
|
+
self._retention,
|
|
300
|
+
)
|
|
301
|
+
return True
|
|
302
|
+
existing = await connection.fetchrow(
|
|
303
|
+
"SELECT command_id, idempotency_key FROM kernel_permit_nonces"
|
|
304
|
+
" WHERE nonce=$1",
|
|
305
|
+
nonce,
|
|
306
|
+
)
|
|
307
|
+
return existing is not None and (
|
|
308
|
+
existing["command_id"] == command_id
|
|
309
|
+
and existing["idempotency_key"] == idempotency_key
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class PostgresAgentKernelStore:
|
|
314
|
+
def __init__(
|
|
315
|
+
self,
|
|
316
|
+
pool: Any,
|
|
317
|
+
session_event_log: PostgresKernelEventLog | None,
|
|
318
|
+
*,
|
|
319
|
+
tenant_id: str = "default",
|
|
320
|
+
owns_pool: bool = False,
|
|
321
|
+
) -> None:
|
|
322
|
+
self._pool = pool
|
|
323
|
+
self._events = session_event_log or PostgresKernelEventLog(pool)
|
|
324
|
+
self.tenant_id = tenant_id
|
|
325
|
+
self._owns_pool = owns_pool
|
|
326
|
+
|
|
327
|
+
# ------------------------------------------------------------- lifecycle
|
|
328
|
+
|
|
329
|
+
@asynccontextmanager
|
|
330
|
+
async def _connection(self):
|
|
331
|
+
if hasattr(self._pool, "acquire"):
|
|
332
|
+
async with self._pool.acquire() as conn:
|
|
333
|
+
yield conn
|
|
334
|
+
else:
|
|
335
|
+
yield self._pool
|
|
336
|
+
|
|
337
|
+
async def ensure_schema(self) -> None:
|
|
338
|
+
schema = SCHEMA_PATH.read_text(encoding="utf-8")
|
|
339
|
+
async with self._connection() as connection:
|
|
340
|
+
await connection.execute(schema)
|
|
341
|
+
|
|
342
|
+
async def reset_for_tests(self) -> None:
|
|
343
|
+
async with self._connection() as connection:
|
|
344
|
+
await connection.execute(
|
|
345
|
+
"DELETE FROM kernel_inbox; DELETE FROM kernel_runs;"
|
|
346
|
+
" DELETE FROM kernel_activations; DELETE FROM kernel_accepted_seq;"
|
|
347
|
+
" DELETE FROM kernel_interactions; DELETE FROM kernel_interaction_submissions;"
|
|
348
|
+
" DELETE FROM ksadk_events WHERE namespace = 'default';"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
async def close(self) -> None:
|
|
352
|
+
if self._owns_pool and hasattr(self._pool, "close"):
|
|
353
|
+
await self._pool.close()
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------- helpers
|
|
356
|
+
|
|
357
|
+
async def _assert_fence(
|
|
358
|
+
self, connection: Any, agent_instance_id: str, session_id: str, expected_fence: int
|
|
359
|
+
) -> dict[str, Any]:
|
|
360
|
+
"""compare-fence:事务内 ``FOR SHARE`` 读 activation 并比较 token/expiry。"""
|
|
361
|
+
|
|
362
|
+
row = await connection.fetchrow(
|
|
363
|
+
ACTIVATION_FOR_SHARE_SQL, agent_instance_id, session_id
|
|
364
|
+
)
|
|
365
|
+
if (
|
|
366
|
+
row is None
|
|
367
|
+
or row["released"]
|
|
368
|
+
or row["lease_expires_at"] <= _now()
|
|
369
|
+
or int(row["fencing_token"]) != int(expected_fence)
|
|
370
|
+
):
|
|
371
|
+
raise StaleFenceError(
|
|
372
|
+
"activation lease does not match expected fence",
|
|
373
|
+
details={
|
|
374
|
+
"agent_instance_id": agent_instance_id,
|
|
375
|
+
"session_id": session_id,
|
|
376
|
+
"expected_fence": int(expected_fence),
|
|
377
|
+
},
|
|
378
|
+
)
|
|
379
|
+
return dict(row)
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _receipt(
|
|
383
|
+
command: AgentControlCommand,
|
|
384
|
+
status: str,
|
|
385
|
+
*,
|
|
386
|
+
message_id: str | None = None,
|
|
387
|
+
accepted_seq: int | None = None,
|
|
388
|
+
error: ControlError | None = None,
|
|
389
|
+
) -> AgentControlReceipt:
|
|
390
|
+
return AgentControlReceipt(
|
|
391
|
+
command_id=command.command_id,
|
|
392
|
+
status=status, # type: ignore[arg-type]
|
|
393
|
+
message_id=message_id,
|
|
394
|
+
accepted_seq=accepted_seq,
|
|
395
|
+
error=error,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
async def _append_admission(
|
|
399
|
+
self, connection: Any, command: AgentControlCommand, envelope: SessionEventEnvelope
|
|
400
|
+
) -> None:
|
|
401
|
+
# accepted/rejected 事实的 write guard 绑定提交方的 permit 引用
|
|
402
|
+
# (server permit_id 或本地 authority),不落内核自造 ref。
|
|
403
|
+
await self._events.append_on(
|
|
404
|
+
connection,
|
|
405
|
+
envelope,
|
|
406
|
+
AdmissionWriteGuard(
|
|
407
|
+
authorization_ref=command.authorization_ref,
|
|
408
|
+
command_id=command.command_id,
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
async def _append_activation_fact(
|
|
413
|
+
self,
|
|
414
|
+
connection: Any,
|
|
415
|
+
envelope: SessionEventEnvelope,
|
|
416
|
+
activation: dict[str, Any],
|
|
417
|
+
fence: int,
|
|
418
|
+
) -> SessionEventEnvelope:
|
|
419
|
+
return await self._events.append_on(
|
|
420
|
+
connection,
|
|
421
|
+
envelope,
|
|
422
|
+
ActivationWriteGuard(
|
|
423
|
+
activation_id=activation["activation_id"], fencing_token=int(fence)
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# --------------------------------------------------------------- commands
|
|
428
|
+
|
|
429
|
+
async def accept_command(
|
|
430
|
+
self, command: AgentControlCommand, *, queue_limit: int
|
|
431
|
+
) -> AgentControlReceipt:
|
|
432
|
+
if queue_limit < 1:
|
|
433
|
+
raise InvalidCommandError("queue_limit must be positive")
|
|
434
|
+
async with self._connection() as connection:
|
|
435
|
+
async with connection.transaction():
|
|
436
|
+
existing = await connection.fetchrow(
|
|
437
|
+
"SELECT message_id, accepted_seq, request_digest FROM kernel_inbox"
|
|
438
|
+
" WHERE tenant_id=$1 AND session_id=$2 AND idempotency_key=$3",
|
|
439
|
+
self.tenant_id,
|
|
440
|
+
command.session_id,
|
|
441
|
+
command.idempotency_key,
|
|
442
|
+
)
|
|
443
|
+
if existing is not None:
|
|
444
|
+
if existing["request_digest"] != command_digest(command):
|
|
445
|
+
await self._append_admission(
|
|
446
|
+
connection,
|
|
447
|
+
command,
|
|
448
|
+
control_event(
|
|
449
|
+
session_id=command.session_id,
|
|
450
|
+
event_type="control.command_rejected",
|
|
451
|
+
payload={
|
|
452
|
+
"command_id": str(command.command_id),
|
|
453
|
+
"status": "rejected",
|
|
454
|
+
"reason": "idempotency_conflict",
|
|
455
|
+
},
|
|
456
|
+
causation_id=str(command.command_id),
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
return self._receipt(
|
|
460
|
+
command,
|
|
461
|
+
"rejected",
|
|
462
|
+
error=ControlError(
|
|
463
|
+
code="idempotency_conflict",
|
|
464
|
+
message=(
|
|
465
|
+
"idempotency key reused with a different request digest"
|
|
466
|
+
),
|
|
467
|
+
retryable=False,
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
return self._receipt(
|
|
471
|
+
command,
|
|
472
|
+
"duplicate",
|
|
473
|
+
message_id=str(existing["message_id"]),
|
|
474
|
+
accepted_seq=int(existing["accepted_seq"]),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
depth = await connection.fetchval(
|
|
478
|
+
"SELECT COUNT(*) FROM kernel_inbox WHERE tenant_id=$1"
|
|
479
|
+
" AND agent_instance_id=$2 AND session_id=$3 AND status='accepted'",
|
|
480
|
+
self.tenant_id,
|
|
481
|
+
command.agent_instance_id,
|
|
482
|
+
command.session_id,
|
|
483
|
+
)
|
|
484
|
+
if int(depth) >= queue_limit:
|
|
485
|
+
await self._append_admission(
|
|
486
|
+
connection,
|
|
487
|
+
command,
|
|
488
|
+
control_event(
|
|
489
|
+
session_id=command.session_id,
|
|
490
|
+
event_type="control.command_rejected",
|
|
491
|
+
payload={
|
|
492
|
+
"command_id": str(command.command_id),
|
|
493
|
+
"status": "queue_full",
|
|
494
|
+
"queue_limit": queue_limit,
|
|
495
|
+
},
|
|
496
|
+
causation_id=str(command.command_id),
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
return self._receipt(
|
|
500
|
+
command,
|
|
501
|
+
"queue_full",
|
|
502
|
+
error=ControlError(
|
|
503
|
+
code="queue_full",
|
|
504
|
+
message=f"inbox reached queue_limit={queue_limit}",
|
|
505
|
+
retryable=True,
|
|
506
|
+
),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
accepted_seq = await connection.fetchval(
|
|
510
|
+
"UPDATE kernel_accepted_seq SET last_seq = last_seq + 1"
|
|
511
|
+
" WHERE tenant_id=$1 AND session_id=$2 RETURNING last_seq",
|
|
512
|
+
self.tenant_id,
|
|
513
|
+
command.session_id,
|
|
514
|
+
)
|
|
515
|
+
if accepted_seq is None:
|
|
516
|
+
await connection.execute(
|
|
517
|
+
"INSERT INTO kernel_accepted_seq (tenant_id, session_id, last_seq)"
|
|
518
|
+
" VALUES ($1, $2, 1) ON CONFLICT (tenant_id, session_id)"
|
|
519
|
+
" DO UPDATE SET last_seq = kernel_accepted_seq.last_seq + 1"
|
|
520
|
+
" RETURNING last_seq",
|
|
521
|
+
self.tenant_id,
|
|
522
|
+
command.session_id,
|
|
523
|
+
)
|
|
524
|
+
accepted_seq = await connection.fetchval(
|
|
525
|
+
"SELECT last_seq FROM kernel_accepted_seq"
|
|
526
|
+
" WHERE tenant_id=$1 AND session_id=$2",
|
|
527
|
+
self.tenant_id,
|
|
528
|
+
command.session_id,
|
|
529
|
+
)
|
|
530
|
+
accepted_seq = int(accepted_seq or 1)
|
|
531
|
+
message_id = new_message_id()
|
|
532
|
+
await connection.execute(
|
|
533
|
+
"INSERT INTO kernel_inbox (message_id, tenant_id, agent_instance_id,"
|
|
534
|
+
" session_id, idempotency_key, request_digest, accepted_seq, status,"
|
|
535
|
+
" claimed_fence, payload) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7,"
|
|
536
|
+
" 'accepted', NULL, $8::jsonb)",
|
|
537
|
+
message_id,
|
|
538
|
+
self.tenant_id,
|
|
539
|
+
command.agent_instance_id,
|
|
540
|
+
command.session_id,
|
|
541
|
+
command.idempotency_key,
|
|
542
|
+
command_digest(command),
|
|
543
|
+
accepted_seq,
|
|
544
|
+
command.model_dump_json(),
|
|
545
|
+
)
|
|
546
|
+
# ControlEvent 与 inbox insert 同一事务(SQLite 版在事务外,此处按计划收进)。
|
|
547
|
+
await self._append_admission(
|
|
548
|
+
connection,
|
|
549
|
+
command,
|
|
550
|
+
control_event(
|
|
551
|
+
session_id=command.session_id,
|
|
552
|
+
event_type="control.command_accepted",
|
|
553
|
+
payload={
|
|
554
|
+
"command_id": str(command.command_id),
|
|
555
|
+
"status": "accepted",
|
|
556
|
+
"message_id": message_id,
|
|
557
|
+
"accepted_seq": accepted_seq,
|
|
558
|
+
"command_type": command.command_type,
|
|
559
|
+
},
|
|
560
|
+
causation_id=str(command.command_id),
|
|
561
|
+
),
|
|
562
|
+
)
|
|
563
|
+
return self._receipt(
|
|
564
|
+
command, "accepted", message_id=message_id, accepted_seq=accepted_seq
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
async def load_message(self, message_id: str) -> InboxMessage | None:
|
|
568
|
+
async with self._connection() as connection:
|
|
569
|
+
row = await connection.fetchrow(
|
|
570
|
+
"SELECT * FROM kernel_inbox WHERE message_id=$1::uuid", str(message_id)
|
|
571
|
+
)
|
|
572
|
+
return self._row_to_message(row)
|
|
573
|
+
|
|
574
|
+
@staticmethod
|
|
575
|
+
def _row_to_message(row: Any) -> InboxMessage | None:
|
|
576
|
+
if row is None:
|
|
577
|
+
return None
|
|
578
|
+
return InboxMessage(
|
|
579
|
+
message_id=str(row["message_id"]),
|
|
580
|
+
agent_instance_id=row["agent_instance_id"],
|
|
581
|
+
session_id=row["session_id"],
|
|
582
|
+
idempotency_key=row["idempotency_key"],
|
|
583
|
+
request_digest=row["request_digest"],
|
|
584
|
+
accepted_seq=int(row["accepted_seq"]),
|
|
585
|
+
status=InboxState(row["status"]),
|
|
586
|
+
claimed_fence=(
|
|
587
|
+
int(row["claimed_fence"]) if row["claimed_fence"] is not None else None
|
|
588
|
+
),
|
|
589
|
+
command=AgentControlCommand.model_validate_json(row["payload"]),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
async def load_by_idempotency(
|
|
593
|
+
self, session_id: str, idempotency_key: str
|
|
594
|
+
) -> InboxMessage | None:
|
|
595
|
+
async with self._connection() as connection:
|
|
596
|
+
row = await connection.fetchrow(
|
|
597
|
+
"SELECT * FROM kernel_inbox WHERE tenant_id=$1 AND session_id=$2"
|
|
598
|
+
" AND idempotency_key=$3",
|
|
599
|
+
self.tenant_id,
|
|
600
|
+
session_id,
|
|
601
|
+
idempotency_key,
|
|
602
|
+
)
|
|
603
|
+
return self._row_to_message(row)
|
|
604
|
+
|
|
605
|
+
async def reject_command(
|
|
606
|
+
self,
|
|
607
|
+
command: AgentControlCommand,
|
|
608
|
+
*,
|
|
609
|
+
status: str,
|
|
610
|
+
code: str,
|
|
611
|
+
message: str,
|
|
612
|
+
retryable: bool = False,
|
|
613
|
+
) -> AgentControlReceipt:
|
|
614
|
+
"""admission 拒绝(invalid_permit / unsupported / ...)的脱敏审计 + receipt。
|
|
615
|
+
|
|
616
|
+
与 InMemory 版语义一致:只追加 ``control.command_rejected`` 事实,
|
|
617
|
+
不写 Inbox 行。
|
|
618
|
+
"""
|
|
619
|
+
async with self._connection() as connection:
|
|
620
|
+
async with connection.transaction():
|
|
621
|
+
await self._append_admission(
|
|
622
|
+
connection,
|
|
623
|
+
command,
|
|
624
|
+
control_event(
|
|
625
|
+
session_id=command.session_id,
|
|
626
|
+
event_type="control.command_rejected",
|
|
627
|
+
payload={
|
|
628
|
+
"command_id": str(command.command_id),
|
|
629
|
+
"status": status,
|
|
630
|
+
"reason": code,
|
|
631
|
+
},
|
|
632
|
+
causation_id=str(command.command_id),
|
|
633
|
+
),
|
|
634
|
+
)
|
|
635
|
+
return self._receipt(
|
|
636
|
+
command,
|
|
637
|
+
status,
|
|
638
|
+
error=ControlError(code=code, message=message, retryable=retryable),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
async def list_messages(
|
|
642
|
+
self, agent_instance_id: str, session_id: str | None = None
|
|
643
|
+
) -> list[InboxMessage]:
|
|
644
|
+
async with self._connection() as connection:
|
|
645
|
+
rows = await connection.fetch(
|
|
646
|
+
"SELECT * FROM kernel_inbox WHERE agent_instance_id=$1"
|
|
647
|
+
+ (" AND session_id=$2" if session_id else "")
|
|
648
|
+
+ " ORDER BY accepted_seq",
|
|
649
|
+
agent_instance_id,
|
|
650
|
+
*([session_id] if session_id else []),
|
|
651
|
+
)
|
|
652
|
+
return [m for m in (self._row_to_message(r) for r in rows) if m is not None]
|
|
653
|
+
|
|
654
|
+
async def list_pending(
|
|
655
|
+
self,
|
|
656
|
+
agent_instance_id: str,
|
|
657
|
+
session_id: str | None = None,
|
|
658
|
+
*,
|
|
659
|
+
fencing_token: int | None = None,
|
|
660
|
+
) -> list[InboxMessage]:
|
|
661
|
+
"""按 accepted_seq 返回可恢复消息。
|
|
662
|
+
|
|
663
|
+
传入当前 fence 时,旧 fence 留下的 ``claimed`` 也必须可见;真正的
|
|
664
|
+
owner 校验与 token 改写由 ``claim_message`` 在事务内完成。否则 Pod
|
|
665
|
+
恰好在 claim 后退出,会让 stale claimed 永久挡住 FIFO 头。
|
|
666
|
+
"""
|
|
667
|
+
sql = (
|
|
668
|
+
"SELECT * FROM kernel_inbox WHERE agent_instance_id=$1"
|
|
669
|
+
+ (" AND session_id=$2" if session_id else "")
|
|
670
|
+
+ (
|
|
671
|
+
" AND status IN ('accepted','claimed')"
|
|
672
|
+
if fencing_token is not None
|
|
673
|
+
else " AND status='accepted'"
|
|
674
|
+
)
|
|
675
|
+
+ " ORDER BY accepted_seq"
|
|
676
|
+
)
|
|
677
|
+
args: list[Any] = [agent_instance_id]
|
|
678
|
+
if session_id:
|
|
679
|
+
args.append(session_id)
|
|
680
|
+
async with self._connection() as connection:
|
|
681
|
+
rows = await connection.fetch(sql, *args)
|
|
682
|
+
return [m for m in (self._row_to_message(r) for r in rows) if m is not None]
|
|
683
|
+
|
|
684
|
+
async def claim_message(self, message_id: str, fencing_token: int) -> InboxMessage:
|
|
685
|
+
"""按 message_id 认领(worker 选择性 FIFO);同 fence 重复认领幂等。"""
|
|
686
|
+
message_id = str(message_id)
|
|
687
|
+
async with self._connection() as connection:
|
|
688
|
+
async with connection.transaction():
|
|
689
|
+
row = await connection.fetchrow(
|
|
690
|
+
"SELECT * FROM kernel_inbox WHERE message_id=$1::uuid FOR UPDATE",
|
|
691
|
+
message_id,
|
|
692
|
+
)
|
|
693
|
+
if row is None:
|
|
694
|
+
raise InvalidCommandError(f"unknown message_id {message_id!r}")
|
|
695
|
+
if (
|
|
696
|
+
row["status"] == InboxState.CLAIMED.value
|
|
697
|
+
and int(row["claimed_fence"]) == int(fencing_token)
|
|
698
|
+
):
|
|
699
|
+
return self._row_to_message(row) # type: ignore[return-value]
|
|
700
|
+
activation = await self._assert_fence(
|
|
701
|
+
connection,
|
|
702
|
+
row["agent_instance_id"],
|
|
703
|
+
row["session_id"],
|
|
704
|
+
fencing_token,
|
|
705
|
+
)
|
|
706
|
+
if row["status"] not in (
|
|
707
|
+
InboxState.ACCEPTED.value,
|
|
708
|
+
InboxState.CLAIMED.value,
|
|
709
|
+
):
|
|
710
|
+
raise InvalidCommandError(
|
|
711
|
+
f"message {message_id!r} is not claimable"
|
|
712
|
+
f" at status {row['status']}"
|
|
713
|
+
)
|
|
714
|
+
command = AgentControlCommand.model_validate_json(row["payload"])
|
|
715
|
+
if command.command_type == "enqueue":
|
|
716
|
+
# Strict per-session FIFO is a database invariant, not a
|
|
717
|
+
# scheduler convention. A second worker may have listed
|
|
718
|
+
# pending rows before the first worker committed its
|
|
719
|
+
# claim. Never let it skip an earlier enqueue that is
|
|
720
|
+
# still accepted/claimed; the query also observes an
|
|
721
|
+
# uncommitted earlier update as ``accepted`` under READ
|
|
722
|
+
# COMMITTED, so the later claim fails closed.
|
|
723
|
+
earlier = await connection.fetchval(
|
|
724
|
+
"SELECT EXISTS (SELECT 1 FROM kernel_inbox"
|
|
725
|
+
" WHERE tenant_id=$1 AND agent_instance_id=$2"
|
|
726
|
+
" AND session_id=$3 AND accepted_seq < $4"
|
|
727
|
+
" AND status IN ('accepted','claimed')"
|
|
728
|
+
" AND payload->>'command_type'='enqueue')",
|
|
729
|
+
self.tenant_id,
|
|
730
|
+
row["agent_instance_id"],
|
|
731
|
+
row["session_id"],
|
|
732
|
+
int(row["accepted_seq"]),
|
|
733
|
+
)
|
|
734
|
+
if earlier:
|
|
735
|
+
raise InvalidCommandError(
|
|
736
|
+
f"message {message_id!r} is not the FIFO enqueue head"
|
|
737
|
+
)
|
|
738
|
+
if row["status"] == InboxState.ACCEPTED.value:
|
|
739
|
+
assert_inbox_transition(
|
|
740
|
+
InboxState(row["status"]), InboxState.CLAIMED
|
|
741
|
+
)
|
|
742
|
+
await connection.execute(
|
|
743
|
+
"UPDATE kernel_inbox SET status='claimed', claimed_fence=$1"
|
|
744
|
+
" WHERE message_id=$2::uuid",
|
|
745
|
+
int(fencing_token),
|
|
746
|
+
message_id,
|
|
747
|
+
)
|
|
748
|
+
await self._append_activation_fact(
|
|
749
|
+
connection,
|
|
750
|
+
control_event(
|
|
751
|
+
session_id=row["session_id"],
|
|
752
|
+
event_type="control.message_claimed",
|
|
753
|
+
payload={
|
|
754
|
+
"message_id": message_id,
|
|
755
|
+
"accepted_seq": int(row["accepted_seq"]),
|
|
756
|
+
"fencing_token": int(fencing_token),
|
|
757
|
+
},
|
|
758
|
+
),
|
|
759
|
+
activation,
|
|
760
|
+
fencing_token,
|
|
761
|
+
)
|
|
762
|
+
return await self.load_message(message_id) # type: ignore[return-value]
|
|
763
|
+
|
|
764
|
+
async def discard_claim(self, message_id: str, *, expected_fence: int) -> None:
|
|
765
|
+
"""typed rejection 的确定性收口:CLAIMED -> DISCARDED。"""
|
|
766
|
+
message_id = str(message_id)
|
|
767
|
+
async with self._connection() as connection:
|
|
768
|
+
async with connection.transaction():
|
|
769
|
+
row = await connection.fetchrow(
|
|
770
|
+
"SELECT * FROM kernel_inbox WHERE message_id=$1::uuid FOR UPDATE",
|
|
771
|
+
message_id,
|
|
772
|
+
)
|
|
773
|
+
if row is None:
|
|
774
|
+
raise InvalidCommandError(f"unknown message_id {message_id!r}")
|
|
775
|
+
activation = await self._assert_fence(
|
|
776
|
+
connection,
|
|
777
|
+
row["agent_instance_id"],
|
|
778
|
+
row["session_id"],
|
|
779
|
+
expected_fence,
|
|
780
|
+
)
|
|
781
|
+
if (
|
|
782
|
+
row["status"] != InboxState.CLAIMED.value
|
|
783
|
+
or int(row["claimed_fence"]) != int(expected_fence)
|
|
784
|
+
):
|
|
785
|
+
raise StaleFenceError(
|
|
786
|
+
f"message {message_id!r} is not claimed at fence {expected_fence}"
|
|
787
|
+
)
|
|
788
|
+
assert_inbox_transition(InboxState(row["status"]), InboxState.DISCARDED)
|
|
789
|
+
await connection.execute(
|
|
790
|
+
"UPDATE kernel_inbox SET status='discarded' WHERE message_id=$1::uuid",
|
|
791
|
+
message_id,
|
|
792
|
+
)
|
|
793
|
+
await self._append_activation_fact(
|
|
794
|
+
connection,
|
|
795
|
+
control_event(
|
|
796
|
+
session_id=row["session_id"],
|
|
797
|
+
event_type="control.message_discarded",
|
|
798
|
+
payload={
|
|
799
|
+
"message_id": message_id,
|
|
800
|
+
"fencing_token": int(expected_fence),
|
|
801
|
+
},
|
|
802
|
+
),
|
|
803
|
+
activation,
|
|
804
|
+
expected_fence,
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
async def inbox_depth(
|
|
808
|
+
self, agent_instance_id: str, session_id: str | None = None
|
|
809
|
+
) -> int:
|
|
810
|
+
async with self._connection() as connection:
|
|
811
|
+
return int(
|
|
812
|
+
await connection.fetchval(
|
|
813
|
+
"SELECT COUNT(*) FROM kernel_inbox WHERE agent_instance_id=$1"
|
|
814
|
+
+ (" AND session_id=$2" if session_id else "")
|
|
815
|
+
+ " AND status='accepted'",
|
|
816
|
+
agent_instance_id,
|
|
817
|
+
*([session_id] if session_id else []),
|
|
818
|
+
)
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
async def find_active_run(
|
|
822
|
+
self, agent_instance_id: str, session_id: str | None = None
|
|
823
|
+
) -> RunRecord | None:
|
|
824
|
+
async with self._connection() as connection:
|
|
825
|
+
rows = await connection.fetch(
|
|
826
|
+
"SELECT * FROM kernel_runs WHERE agent_instance_id=$1"
|
|
827
|
+
+ (" AND session_id=$2" if session_id else "")
|
|
828
|
+
+ " ORDER BY created_at",
|
|
829
|
+
agent_instance_id,
|
|
830
|
+
*([session_id] if session_id else []),
|
|
831
|
+
)
|
|
832
|
+
for row in rows:
|
|
833
|
+
if is_active_run(RunState(row["state"])):
|
|
834
|
+
return RunRecord(
|
|
835
|
+
run_id=row["run_id"],
|
|
836
|
+
agent_instance_id=row["agent_instance_id"],
|
|
837
|
+
session_id=row["session_id"],
|
|
838
|
+
state=row["state"],
|
|
839
|
+
activation_fence=int(row["activation_fence"]),
|
|
840
|
+
created_at=row["created_at"].isoformat(),
|
|
841
|
+
updated_at=row["updated_at"].isoformat(),
|
|
842
|
+
metadata=json.loads(row["metadata"]),
|
|
843
|
+
)
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
async def current_lease(
|
|
847
|
+
self, agent_instance_id: str, session_id: str | None = None
|
|
848
|
+
) -> ActivationLease | None:
|
|
849
|
+
async with self._connection() as connection:
|
|
850
|
+
row = await connection.fetchrow(
|
|
851
|
+
"SELECT * FROM kernel_activations WHERE agent_instance_id=$1"
|
|
852
|
+
+ (" AND session_id=$2" if session_id else "")
|
|
853
|
+
+ " AND released=FALSE AND lease_expires_at > now()"
|
|
854
|
+
+ (" ORDER BY lease_expires_at DESC LIMIT 1"),
|
|
855
|
+
agent_instance_id,
|
|
856
|
+
*([session_id] if session_id else []),
|
|
857
|
+
)
|
|
858
|
+
if row is None:
|
|
859
|
+
return None
|
|
860
|
+
return ActivationLease(
|
|
861
|
+
agent_instance_id=row["agent_instance_id"],
|
|
862
|
+
activation_id=row["activation_id"],
|
|
863
|
+
fencing_token=int(row["fencing_token"]),
|
|
864
|
+
lease_expires_at=row["lease_expires_at"].isoformat(),
|
|
865
|
+
bundle_digest=row["bundle_digest"],
|
|
866
|
+
runtime_type=row["runtime_type"],
|
|
867
|
+
capability_digest=row["capability_digest"],
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
async def claim_next(
|
|
871
|
+
self, agent_instance_id: str, session_id: str, fencing_token: int
|
|
872
|
+
) -> InboxMessage | None:
|
|
873
|
+
async with self._connection() as connection:
|
|
874
|
+
async with connection.transaction():
|
|
875
|
+
activation = await self._assert_fence(
|
|
876
|
+
connection, agent_instance_id, session_id, fencing_token
|
|
877
|
+
)
|
|
878
|
+
row = await connection.fetchrow(
|
|
879
|
+
"SELECT message_id, status FROM kernel_inbox"
|
|
880
|
+
" WHERE agent_instance_id=$1 AND session_id=$2"
|
|
881
|
+
" AND (status='accepted' OR (status='claimed' AND claimed_fence <> $3))"
|
|
882
|
+
" ORDER BY accepted_seq"
|
|
883
|
+
" FOR UPDATE SKIP LOCKED"
|
|
884
|
+
" LIMIT 1",
|
|
885
|
+
agent_instance_id,
|
|
886
|
+
session_id,
|
|
887
|
+
int(fencing_token),
|
|
888
|
+
)
|
|
889
|
+
if row is None:
|
|
890
|
+
return None
|
|
891
|
+
if row["status"] == InboxState.ACCEPTED.value:
|
|
892
|
+
assert_inbox_transition(InboxState(row["status"]), InboxState.CLAIMED)
|
|
893
|
+
await connection.execute(
|
|
894
|
+
"UPDATE kernel_inbox SET status='claimed', claimed_fence=$1"
|
|
895
|
+
" WHERE message_id=$2::uuid",
|
|
896
|
+
int(fencing_token),
|
|
897
|
+
str(row["message_id"]),
|
|
898
|
+
)
|
|
899
|
+
await self._append_activation_fact(
|
|
900
|
+
connection,
|
|
901
|
+
control_event(
|
|
902
|
+
session_id=session_id,
|
|
903
|
+
event_type="control.message_claimed",
|
|
904
|
+
payload={
|
|
905
|
+
"message_id": str(row["message_id"]),
|
|
906
|
+
"fencing_token": int(fencing_token),
|
|
907
|
+
},
|
|
908
|
+
),
|
|
909
|
+
activation,
|
|
910
|
+
fencing_token,
|
|
911
|
+
)
|
|
912
|
+
return await self.load_message(row["message_id"])
|
|
913
|
+
|
|
914
|
+
async def complete_claim(self, message_id: str, *, expected_fence: int) -> None:
|
|
915
|
+
message_id = str(message_id)
|
|
916
|
+
async with self._connection() as connection:
|
|
917
|
+
async with connection.transaction():
|
|
918
|
+
row = await connection.fetchrow(
|
|
919
|
+
"SELECT * FROM kernel_inbox WHERE message_id=$1::uuid", message_id
|
|
920
|
+
)
|
|
921
|
+
if row is None:
|
|
922
|
+
raise InvalidCommandError(f"unknown message_id {message_id!r}")
|
|
923
|
+
activation = await self._assert_fence(
|
|
924
|
+
connection, row["agent_instance_id"], row["session_id"], expected_fence
|
|
925
|
+
)
|
|
926
|
+
if (
|
|
927
|
+
row["status"] != InboxState.CLAIMED.value
|
|
928
|
+
or row["claimed_fence"] != int(expected_fence)
|
|
929
|
+
):
|
|
930
|
+
raise StaleFenceError(
|
|
931
|
+
f"message {message_id!r} is not claimed at fence {expected_fence}"
|
|
932
|
+
)
|
|
933
|
+
assert_inbox_transition(InboxState(row["status"]), InboxState.COMPLETED)
|
|
934
|
+
await connection.execute(
|
|
935
|
+
"UPDATE kernel_inbox SET status='completed' WHERE message_id=$1::uuid",
|
|
936
|
+
message_id,
|
|
937
|
+
)
|
|
938
|
+
await self._append_activation_fact(
|
|
939
|
+
connection,
|
|
940
|
+
control_event(
|
|
941
|
+
session_id=row["session_id"],
|
|
942
|
+
event_type="control.message_completed",
|
|
943
|
+
payload={"message_id": message_id, "fencing_token": int(expected_fence)},
|
|
944
|
+
),
|
|
945
|
+
activation,
|
|
946
|
+
expected_fence,
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
# -------------------------------------------------------------- interactions
|
|
950
|
+
|
|
951
|
+
async def _assert_interaction_guard(
|
|
952
|
+
self, connection: Any, agent_instance_id: str, session_id: str, guard: Any
|
|
953
|
+
) -> dict[str, Any]:
|
|
954
|
+
row = await connection.fetchrow(
|
|
955
|
+
"SELECT activation_id, agent_instance_id, session_id, fencing_token,"
|
|
956
|
+
" lease_expires_at, released FROM kernel_activations"
|
|
957
|
+
" WHERE activation_id = $1 AND agent_instance_id = $2"
|
|
958
|
+
" AND session_id = $3 FOR SHARE",
|
|
959
|
+
guard.activation_id,
|
|
960
|
+
agent_instance_id,
|
|
961
|
+
session_id,
|
|
962
|
+
)
|
|
963
|
+
if (
|
|
964
|
+
row is None
|
|
965
|
+
or row["released"]
|
|
966
|
+
or row["lease_expires_at"] <= _now()
|
|
967
|
+
or int(row["fencing_token"]) != int(guard.fencing_token)
|
|
968
|
+
or row["agent_instance_id"] != agent_instance_id
|
|
969
|
+
or row["session_id"] != session_id
|
|
970
|
+
):
|
|
971
|
+
raise StaleFenceError(
|
|
972
|
+
"interaction write guard does not match the current lease",
|
|
973
|
+
details={
|
|
974
|
+
"activation_id": guard.activation_id,
|
|
975
|
+
"fencing_token": int(guard.fencing_token),
|
|
976
|
+
"session_id": session_id,
|
|
977
|
+
},
|
|
978
|
+
)
|
|
979
|
+
return dict(row)
|
|
980
|
+
|
|
981
|
+
async def _interaction_row_for_guard(
|
|
982
|
+
self, connection: Any, interaction_id: str, guard: Any
|
|
983
|
+
) -> Any | None:
|
|
984
|
+
"""Resolve a public interaction id within its fenced activation scope.
|
|
985
|
+
|
|
986
|
+
Interaction ids are opaque browser-visible handles, not tenant grants.
|
|
987
|
+
A Runtime mutation already has the Server-admitted activation guard, so
|
|
988
|
+
select the row by that trusted AgentInstance/session before taking the
|
|
989
|
+
row lock. This prevents a same-id record in another tenant from being
|
|
990
|
+
selected and then rejected only after information has been consulted.
|
|
991
|
+
"""
|
|
992
|
+
|
|
993
|
+
activation = await connection.fetchrow(
|
|
994
|
+
"SELECT activation_id, agent_instance_id, session_id, fencing_token,"
|
|
995
|
+
" lease_expires_at, released FROM kernel_activations"
|
|
996
|
+
" WHERE activation_id=$1 FOR SHARE",
|
|
997
|
+
guard.activation_id,
|
|
998
|
+
)
|
|
999
|
+
if (
|
|
1000
|
+
activation is None
|
|
1001
|
+
or activation["released"]
|
|
1002
|
+
or activation["lease_expires_at"] <= _now()
|
|
1003
|
+
or int(activation["fencing_token"]) != int(guard.fencing_token)
|
|
1004
|
+
):
|
|
1005
|
+
raise StaleFenceError(
|
|
1006
|
+
"interaction write guard does not match the current lease",
|
|
1007
|
+
details={
|
|
1008
|
+
"activation_id": guard.activation_id,
|
|
1009
|
+
"fencing_token": int(guard.fencing_token),
|
|
1010
|
+
},
|
|
1011
|
+
)
|
|
1012
|
+
return await connection.fetchrow(
|
|
1013
|
+
"SELECT * FROM kernel_interactions WHERE interaction_id=$1"
|
|
1014
|
+
" AND agent_instance_id=$2 AND session_id=$3 FOR UPDATE",
|
|
1015
|
+
interaction_id,
|
|
1016
|
+
activation["agent_instance_id"],
|
|
1017
|
+
activation["session_id"],
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
@staticmethod
|
|
1021
|
+
def _interaction_row_to_record(row: Any) -> InteractionRecord:
|
|
1022
|
+
from ksadk.interaction.contracts import InteractionPresentation
|
|
1023
|
+
|
|
1024
|
+
presentation = None
|
|
1025
|
+
if row["presentation"] is not None:
|
|
1026
|
+
presentation = InteractionPresentation.model_validate(
|
|
1027
|
+
json.loads(row["presentation"])
|
|
1028
|
+
)
|
|
1029
|
+
return InteractionRecord(
|
|
1030
|
+
interaction_id=row["interaction_id"],
|
|
1031
|
+
tenant_id=row["tenant_id"],
|
|
1032
|
+
agent_instance_id=row["agent_instance_id"],
|
|
1033
|
+
session_id=row["session_id"],
|
|
1034
|
+
run_id=row["run_id"],
|
|
1035
|
+
kind=row["kind"],
|
|
1036
|
+
request_schema=json.loads(row["request_schema"]),
|
|
1037
|
+
revision=int(row["revision"]),
|
|
1038
|
+
status=row["status"],
|
|
1039
|
+
created_at=row["created_at"].isoformat(),
|
|
1040
|
+
expires_at=(
|
|
1041
|
+
row["expires_at"].isoformat() if row["expires_at"] is not None else None
|
|
1042
|
+
),
|
|
1043
|
+
presentation=presentation,
|
|
1044
|
+
provider_id=row["provider_id"] or "",
|
|
1045
|
+
native_target=(
|
|
1046
|
+
json.loads(row["native_target"])
|
|
1047
|
+
if row["native_target"] is not None
|
|
1048
|
+
else None
|
|
1049
|
+
),
|
|
1050
|
+
continuation_metadata=(
|
|
1051
|
+
json.loads(row["continuation_metadata"])
|
|
1052
|
+
if row["continuation_metadata"] is not None
|
|
1053
|
+
else None
|
|
1054
|
+
),
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
async def request(
|
|
1058
|
+
self, record: InteractionRecord, *, guard: Any
|
|
1059
|
+
) -> InteractionRecord:
|
|
1060
|
+
digest = request_digest(record)
|
|
1061
|
+
async with self._connection() as connection:
|
|
1062
|
+
async with connection.transaction():
|
|
1063
|
+
await self._assert_interaction_guard(
|
|
1064
|
+
connection, record.agent_instance_id, record.session_id, guard
|
|
1065
|
+
)
|
|
1066
|
+
existing = await connection.fetchrow(
|
|
1067
|
+
"SELECT * FROM kernel_interactions"
|
|
1068
|
+
" WHERE tenant_id=$1 AND interaction_id=$2",
|
|
1069
|
+
record.tenant_id,
|
|
1070
|
+
record.interaction_id,
|
|
1071
|
+
)
|
|
1072
|
+
if existing is not None:
|
|
1073
|
+
if existing["request_digest"] != digest:
|
|
1074
|
+
raise InvalidCommandError(
|
|
1075
|
+
"interaction_id reused with a different request digest",
|
|
1076
|
+
details={
|
|
1077
|
+
"reason": REQUEST_CONFLICT,
|
|
1078
|
+
"interaction_id": record.interaction_id,
|
|
1079
|
+
},
|
|
1080
|
+
)
|
|
1081
|
+
return self._interaction_row_to_record(existing)
|
|
1082
|
+
await connection.execute(
|
|
1083
|
+
"""
|
|
1084
|
+
INSERT INTO kernel_interactions (
|
|
1085
|
+
interaction_id, tenant_id, agent_instance_id, session_id,
|
|
1086
|
+
run_id, kind, request_schema, presentation, revision, status,
|
|
1087
|
+
created_at, expires_at, provider_id, native_target,
|
|
1088
|
+
continuation_metadata, request_digest, updated_at
|
|
1089
|
+
) VALUES (
|
|
1090
|
+
$1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, 'pending',
|
|
1091
|
+
$10::timestamptz, $11::timestamptz, $12, $13::jsonb,
|
|
1092
|
+
$14::jsonb, $15, now()
|
|
1093
|
+
)
|
|
1094
|
+
""",
|
|
1095
|
+
record.interaction_id,
|
|
1096
|
+
record.tenant_id,
|
|
1097
|
+
record.agent_instance_id,
|
|
1098
|
+
record.session_id,
|
|
1099
|
+
record.run_id,
|
|
1100
|
+
record.kind,
|
|
1101
|
+
json.dumps(record.request_schema, ensure_ascii=False),
|
|
1102
|
+
(
|
|
1103
|
+
record.presentation.model_dump_json()
|
|
1104
|
+
if record.presentation is not None
|
|
1105
|
+
else None
|
|
1106
|
+
),
|
|
1107
|
+
record.revision,
|
|
1108
|
+
_parse_ts(record.created_at),
|
|
1109
|
+
_parse_ts(record.expires_at),
|
|
1110
|
+
record.provider_id,
|
|
1111
|
+
(
|
|
1112
|
+
json.dumps(record.native_target, ensure_ascii=False)
|
|
1113
|
+
if record.native_target is not None
|
|
1114
|
+
else None
|
|
1115
|
+
),
|
|
1116
|
+
(
|
|
1117
|
+
json.dumps(record.continuation_metadata, ensure_ascii=False)
|
|
1118
|
+
if record.continuation_metadata is not None
|
|
1119
|
+
else None
|
|
1120
|
+
),
|
|
1121
|
+
digest,
|
|
1122
|
+
)
|
|
1123
|
+
# requested 事件与 pending 行同一事务:commit 前被 kill 无半状态。
|
|
1124
|
+
await self._events.append_on(
|
|
1125
|
+
connection, requested_event_payload(record, now_iso()), guard
|
|
1126
|
+
)
|
|
1127
|
+
return record
|
|
1128
|
+
|
|
1129
|
+
async def resolve(
|
|
1130
|
+
self, submission: InteractionSubmission, *, guard: Any
|
|
1131
|
+
) -> InteractionReceipt:
|
|
1132
|
+
sub_digest = submission_digest(submission)
|
|
1133
|
+
async with self._connection() as connection:
|
|
1134
|
+
async with connection.transaction():
|
|
1135
|
+
row = await self._interaction_row_for_guard(
|
|
1136
|
+
connection, submission.interaction_id, guard
|
|
1137
|
+
)
|
|
1138
|
+
if row is None:
|
|
1139
|
+
raise InvalidCommandError(
|
|
1140
|
+
f"unknown interaction_id {submission.interaction_id!r}"
|
|
1141
|
+
)
|
|
1142
|
+
await self._assert_interaction_guard(
|
|
1143
|
+
connection, row["agent_instance_id"], row["session_id"], guard
|
|
1144
|
+
)
|
|
1145
|
+
current = self._interaction_row_to_record(row)
|
|
1146
|
+
if is_terminal(current.status):
|
|
1147
|
+
existing_sub = await connection.fetchrow(
|
|
1148
|
+
"SELECT submission_digest, receipt FROM"
|
|
1149
|
+
" kernel_interaction_submissions WHERE tenant_id=$1"
|
|
1150
|
+
" AND interaction_id=$2 AND idempotency_key=$3",
|
|
1151
|
+
current.tenant_id,
|
|
1152
|
+
current.interaction_id,
|
|
1153
|
+
submission.idempotency_key,
|
|
1154
|
+
)
|
|
1155
|
+
if (
|
|
1156
|
+
existing_sub is not None
|
|
1157
|
+
and existing_sub["submission_digest"] == sub_digest
|
|
1158
|
+
):
|
|
1159
|
+
return InteractionReceipt.model_validate(
|
|
1160
|
+
json.loads(existing_sub["receipt"])
|
|
1161
|
+
)
|
|
1162
|
+
raise InvalidCommandError(
|
|
1163
|
+
f"interaction already reached terminal status"
|
|
1164
|
+
f" {current.status!r}",
|
|
1165
|
+
details={
|
|
1166
|
+
"reason": ALREADY_RESOLVED,
|
|
1167
|
+
"interaction_id": current.interaction_id,
|
|
1168
|
+
},
|
|
1169
|
+
)
|
|
1170
|
+
if current.revision != submission.expected_revision:
|
|
1171
|
+
raise InvalidCommandError(
|
|
1172
|
+
"interaction revision does not match expected_revision",
|
|
1173
|
+
details={
|
|
1174
|
+
"reason": REVISION_MISMATCH,
|
|
1175
|
+
"interaction_id": current.interaction_id,
|
|
1176
|
+
"expected_revision": submission.expected_revision,
|
|
1177
|
+
"current_revision": current.revision,
|
|
1178
|
+
},
|
|
1179
|
+
)
|
|
1180
|
+
outcome = resolve_outcome(submission.action)
|
|
1181
|
+
updated = current.model_copy(
|
|
1182
|
+
update={"status": "resolved", "revision": current.revision + 1}
|
|
1183
|
+
)
|
|
1184
|
+
stored = await self._events.append_on(
|
|
1185
|
+
connection,
|
|
1186
|
+
interaction_event(
|
|
1187
|
+
updated,
|
|
1188
|
+
event_type="interaction.resolved",
|
|
1189
|
+
timestamp=now_iso(),
|
|
1190
|
+
outcome=outcome,
|
|
1191
|
+
response=submission.response,
|
|
1192
|
+
actor_ref="user",
|
|
1193
|
+
),
|
|
1194
|
+
guard,
|
|
1195
|
+
)
|
|
1196
|
+
receipt = InteractionReceipt(
|
|
1197
|
+
interaction_id=updated.interaction_id,
|
|
1198
|
+
revision=updated.revision,
|
|
1199
|
+
status="resolved",
|
|
1200
|
+
outcome=outcome, # type: ignore[arg-type]
|
|
1201
|
+
event_id=str(stored.event_id),
|
|
1202
|
+
accepted_seq=stored.seq,
|
|
1203
|
+
)
|
|
1204
|
+
await connection.execute(
|
|
1205
|
+
"UPDATE kernel_interactions SET revision=$1, status='resolved',"
|
|
1206
|
+
" response=$2::jsonb, outcome=$3, actor=$4, event_id=$5::uuid,"
|
|
1207
|
+
" accepted_seq=$6, fencing_token=$7, updated_at=now()"
|
|
1208
|
+
" WHERE tenant_id=$8 AND interaction_id=$9",
|
|
1209
|
+
updated.revision,
|
|
1210
|
+
json.dumps(submission.response, ensure_ascii=False),
|
|
1211
|
+
outcome,
|
|
1212
|
+
"user",
|
|
1213
|
+
str(stored.event_id),
|
|
1214
|
+
stored.seq,
|
|
1215
|
+
int(guard.fencing_token),
|
|
1216
|
+
updated.tenant_id,
|
|
1217
|
+
updated.interaction_id,
|
|
1218
|
+
)
|
|
1219
|
+
await connection.execute(
|
|
1220
|
+
"INSERT INTO kernel_interaction_submissions (tenant_id,"
|
|
1221
|
+
" interaction_id, idempotency_key, submission_digest, receipt)"
|
|
1222
|
+
" VALUES ($1, $2, $3, $4, $5::jsonb) ON CONFLICT DO NOTHING",
|
|
1223
|
+
updated.tenant_id,
|
|
1224
|
+
updated.interaction_id,
|
|
1225
|
+
submission.idempotency_key,
|
|
1226
|
+
sub_digest,
|
|
1227
|
+
receipt.model_dump_json(),
|
|
1228
|
+
)
|
|
1229
|
+
return receipt
|
|
1230
|
+
|
|
1231
|
+
async def _terminal_command(
|
|
1232
|
+
self,
|
|
1233
|
+
interaction_id: str,
|
|
1234
|
+
expected_revision: int,
|
|
1235
|
+
*,
|
|
1236
|
+
guard: Any,
|
|
1237
|
+
status: str,
|
|
1238
|
+
reason: str,
|
|
1239
|
+
) -> InteractionReceipt:
|
|
1240
|
+
async with self._connection() as connection:
|
|
1241
|
+
async with connection.transaction():
|
|
1242
|
+
row = await self._interaction_row_for_guard(
|
|
1243
|
+
connection, interaction_id, guard
|
|
1244
|
+
)
|
|
1245
|
+
if row is None:
|
|
1246
|
+
raise InvalidCommandError(
|
|
1247
|
+
f"unknown interaction_id {interaction_id!r}"
|
|
1248
|
+
)
|
|
1249
|
+
await self._assert_interaction_guard(
|
|
1250
|
+
connection, row["agent_instance_id"], row["session_id"], guard
|
|
1251
|
+
)
|
|
1252
|
+
current = self._interaction_row_to_record(row)
|
|
1253
|
+
if is_terminal(current.status):
|
|
1254
|
+
raise InvalidCommandError(
|
|
1255
|
+
f"interaction already reached terminal status"
|
|
1256
|
+
f" {current.status!r}",
|
|
1257
|
+
details={
|
|
1258
|
+
"reason": ALREADY_RESOLVED,
|
|
1259
|
+
"interaction_id": current.interaction_id,
|
|
1260
|
+
},
|
|
1261
|
+
)
|
|
1262
|
+
if current.revision != expected_revision:
|
|
1263
|
+
raise InvalidCommandError(
|
|
1264
|
+
"interaction revision does not match expected_revision",
|
|
1265
|
+
details={
|
|
1266
|
+
"reason": REVISION_MISMATCH,
|
|
1267
|
+
"interaction_id": current.interaction_id,
|
|
1268
|
+
"expected_revision": expected_revision,
|
|
1269
|
+
"current_revision": current.revision,
|
|
1270
|
+
},
|
|
1271
|
+
)
|
|
1272
|
+
updated = current.model_copy(
|
|
1273
|
+
update={"status": status, "revision": current.revision + 1}
|
|
1274
|
+
)
|
|
1275
|
+
event_type = (
|
|
1276
|
+
"interaction.cancelled"
|
|
1277
|
+
if status == "cancelled"
|
|
1278
|
+
else "interaction.expired"
|
|
1279
|
+
)
|
|
1280
|
+
stored = await self._events.append_on(
|
|
1281
|
+
connection,
|
|
1282
|
+
interaction_event(
|
|
1283
|
+
updated,
|
|
1284
|
+
event_type=event_type,
|
|
1285
|
+
timestamp=now_iso(),
|
|
1286
|
+
reason=reason,
|
|
1287
|
+
),
|
|
1288
|
+
guard,
|
|
1289
|
+
)
|
|
1290
|
+
receipt = InteractionReceipt(
|
|
1291
|
+
interaction_id=updated.interaction_id,
|
|
1292
|
+
revision=updated.revision,
|
|
1293
|
+
status=updated.status, # type: ignore[arg-type]
|
|
1294
|
+
outcome=updated.status, # type: ignore[arg-type]
|
|
1295
|
+
event_id=str(stored.event_id),
|
|
1296
|
+
accepted_seq=stored.seq,
|
|
1297
|
+
)
|
|
1298
|
+
await connection.execute(
|
|
1299
|
+
"UPDATE kernel_interactions SET revision=$1, status=$2,"
|
|
1300
|
+
" outcome=$3, event_id=$4::uuid, accepted_seq=$5,"
|
|
1301
|
+
" fencing_token=$6, updated_at=now()"
|
|
1302
|
+
" WHERE tenant_id=$7 AND interaction_id=$8",
|
|
1303
|
+
updated.revision,
|
|
1304
|
+
status,
|
|
1305
|
+
status,
|
|
1306
|
+
str(stored.event_id),
|
|
1307
|
+
stored.seq,
|
|
1308
|
+
int(guard.fencing_token),
|
|
1309
|
+
updated.tenant_id,
|
|
1310
|
+
updated.interaction_id,
|
|
1311
|
+
)
|
|
1312
|
+
return receipt
|
|
1313
|
+
|
|
1314
|
+
async def cancel(
|
|
1315
|
+
self, interaction_id: str, expected_revision: int, *, guard: Any
|
|
1316
|
+
) -> InteractionReceipt:
|
|
1317
|
+
return await self._terminal_command(
|
|
1318
|
+
interaction_id,
|
|
1319
|
+
expected_revision,
|
|
1320
|
+
guard=guard,
|
|
1321
|
+
status="cancelled",
|
|
1322
|
+
reason="cancelled by owner",
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
async def expire(
|
|
1326
|
+
self, interaction_id: str, expected_revision: int, *, guard: Any
|
|
1327
|
+
) -> InteractionReceipt:
|
|
1328
|
+
return await self._terminal_command(
|
|
1329
|
+
interaction_id,
|
|
1330
|
+
expected_revision,
|
|
1331
|
+
guard=guard,
|
|
1332
|
+
status="expired",
|
|
1333
|
+
reason="interaction expired",
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
async def get(
|
|
1337
|
+
self,
|
|
1338
|
+
interaction_id: str,
|
|
1339
|
+
*,
|
|
1340
|
+
tenant_id: str | None = None,
|
|
1341
|
+
agent_instance_id: str | None = None,
|
|
1342
|
+
session_id: str | None = None,
|
|
1343
|
+
run_id: str | None = None,
|
|
1344
|
+
) -> InteractionRecord | None:
|
|
1345
|
+
"""Read public ids only through a complete trusted execution scope."""
|
|
1346
|
+
|
|
1347
|
+
scope = (tenant_id, agent_instance_id, session_id, run_id)
|
|
1348
|
+
async with self._connection() as connection:
|
|
1349
|
+
if any(value is not None for value in scope):
|
|
1350
|
+
if not all(value is not None for value in scope):
|
|
1351
|
+
raise InvalidCommandError(
|
|
1352
|
+
"interaction lookup requires a complete trusted scope",
|
|
1353
|
+
details={"interaction_id": interaction_id},
|
|
1354
|
+
)
|
|
1355
|
+
row = await connection.fetchrow(
|
|
1356
|
+
"SELECT * FROM kernel_interactions WHERE interaction_id=$1"
|
|
1357
|
+
" AND tenant_id=$2 AND agent_instance_id=$3 AND session_id=$4"
|
|
1358
|
+
" AND run_id=$5",
|
|
1359
|
+
interaction_id,
|
|
1360
|
+
tenant_id,
|
|
1361
|
+
agent_instance_id,
|
|
1362
|
+
session_id,
|
|
1363
|
+
run_id,
|
|
1364
|
+
)
|
|
1365
|
+
return self._interaction_row_to_record(row) if row is not None else None
|
|
1366
|
+
rows = await connection.fetch(
|
|
1367
|
+
"SELECT * FROM kernel_interactions WHERE interaction_id=$1 LIMIT 2",
|
|
1368
|
+
interaction_id,
|
|
1369
|
+
)
|
|
1370
|
+
if len(rows) > 1:
|
|
1371
|
+
raise InvalidCommandError(
|
|
1372
|
+
f"interaction_id {interaction_id!r} is ambiguous without trusted scope",
|
|
1373
|
+
details={"reason": REQUEST_CONFLICT, "interaction_id": interaction_id},
|
|
1374
|
+
)
|
|
1375
|
+
return self._interaction_row_to_record(rows[0]) if rows else None
|
|
1376
|
+
|
|
1377
|
+
async def list_pending_interactions(
|
|
1378
|
+
self, tenant_id: str, session_id: str
|
|
1379
|
+
) -> list[InteractionRecord]:
|
|
1380
|
+
async with self._connection() as connection:
|
|
1381
|
+
rows = await connection.fetch(
|
|
1382
|
+
"SELECT * FROM kernel_interactions WHERE tenant_id=$1 AND session_id=$2"
|
|
1383
|
+
" AND status='pending' ORDER BY created_at",
|
|
1384
|
+
tenant_id,
|
|
1385
|
+
session_id,
|
|
1386
|
+
)
|
|
1387
|
+
return [self._interaction_row_to_record(row) for row in rows]
|
|
1388
|
+
|
|
1389
|
+
# ------------------------------------------------------------- activations
|
|
1390
|
+
|
|
1391
|
+
async def acquire_activation(self, request: ActivationLeaseRequest) -> ActivationLease:
|
|
1392
|
+
async with self._connection() as connection:
|
|
1393
|
+
async with connection.transaction():
|
|
1394
|
+
row = await connection.fetchrow(
|
|
1395
|
+
"""
|
|
1396
|
+
INSERT INTO kernel_activations (
|
|
1397
|
+
agent_instance_id, session_id, activation_id, fencing_token,
|
|
1398
|
+
lease_expires_at, runtime_type, bundle_digest, capability_digest
|
|
1399
|
+
) VALUES (
|
|
1400
|
+
$1, $2, $3, 1, now() + make_interval(secs => $4), $5, $6, $7
|
|
1401
|
+
)
|
|
1402
|
+
ON CONFLICT (agent_instance_id, session_id) DO UPDATE SET
|
|
1403
|
+
activation_id = excluded.activation_id,
|
|
1404
|
+
fencing_token = CASE
|
|
1405
|
+
WHEN kernel_activations.activation_id = excluded.activation_id
|
|
1406
|
+
THEN kernel_activations.fencing_token
|
|
1407
|
+
ELSE kernel_activations.fencing_token + 1
|
|
1408
|
+
END,
|
|
1409
|
+
lease_expires_at = excluded.lease_expires_at,
|
|
1410
|
+
released = FALSE,
|
|
1411
|
+
runtime_type = excluded.runtime_type,
|
|
1412
|
+
bundle_digest = excluded.bundle_digest,
|
|
1413
|
+
capability_digest = excluded.capability_digest
|
|
1414
|
+
WHERE kernel_activations.released
|
|
1415
|
+
OR kernel_activations.lease_expires_at <= now()
|
|
1416
|
+
OR kernel_activations.activation_id = excluded.activation_id
|
|
1417
|
+
RETURNING fencing_token, lease_expires_at
|
|
1418
|
+
""",
|
|
1419
|
+
request.agent_instance_id,
|
|
1420
|
+
request.session_id,
|
|
1421
|
+
request.activation_id,
|
|
1422
|
+
request.lease_ttl_seconds,
|
|
1423
|
+
request.runtime_type,
|
|
1424
|
+
request.bundle_digest,
|
|
1425
|
+
request.capability_digest,
|
|
1426
|
+
)
|
|
1427
|
+
if row is None:
|
|
1428
|
+
holder = await connection.fetchrow(
|
|
1429
|
+
"SELECT activation_id, lease_expires_at FROM kernel_activations"
|
|
1430
|
+
" WHERE agent_instance_id=$1 AND session_id=$2",
|
|
1431
|
+
request.agent_instance_id,
|
|
1432
|
+
request.session_id,
|
|
1433
|
+
)
|
|
1434
|
+
raise InvalidCommandError(
|
|
1435
|
+
"activation lease is still held by another owner",
|
|
1436
|
+
details={
|
|
1437
|
+
"holder": holder["activation_id"] if holder else None,
|
|
1438
|
+
"lease_expires_at": (
|
|
1439
|
+
holder["lease_expires_at"].isoformat() if holder else None
|
|
1440
|
+
),
|
|
1441
|
+
},
|
|
1442
|
+
)
|
|
1443
|
+
return ActivationLease(
|
|
1444
|
+
agent_instance_id=request.agent_instance_id,
|
|
1445
|
+
activation_id=request.activation_id,
|
|
1446
|
+
fencing_token=int(row["fencing_token"]),
|
|
1447
|
+
lease_expires_at=row["lease_expires_at"].isoformat(),
|
|
1448
|
+
bundle_digest=request.bundle_digest,
|
|
1449
|
+
runtime_type=request.runtime_type,
|
|
1450
|
+
capability_digest=request.capability_digest,
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
async def renew_activation(
|
|
1454
|
+
self, activation_id: str, *, expected_fence: int, lease_ttl_seconds: float
|
|
1455
|
+
) -> ActivationLease:
|
|
1456
|
+
async with self._connection() as connection:
|
|
1457
|
+
async with connection.transaction():
|
|
1458
|
+
row = await connection.fetchrow(
|
|
1459
|
+
"SELECT * FROM kernel_activations WHERE activation_id=$1 FOR UPDATE",
|
|
1460
|
+
activation_id,
|
|
1461
|
+
)
|
|
1462
|
+
if row is None:
|
|
1463
|
+
raise InvalidCommandError(f"unknown activation_id {activation_id!r}")
|
|
1464
|
+
if (
|
|
1465
|
+
row["released"]
|
|
1466
|
+
or row["lease_expires_at"] <= _now()
|
|
1467
|
+
or int(row["fencing_token"]) != int(expected_fence)
|
|
1468
|
+
):
|
|
1469
|
+
raise StaleFenceError(
|
|
1470
|
+
f"cannot renew activation {activation_id!r} at fence {expected_fence}"
|
|
1471
|
+
)
|
|
1472
|
+
expires_at = await connection.fetchval(
|
|
1473
|
+
"UPDATE kernel_activations SET lease_expires_at = now()"
|
|
1474
|
+
" + make_interval(secs => $1) WHERE activation_id=$2"
|
|
1475
|
+
" RETURNING lease_expires_at",
|
|
1476
|
+
lease_ttl_seconds,
|
|
1477
|
+
activation_id,
|
|
1478
|
+
)
|
|
1479
|
+
return ActivationLease(
|
|
1480
|
+
agent_instance_id=row["agent_instance_id"],
|
|
1481
|
+
activation_id=activation_id,
|
|
1482
|
+
fencing_token=int(row["fencing_token"]),
|
|
1483
|
+
lease_expires_at=expires_at.isoformat(),
|
|
1484
|
+
bundle_digest=row["bundle_digest"],
|
|
1485
|
+
runtime_type=row["runtime_type"],
|
|
1486
|
+
capability_digest=row["capability_digest"],
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1489
|
+
async def release_activation(self, activation_id: str, *, expected_fence: int) -> None:
|
|
1490
|
+
async with self._connection() as connection:
|
|
1491
|
+
async with connection.transaction():
|
|
1492
|
+
row = await connection.fetchrow(
|
|
1493
|
+
"SELECT fencing_token, released FROM kernel_activations"
|
|
1494
|
+
" WHERE activation_id=$1 FOR UPDATE",
|
|
1495
|
+
activation_id,
|
|
1496
|
+
)
|
|
1497
|
+
if row is None:
|
|
1498
|
+
raise InvalidCommandError(f"unknown activation_id {activation_id!r}")
|
|
1499
|
+
if row["released"] or int(row["fencing_token"]) != int(expected_fence):
|
|
1500
|
+
raise StaleFenceError(
|
|
1501
|
+
f"cannot release activation {activation_id!r} at fence {expected_fence}"
|
|
1502
|
+
)
|
|
1503
|
+
await connection.execute(
|
|
1504
|
+
"UPDATE kernel_activations SET released=TRUE, lease_expires_at=now()"
|
|
1505
|
+
" WHERE activation_id=$1",
|
|
1506
|
+
activation_id,
|
|
1507
|
+
)
|
|
1508
|
+
|
|
1509
|
+
# ------------------------------------------------------------------ events
|
|
1510
|
+
|
|
1511
|
+
async def append_event(
|
|
1512
|
+
self,
|
|
1513
|
+
envelope: SessionEventEnvelope,
|
|
1514
|
+
*,
|
|
1515
|
+
expected_fence: int,
|
|
1516
|
+
agent_instance_id: str | None = None,
|
|
1517
|
+
) -> SessionEventEnvelope:
|
|
1518
|
+
async with self._connection() as connection:
|
|
1519
|
+
async with connection.transaction():
|
|
1520
|
+
activation = await self._resolve_activation(
|
|
1521
|
+
connection, envelope.session_id, agent_instance_id
|
|
1522
|
+
)
|
|
1523
|
+
# fence 比较必须发生在 event insert 之前且同一事务。
|
|
1524
|
+
await self._assert_fence(
|
|
1525
|
+
connection,
|
|
1526
|
+
activation["agent_instance_id"],
|
|
1527
|
+
envelope.session_id,
|
|
1528
|
+
expected_fence,
|
|
1529
|
+
)
|
|
1530
|
+
return await self._append_activation_fact(
|
|
1531
|
+
connection, envelope, activation, expected_fence
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
async def _resolve_activation(
|
|
1535
|
+
self, connection: Any, session_id: str, agent_instance_id: str | None
|
|
1536
|
+
) -> dict[str, Any]:
|
|
1537
|
+
if agent_instance_id is not None:
|
|
1538
|
+
row = await connection.fetchrow(
|
|
1539
|
+
"SELECT activation_id, agent_instance_id, session_id, released,"
|
|
1540
|
+
" fencing_token, lease_expires_at FROM kernel_activations"
|
|
1541
|
+
" WHERE agent_instance_id=$1 AND session_id=$2",
|
|
1542
|
+
agent_instance_id,
|
|
1543
|
+
session_id,
|
|
1544
|
+
)
|
|
1545
|
+
if row is None or row["released"]:
|
|
1546
|
+
raise StaleFenceError(
|
|
1547
|
+
"no active activation lease",
|
|
1548
|
+
details={"agent_instance_id": agent_instance_id, "session_id": session_id},
|
|
1549
|
+
)
|
|
1550
|
+
return dict(row)
|
|
1551
|
+
rows = await connection.fetch(
|
|
1552
|
+
"SELECT activation_id, agent_instance_id, session_id, released,"
|
|
1553
|
+
" fencing_token, lease_expires_at FROM kernel_activations WHERE session_id=$1",
|
|
1554
|
+
session_id,
|
|
1555
|
+
)
|
|
1556
|
+
active = [dict(row) for row in rows if not row["released"]]
|
|
1557
|
+
if len(active) != 1:
|
|
1558
|
+
raise StaleFenceError(
|
|
1559
|
+
"cannot resolve a single activation lease for session",
|
|
1560
|
+
details={"session_id": session_id, "matches": len(active)},
|
|
1561
|
+
)
|
|
1562
|
+
return active[0]
|
|
1563
|
+
|
|
1564
|
+
# -------------------------------------------------------------------- runs
|
|
1565
|
+
|
|
1566
|
+
async def load_run(self, run_id: str) -> RunRecord | None:
|
|
1567
|
+
async with self._connection() as connection:
|
|
1568
|
+
row = await connection.fetchrow(
|
|
1569
|
+
"SELECT * FROM kernel_runs WHERE run_id=$1", run_id
|
|
1570
|
+
)
|
|
1571
|
+
if row is None:
|
|
1572
|
+
return None
|
|
1573
|
+
return RunRecord(
|
|
1574
|
+
run_id=row["run_id"],
|
|
1575
|
+
agent_instance_id=row["agent_instance_id"],
|
|
1576
|
+
session_id=row["session_id"],
|
|
1577
|
+
state=row["state"],
|
|
1578
|
+
activation_fence=int(row["activation_fence"]),
|
|
1579
|
+
created_at=row["created_at"].isoformat(),
|
|
1580
|
+
updated_at=row["updated_at"].isoformat(),
|
|
1581
|
+
metadata=json.loads(row["metadata"]),
|
|
1582
|
+
)
|
|
1583
|
+
|
|
1584
|
+
async def save_run_transition(
|
|
1585
|
+
self, run: RunRecord, *, expected_fence: int
|
|
1586
|
+
) -> RunRecord:
|
|
1587
|
+
async with self._connection() as connection:
|
|
1588
|
+
async with connection.transaction():
|
|
1589
|
+
activation = await self._assert_fence(
|
|
1590
|
+
connection, run.agent_instance_id, run.session_id, expected_fence
|
|
1591
|
+
)
|
|
1592
|
+
existing = await connection.fetchrow(
|
|
1593
|
+
"SELECT * FROM kernel_runs WHERE run_id=$1", run.run_id
|
|
1594
|
+
)
|
|
1595
|
+
assert_run_transition(
|
|
1596
|
+
RunState(existing["state"]) if existing is not None else None, run.state
|
|
1597
|
+
)
|
|
1598
|
+
if is_active_run(run.state):
|
|
1599
|
+
clash = await connection.fetchval(
|
|
1600
|
+
"SELECT run_id FROM kernel_runs WHERE session_id=$1 AND run_id <> $2"
|
|
1601
|
+
" AND state IN ('running','paused','waiting')",
|
|
1602
|
+
run.session_id,
|
|
1603
|
+
run.run_id,
|
|
1604
|
+
)
|
|
1605
|
+
if clash is not None:
|
|
1606
|
+
raise InvalidCommandError(
|
|
1607
|
+
"session already has an active run",
|
|
1608
|
+
details={"session_id": run.session_id, "active_run_id": clash},
|
|
1609
|
+
)
|
|
1610
|
+
timestamp = now_iso()
|
|
1611
|
+
stored = run.model_copy(
|
|
1612
|
+
update={
|
|
1613
|
+
"activation_fence": int(expected_fence),
|
|
1614
|
+
"created_at": (
|
|
1615
|
+
existing["created_at"].isoformat() if existing else timestamp
|
|
1616
|
+
),
|
|
1617
|
+
"updated_at": timestamp,
|
|
1618
|
+
}
|
|
1619
|
+
)
|
|
1620
|
+
await connection.execute(
|
|
1621
|
+
"""
|
|
1622
|
+
INSERT INTO kernel_runs (run_id, tenant_id, agent_instance_id, session_id,
|
|
1623
|
+
state, activation_fence, created_at, updated_at, metadata)
|
|
1624
|
+
VALUES ($1, $2, $3, $4, $5, $6, now(), now(), $7::jsonb)
|
|
1625
|
+
ON CONFLICT (run_id) DO UPDATE SET
|
|
1626
|
+
state = excluded.state,
|
|
1627
|
+
activation_fence = excluded.activation_fence,
|
|
1628
|
+
updated_at = now(),
|
|
1629
|
+
metadata = excluded.metadata
|
|
1630
|
+
""",
|
|
1631
|
+
stored.run_id,
|
|
1632
|
+
self.tenant_id,
|
|
1633
|
+
stored.agent_instance_id,
|
|
1634
|
+
stored.session_id,
|
|
1635
|
+
stored.state.value,
|
|
1636
|
+
stored.activation_fence,
|
|
1637
|
+
json.dumps(stored.metadata, ensure_ascii=False),
|
|
1638
|
+
)
|
|
1639
|
+
await self._append_activation_fact(
|
|
1640
|
+
connection,
|
|
1641
|
+
control_event(
|
|
1642
|
+
session_id=run.session_id,
|
|
1643
|
+
event_type="control.run_transition",
|
|
1644
|
+
payload={
|
|
1645
|
+
"run_id": run.run_id,
|
|
1646
|
+
"state": run.state.value,
|
|
1647
|
+
"fencing_token": int(expected_fence),
|
|
1648
|
+
},
|
|
1649
|
+
run_id=run.run_id,
|
|
1650
|
+
),
|
|
1651
|
+
activation,
|
|
1652
|
+
expected_fence,
|
|
1653
|
+
)
|
|
1654
|
+
return stored
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
class PostgresFencedSessionEventStore:
|
|
1658
|
+
"""事务级 fenced ``SessionEventStore``(Task 4 Step 5)。
|
|
1659
|
+
|
|
1660
|
+
typed RuntimeEvent 写路径(``RuntimeEventStore.append -> append(envelope,
|
|
1661
|
+
guard=ActivationWriteGuard)``)的缺口修复:每个 ActivationWriteGuard
|
|
1662
|
+
append 都在**同一个 PostgreSQL 事务**里先对 activation 行做
|
|
1663
|
+
``FOR SHARE`` compare-fence(activation_id / fencing_token / 未过期 /
|
|
1664
|
+
未 released),再执行 event insert——被 takeover 的旧 owner 在写出任何
|
|
1665
|
+
runtime/progress/terminal 事实之前就被 :class:`StaleFenceError` 回滚。
|
|
1666
|
+
|
|
1667
|
+
AdmissionWriteGuard(accepted/rejected admission 事实)继续由
|
|
1668
|
+
:class:`PostgresAgentKernelStore` 的 writer 事务内联处理;独立调用时
|
|
1669
|
+
走 event log 自开事务。
|
|
1670
|
+
"""
|
|
1671
|
+
|
|
1672
|
+
def __init__(self, store: "PostgresAgentKernelStore") -> None:
|
|
1673
|
+
self._store = store
|
|
1674
|
+
self._log = store._events
|
|
1675
|
+
|
|
1676
|
+
@asynccontextmanager
|
|
1677
|
+
async def _connection(self):
|
|
1678
|
+
async with self._store._connection() as connection:
|
|
1679
|
+
yield connection
|
|
1680
|
+
|
|
1681
|
+
async def append(
|
|
1682
|
+
self, envelope: SessionEventEnvelope, *, guard: Any
|
|
1683
|
+
) -> SessionEventEnvelope:
|
|
1684
|
+
from ksadk.events.session_event import validate_write_guard
|
|
1685
|
+
|
|
1686
|
+
validate_write_guard(envelope, guard)
|
|
1687
|
+
if isinstance(guard, ActivationWriteGuard):
|
|
1688
|
+
async with self._connection() as connection:
|
|
1689
|
+
async with connection.transaction():
|
|
1690
|
+
await self._assert_activation_fence(
|
|
1691
|
+
connection, guard, envelope.session_id
|
|
1692
|
+
)
|
|
1693
|
+
return await self._log.append_on(connection, envelope, guard)
|
|
1694
|
+
return await self._log.append(envelope, guard=guard)
|
|
1695
|
+
|
|
1696
|
+
async def _assert_activation_fence(
|
|
1697
|
+
self, connection: Any, guard: ActivationWriteGuard, session_id: str
|
|
1698
|
+
) -> None:
|
|
1699
|
+
row = await connection.fetchrow(
|
|
1700
|
+
"SELECT activation_id, fencing_token, lease_expires_at, released"
|
|
1701
|
+
" FROM kernel_activations WHERE activation_id = $1 AND session_id = $2"
|
|
1702
|
+
" FOR SHARE",
|
|
1703
|
+
guard.activation_id,
|
|
1704
|
+
session_id,
|
|
1705
|
+
)
|
|
1706
|
+
if (
|
|
1707
|
+
row is None
|
|
1708
|
+
or row["released"]
|
|
1709
|
+
or row["lease_expires_at"] <= _now()
|
|
1710
|
+
or int(row["fencing_token"]) != int(guard.fencing_token)
|
|
1711
|
+
):
|
|
1712
|
+
raise StaleFenceError(
|
|
1713
|
+
"activation lease does not match runtime event write guard",
|
|
1714
|
+
details={
|
|
1715
|
+
"activation_id": guard.activation_id,
|
|
1716
|
+
"expected_fence": int(guard.fencing_token),
|
|
1717
|
+
"observed_activation_id": (
|
|
1718
|
+
str(row["activation_id"]) if row is not None else None
|
|
1719
|
+
),
|
|
1720
|
+
"observed_fence": (
|
|
1721
|
+
int(row["fencing_token"]) if row is not None else None
|
|
1722
|
+
),
|
|
1723
|
+
"released": bool(row["released"]) if row is not None else None,
|
|
1724
|
+
"lease_expires_at": (
|
|
1725
|
+
row["lease_expires_at"].isoformat() if row is not None else None
|
|
1726
|
+
),
|
|
1727
|
+
},
|
|
1728
|
+
)
|
|
1729
|
+
|
|
1730
|
+
async def read(
|
|
1731
|
+
self, session_id: str, after_seq: int, limit: int
|
|
1732
|
+
) -> list[SessionEventEnvelope]:
|
|
1733
|
+
return await self._log.read(session_id, after_seq, limit)
|
|
1734
|
+
|
|
1735
|
+
async def subscribe(
|
|
1736
|
+
self, session_id: str, after_seq: int, *, poll_interval: float = 0.25
|
|
1737
|
+
):
|
|
1738
|
+
import asyncio
|
|
1739
|
+
|
|
1740
|
+
cursor = int(after_seq or 0)
|
|
1741
|
+
while True:
|
|
1742
|
+
envelopes = await self._log.read(session_id, cursor, 1000)
|
|
1743
|
+
for envelope in envelopes:
|
|
1744
|
+
cursor = max(cursor, int(envelope.seq))
|
|
1745
|
+
yield envelope
|
|
1746
|
+
await asyncio.sleep(poll_interval)
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
__all__ = [
|
|
1750
|
+
"PostgresAgentKernelStore",
|
|
1751
|
+
"PostgresFencedSessionEventStore",
|
|
1752
|
+
"PostgresKernelEventLog",
|
|
1753
|
+
"PostgresNonceStore",
|
|
1754
|
+
"SCHEMA_PATH",
|
|
1755
|
+
"NONCE_RETENTION_SECONDS",
|
|
1756
|
+
]
|